New Electron application. Import old AP.Mail HTML/JS version and improve it a bit.

This commit is contained in:
Feufochmar 2020-03-25 13:37:11 +01:00
commit b811b09fa5
11 changed files with 1263 additions and 0 deletions

193
apmail.css Normal file
View File

@ -0,0 +1,193 @@
/* Body */
body {
background: hsl(240, 10%, 40%);
margin-left: 1.5%;
margin-right: 1.5%;
color: hsl(240, 10%, 15%);
border-color: hsl(240, 10%, 15%);
}
/* Header */
header {
padding: 0;
margin: 0;
background-color: hsl(240, 10%, 75%);
border-style: solid;
border-width: thin;
border-radius: 0.5em;
padding-top: 0.5em;
padding-bottom: 0.5em;
padding-left: 1em;
padding-right: 1em;
text-align: center;
margin-bottom: 0.5em;
}
nav {
padding: 0;
margin: 0;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
}
.page {
display: none;
}
.page:checked+label {
background-color: hsl(240, 10%, 85%);
}
.page-label {
padding: 0;
margin: 0;
border-style: solid;
border-width: thin;
border-radius: 0.5em 0.5em 0 0;
background-color: hsl(240, 10%, 65%);
padding-top: 0.5em;
padding-bottom: 0.5em;
padding-left: 1em;
padding-right: 1em;
}
/* Main */
h1 {
padding: 0;
margin: 0;
}
h2 {
padding: 0;
margin: 0;
}
h3 {
padding: 0;
margin: 0;
}
h4 {
padding: 0;
margin: 0;
}
h5 {
padding: 0;
margin: 0;
}
h6 {
padding: 0;
margin: 0;
}
main {
padding: 0;
margin: 0;
background-color: hsl(240, 10%, 85%);
border-style: solid;
border-width: thin;
border-radius: 0 0.5em 0.5em 0.5em;
padding-top: 0.5em;
padding-bottom: 0.5em;
padding-left: 1em;
padding-right: 1em;
}
/* Footer */
footer {
padding: 0;
margin: 0;
background-color: hsl(240, 10%, 75%);
border-style: solid;
border-width: thin;
border-radius: 0.5em;
padding-top: 0.5em;
padding-bottom: 0.5em;
padding-left: 1em;
padding-right: 1em;
text-align: center;
margin-top: 0.5em;
}
/* Other */
a:link {
color: hsl(220, 100%, 40%);
text-decoration: none;
}
a:visited {
color: hsl(280, 100%, 40%);
text-decoration: none;
}
a:hover {
color: hsl(220, 100%, 90%);
text-shadow: 2px 0px 1px hsl(220, 100%, 30%), -2px 0px 1px hsl(220, 100%, 30%), 0px 2px 1px hsl(220, 100%, 30%), 0px -2px 1px hsl(220, 100%, 30%),
1px 1px 0px hsl(220, 100%, 30%), 1px -1px 0px hsl(220, 100%, 30%), -1px 1px 0px hsl(220, 100%, 30%), -1px -1px 0px hsl(220, 100%, 30%);
}
hr {
border-style: solid;
border-width: thin;
}
/* Page specific */
ul.recipient-list {
padding: 0;
margin: 0;
display: inline-block;
}
li.actor-display {
display: inline-block;
border-style: solid;
border-width: thin;
border-radius: 0.5em;
padding: 0.1em;
margin: 0;
margin-left: 0.5em;
margin-right: 0.5em;
background-color: hsl(240, 10%, 75%);
}
article.activity:after {
content: "";
display: block;
border-style: solid;
border-width: thin;
margin: 0;
margin-top: 0.5em;
margin-bottom: 0.5em;
}
section.actor-display {
display: inline-block;
margin: 0;
padding: 0.1em;
margin-left: 0.2em;
margin-right: 0.2em;
background-color: hsl(240, 10%, 75%);
}
p {
margin: 0;
padding: 0;
}
section.activity-object-field:after {
content: "";
display: block;
border-style: dotted;
border-width: thin;
margin: 0;
margin-top: 0.2em;
margin-bottom: 0.2em;
}
section.activity-object-content {
background-color: hsl(240, 10%, 95%);
}

102
index.html Normal file
View File

@ -0,0 +1,102 @@
<!DOCTYPE html>
<html>
<head>
<title>AP.Mail</title>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';" />
<link href="apmail.css" rel="stylesheet" type="text/css" media="all" />
<script src="render.js"></script>
</head>
<body onload="UI.checkConnection()">
<nav id="tab-bar" style="visibility:hidden;">
<!-- Profile page tab -->
<input type="radio" id="profile-selector" class="page" name="page" value="profile" checked onchange="UI.showPage('profile')" />
<label for="profile-selector" class="page-label" id="profile-selector-label">Profile</label>
<!-- Send page tab -->
<input type="radio" id="send-selector" class="page" name="page" value="send" checked onchange="UI.showPage('send')" />
<label for="send-selector" class="page-label" id="send-selector-label">Send Message</label>
<!-- Inbox tab -->
<input type="radio" id="inbox-selector" class="page" name="page" value="inbox" onchange="UI.showPage('inbox')" />
<label for="inbox-selector" class="page-label" id="inbox-selector-label">Inbox</label>
<!-- Outbox tab -->
<input type="radio" id="outbox-selector" class="page" name="page" value="outbox" onchange="UI.showPage('outbox')" />
<label for="outbox-selector" class="page-label" id="outbox-selector-label">Outbox</label>
<!-- Lookup user tab -->
<input type="radio" id="lookup-selector" class="page" name="page" value="lookup" onchange="UI.showPage('lookup')" />
<label for="lookup-selector" class="page-label" id="lookup-selector-label">Lookup User</label>
<!-- Disconnection -->
<input type="radio" id="disconnect-selector" class="page" name="page" value="disconnect" onchange="UI.showPage('select-user')" />
<label for="disconnect-selector" class="page-label" id="disconnect-selector-label">Disconnection</label>
</nav>
<!-- Connection pages -->
<main id="select-user">
<h2>Connection</h2>
Indicate the account to connect to. <br/>
<input id="connect-username" type="text" placeholder="user@server" pattern=".*@.*" required /><br/>
<button onclick="UI.selectUser()">Next</button>
<section id="select-user-error"></section>
</main>
<main id="ask-password" style="display:none;">
<h2>Password</h2>
<section id="ask-password-user-info"></section>
Enter the password for this account. <br/>
<input id="connect-password" type="password" placeholder="password" /><br/>
<button onclick="UI.showPage('select-user')">Back</button>
<button onclick="UI.connectUser()">Connect</button>
<section id="ask-password-error"></section>
</main>
<!-- Profile page -->
<main id="profile" style="display:none;">
<section id="profile-info"></section>
</main>
<!-- Send message page -->
<main id="send" style="display:none;">
<h4>Audiance</h4>
<label for="send-message-public-visibility">General visibility</label>
<select id="send-message-public-visibility" onchange="UI.updateSendVisibility()">
<option value="to">Public (to), displayed in local/global timelines</option>
<option value="cc">Public (cc), hidden from local/global timelines</option>
<option value="non">Not public</option>
</select><br/>
<label for="send-message-follower-visibility">Follower's visibility</label>
<select id="send-message-follower-visibility" onchange="UI.updateSendVisibility()">
<option value="to">Public (to)</option>
<option value="cc">Public (cc)</option>
<option value="non">Not sent to followers</option>
</select><br/>
<label for="send-message-to-recipient">To</label>
<ul id="send-message-to" class="recipient-list"></ul>
<input id="send-message-to-recipient" type="text" placeholder="user@instance" size="40" />
<button onclick="UI.addToRecipient()">Add</button><br/>
<label for="send-message-cc-recipient">Cc</label>
<ul id="send-message-cc" class="recipient-list"></ul>
<input id="send-message-cc-recipient" type="text" placeholder="user@instance" size="40" />
<button onclick="UI.addCcRecipient()">Add</button><br/>
<section id="send-message-recipient-error"></section>
<hr/>
<h4>Message</h4>
<input id="send-message-subject" type="text" onchange="UI.updateSendContent()" size="80" placeholder="Subject (optional)"/> <br/>
<textarea id="send-message-content" rows="20" cols="80" placeholder="What do you want to say ?" onchange="UI.updateSendContent()"></textarea> <br/>
<button onclick="UI.sendMessage()">Send</button>
<section id="send-error"></section>
</main>
<!-- Inbox page -->
<main id="inbox" style="display:none;">
<section id="inbox-messages"></section>
<section id="inbox-messages-error"></section>
</main>
<!-- Outbox page -->
<main id="outbox" style="display:none;">
<section id="outbox-messages"></section>
<section id="outbox-messages-error"></section>
</main>
<!-- Lookup page -->
<main id="lookup" style="display:none;">
<input id="lookup-user" type="text" placeholder="user@instance"/> <button onclick="UI.lookupUser()">Lookup user</button> <br/><hr/>
<section id="lookup-user-error"></section>
<section id="lookup-user-info"></section><hr/>
<section id="lookup-user-timeline"></section>
<section id="lookup-user-timeline-error"></section>
</main>
</body>
</html>

47
main.js Normal file
View File

@ -0,0 +1,47 @@
const { app, BrowserWindow } = require('electron')
function createWindow () {
// Create the browser window.
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: true
}
})
// and load the index.html of the app.
win.loadFile('index.html')
// Open the DevTools.
win.webContents.openDevTools()
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(createWindow)
// Quit when all windows are closed.
app.on('window-all-closed', () => {
// On macOS it is common for applications and their menu bar
// to stay active until the user quits explicitly with Cmd + Q
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
app.on('web-contents-created', (event, contents) => {
contents.on('will-navigate', (event, navigationUrl) => {
// Disable all navigation to external webpages
event.preventDefault()
})
})

11
package.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "apmail",
"version": "1.0.0",
"description": "An ActivityPub Client",
"main": "main.js",
"scripts": {
"start": "electron ."
},
"author": "Feufochmar",
"license": "ISC"
}

361
render.js Normal file
View File

@ -0,0 +1,361 @@
const {Actor} = require('./src/actor.js')
const {Timeline} = require('./src/timeline.js')
const {Message} = require('./src/message.js')
const {ConnectedUser} = require('./src/connected-user.js')
// UI actions
var UI = {
// Attributes
composed_message: new Message(),
// Methods
// Get the value of an element
getValue: function(id) {
return window.document.getElementById(id).value
},
// Set the value of an element
setValue: function(id, val) {
window.document.getElementById(id).value = val
},
// Clear the value of an element
clearValue: function(id) {
UI.setValue(id, '')
},
// Actor display
renderActorTag: function(actor) {
var display = '<section class="actor-display">'
if (actor.valid) {
if (actor.info.icon) {
display = display + '<img src="' + actor.info.icon + '" width="32" height="32" /> '
}
display = display
+ '<p style="display:inline-block;"><strong>' + actor.info.display_name + '</strong> <br/>'
+ '<a href="' + actor.urls.profile + '">'
+ actor.name + '@' + actor.server
+ '</a></p>'
} else {
display = display
+ '<p style="display:inline-block;">'
+ '<a href="' + actor.urls.profile + '">'
+ 'Other actor'
+ '</a></p>'
}
display = display + '</section>'
return display
},
// Show the page tab
showPageTab: function(is_shown) {
window.document.getElementById('tab-bar').style.visibility = is_shown ? 'visible' : 'hidden'
},
// Action done when refreshing a page
refreshPage: {
'select-user': function() {
UI.showPageTab(false)
ConnectedUser.disconnect()
UI.clearValue('connect-username')
},
'ask-password': function() {
UI.showPageTab(false)
UI.clearValue('connect-password')
window.document.getElementById('ask-password-user-info').innerHTML = UI.renderActorTag(ConnectedUser.actor)
},
'profile': function() {
UI.showPageTab(true)
UI.updateProfilePage()
},
'send': function() {
UI.showPageTab(true)
UI.composed_message = new Message()
UI.updateSendMessagePage()
},
'inbox': function() {
UI.showPageTab(true)
window.document.getElementById('inbox-messages-error').innerHTML = 'Loading inbox...'
UI.showTimeline('inbox-messages', ConnectedUser.tokens.user.access_token, ConnectedUser.actor.urls.inbox)
},
'outbox': function() {
UI.showPageTab(true)
window.document.getElementById('outbox-messages-error').innerHTML = 'Loading outbox...'
UI.showTimeline('outbox-messages', ConnectedUser.tokens.user.access_token, ConnectedUser.actor.urls.outbox)
},
'lookup': function() {
UI.showPageTab(true)
window.document.getElementById('lookup-user-error').innerHTML = ''
window.document.getElementById('lookup-user-info').innerHTML = ''
window.document.getElementById('lookup-user-timeline').innerHTML = ''
window.document.getElementById('lookup-user-timeline-error').innerHTML = ''
}
},
// Show a given page
showPage: function(page) {
['select-user', 'ask-password', 'profile', 'send', 'inbox', 'outbox', 'lookup'].map(x => window.document.getElementById(x).style.display = 'none')
window.document.getElementById(page).style.display = 'block'
UI.refreshPage[page]()
},
// When the page loads, load the connected user if already connected
checkConnection: function() {
ConnectedUser.loadFromLocalStorage(
function(load_ok, failure_message) {
if (load_ok) {
// TODO: Refresh the access token ?
// No need to show the login pages, go directly to send message page
UI.showPage('send')
} else {
// Show the select user page
UI.showPage('select-user')
}
})
},
// Get the username + servername from an address "user@server"
getUserAndServer: function(address) {
var names = address.split('@')
return {
user: names[0],
server: names[1]
}
},
// When the user enter its address, load the actor representing the user
selectUser: function() {
// Get the user name and server name
var names = UI.getUserAndServer(window.document.getElementById('connect-username').value)
// Load the actor and go to the ask-password page
ConnectedUser.actor.loadFromNameAndServer(
names.user, names.server,
function(load_ok, failure_message) {
if (load_ok) {
UI.showPage('ask-password')
} else {
window.document.getElementById('select-user-error').innerText = 'Error: ' + failure_message
}
})
},
// When the user enter its password, get the access tokens and then go to the send page
connectUser: function() {
// Connect the user and go to the send page
ConnectedUser.connect(
UI.getValue('connect-password'),
function(load_ok, failure_message) {
if (load_ok) {
UI.showPage('send')
} else {
window.document.getElementById('ask-password-error').innerText = 'Error: ' + failure_message
}
})
},
updateProfilePage: function() {
window.document.getElementById('profile-info').innerHTML = UI.renderActor(ConnectedUser.actor)
},
updateSendMessagePage: function() {
UI.clearValue('send-message-to-recipient')
UI.clearValue('send-message-cc-recipient')
UI.setValue('send-message-public-visibility', UI.composed_message.public_visibility)
UI.setValue('send-message-follower-visibility', UI.composed_message.follower_visibility)
UI.setValue('send-message-subject', UI.composed_message.subject)
UI.setValue('send-message-content', UI.composed_message.content)
// TO/CC
window.document.getElementById('send-message-to').innerHTML = UI.composed_message.to.map(
function(element) {
return '<li class="actor-display">' + UI.renderActorTag(element) + ' <button onclick="UI.removeToRecipient(\'' + element.urls.profile + '\')">×</button></li>'
}).join('')
window.document.getElementById('send-message-cc').innerHTML = UI.composed_message.cc.map(
function(element) {
return '<li class="actor-display">' + UI.renderActorTag(element) + ' <button onclick="UI.removeCcRecipient(\'' + element.urls.profile + '\')">×</button></li>'
}).join('')
// Errors
window.document.getElementById('send-message-recipient-error').innerHTML = ''
window.document.getElementById('send-error').innerHTML = ''
},
updateSendVisibility: function() {
UI.composed_message.setVisibility(UI.getValue('send-message-public-visibility'), UI.getValue('send-message-follower-visibility'))
},
updateSendContent: function() {
UI.composed_message.setContent(UI.getValue('send-message-subject'), UI.getValue('send-message-content'))
},
// Add to recipient lists
addToRecipient: function() {
var names = UI.getUserAndServer(window.document.getElementById('send-message-to-recipient').value)
var actor = new Actor()
actor.loadFromNameAndServer(
names.user, names.server,
function(load_ok, failure_message) {
if (load_ok) {
UI.composed_message.addToRecipient(actor)
UI.updateSendMessagePage()
} else {
window.document.getElementById('send-message-recipient-error').innerHTML = 'Unable to find user (' + failure_message + ')'
}
})
},
addCcRecipient: function() {
var names = UI.getUserAndServer(window.document.getElementById('send-message-cc-recipient').value)
var actor = new Actor()
actor.loadFromNameAndServer(
names.user, names.server,
function(load_ok, failure_message) {
if (load_ok) {
UI.composed_message.addCcRecipient(actor)
UI.updateSendMessagePage()
} else {
window.document.getElementById('send-message-recipient-error').innerHTML = 'Unable to find user (' + failure_message + ')'
}
})
},
// Remove from recipient lists
removeToRecipient: function(url_profile) {
// Don't fetch the actor, only set the profile url used in removal
var actor = new Actor()
actor.urls.profile = url_profile
//
UI.composed_message.removeToRecipient(actor)
UI.updateSendMessagePage()
},
removeCcRecipient: function(url_profile) {
var actor = new Actor()
actor.urls.profile = url_profile
//
UI.composed_message.removeCcRecipient(actor)
UI.updateSendMessagePage()
},
// Send message
sendMessage: function() {
UI.composed_message.send(
function(is_ok, failure_message) {
if (is_ok) {
UI.showPage('send')
} else {
window.document.getElementById('send-error').innerText = failure_message
}
})
},
renderRawActivity: function(activity) {
var str_activity = JSON.stringify(activity.raw, null, 1)
var replace_map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
'\'': '&#039;'
}
str_activity = str_activity.replace(/[&<>"']/g, x => replace_map[x])
return '<section class="activity-raw"><details>'
+ '<summary>Raw activity</summary>'
+ '<textarea rows="30" cols="120" readonly>' + str_activity + '</textarea>'
+ '</details></section>'
},
renderObject: {
'Note': function(activity) {
return '<article class="activity create note">'
+ '<section class="activity-object-field">From ' + (activity.actor ? UI.renderActorTag(activity.actor) : '')
+ ' ' + (activity.published ? activity.published.toLocaleString() : '') + '</section>'
+ '<section class="activity-object-field"> To ' + (activity.to ? activity.to.map(x => UI.renderActorTag(x)).join(' ') : '' ) + '</section>'
+ '<section class="activity-object-field"> Cc ' + (activity.cc ? activity.cc.map(x => UI.renderActorTag(x)).join(' ') : '' ) + '</section>'
+ '<section class="activity-object-field">Subject: ' + (activity.object.summary ? activity.object.summary : '' ) + '</section>'
+ '<section class="activity-object-field activity-object-content">' + (activity.object.content ? activity.object.content : '' ) + '</section>'
+ UI.renderRawActivity(activity)
+ '</article>'
}
},
renderActivity: {
'Create': function(activity) {
if (UI.renderObject[activity.object.type]) {
return UI.renderObject[activity.object.type](activity)
} else {
return '<article class="activity create">'
+ 'Object creation (' + activity.object.type + ').'
+ UI.renderRawActivity(activity)
+ '</article>'
}
},
'Like': function(activity) {
return '<article class="activity like">'
+ (activity.actor ? UI.renderActorTag(activity.actor) : '')
+ " liked "
+ (activity.object ? '<a href="' + activity.object + '">' + activity.object + '</a>' : '')
+ UI.renderRawActivity(activity)
+ '</article>'
},
'Announce': function(activity) {
return '<article class="activity share">'
+ (activity.actor ? UI.renderActorTag(activity.actor) : '')
+ " shared "
+ (activity.object ? '<a href="' + activity.object + '">' + activity.object + '</a>' : '')
+ UI.renderRawActivity(activity)
+ '</article>'
},
'Delete': function(activity) {
return '<article class="activity delete">'
+ (activity.actor ? UI.renderActorTag(activity.actor) : '')
+ " deleted an object."
+ UI.renderRawActivity(activity)
+ '</article>'
}
},
showTimeline: function(id, token, url) {
var timeline = new Timeline()
timeline.load(url, token, function(load_ok, failure_message) {
if (load_ok) {
var content = timeline.activities.map(
function(activity) {
if (UI.renderActivity[activity.type]) {
return UI.renderActivity[activity.type](activity)
} else {
return '<article class="activity">'
+ 'Other activity (' + activity.type + ').'
+ UI.renderRawActivity(activity)
+ '</article>'
}
}).join('')
content = content + '<section class="prev-next">'
if (timeline.prev) {
content = content + '<button onclick="UI.showTimeline(\'' + id + '\',' + (token ? '\'' + token + '\'' : 'undefined') + ',\'' + timeline.prev + '\')">Prev</button>'
}
if (timeline.next) {
content = content + '<button onclick="UI.showTimeline(\'' + id + '\',' + (token ? '\'' + token + '\'' : 'undefined') + ',\'' + timeline.next + '\')">Next</button>'
}
content = content + '</section>'
window.document.getElementById(id).innerHTML = content
} else {
window.document.getElementById(id + '-error').innerText = failure_message
}
})
},
renderActor: function(actor) {
var display = '<section>'
if (actor.valid) {
if (actor.info.icon) {
display = display + '<img src="' + actor.info.icon + '" width="96" height="96" /> '
}
display = display
+ '<p style="display:inline-block;"><strong>' + actor.info.display_name + '</strong> <br/>'
+ '<a href="' + actor.urls.profile + '">'
+ actor.name + '@' + actor.server
+ '</a></p>'
+ '<p>' + actor.info.summary + '</p>'
} else {
display = display
+ '<p style="display:inline-block;">'
+ '<a href="' + actor.urls.profile + '">'
+ 'Other actor'
+ '</a></p>'
}
display = display + '</section>'
return display
},
lookupUser: function() {
window.document.getElementById('lookup-user-info').innerHTML = ''
window.document.getElementById('lookup-user-timeline').innerHTML = ''
window.document.getElementById('lookup-user-error').innerHTML = ''
window.document.getElementById('lookup-user-timeline-error').innerHTML = ''
var names = UI.getUserAndServer(window.document.getElementById('lookup-user').value)
var actor = new Actor()
actor.loadFromNameAndServer(
names.user, names.server,
function(load_ok, failure_message) {
if (load_ok) {
window.document.getElementById('lookup-user-info').innerHTML = UI.renderActor(actor)
UI.showTimeline('lookup-user-timeline', undefined, actor.urls.outbox)
} else {
window.document.getElementById('lookup-user-error').innerHTML = 'Unable to find user (' + failure_message + ')'
}
})
}
}

93
src/activity.js Normal file
View File

@ -0,0 +1,93 @@
const {Actor} = require('./actor.js')
const {KnownActors} = require('./known-actors.js')
// Activity class
var Activity = function(raw_activity) {
this.raw = raw_activity
this.type = raw_activity.type
this.published = raw_activity.published ? new Date(raw_activity.published) : undefined
this.object = raw_activity.object
this.public_visibility = 'non'
// actor, to, cc are filled in loadActors
this.actor = undefined
this.to = []
this.cc = []
}
Activity.prototype = {
// Load the actors present in the actor, to and cc fields
loadActors: function(callback) {
// Load the actor
var profile = this.raw.actor
KnownActors.retrieve(
profile,
function(load_ok, failure_message) {
if (load_ok) {
this.actor = KnownActors.get(profile)
}
// Load the actors in the "to" array, skip to "cc" if there is no "to", and stop is there is no "cc" either
if (this.raw.to) {
this.loadToActors(this.raw.to.values(), callback)
} else if (this.raw.cc) {
this.loadCcActors(this.raw.cc.values(), callback)
} else {
callback(true, 'ok')
}
}.bind(this))
},
// Load the "To" array
loadToActors: function(iter, callback) {
var next = iter.next()
if (next.done) {
// Finished: load the "cc" array (if possible)
if (this.raw.cc) {
this.loadCcActors(this.raw.cc.values(), callback)
} else {
callback(true, 'ok')
}
} else {
var profile = next.value
if (profile === 'https://www.w3.org/ns/activitystreams#Public') {
this.public_visibility = 'to'
this.loadToActors(iter, callback)
} else {
KnownActors.retrieve(
profile,
function(load_ok, failure_message) {
if (load_ok) {
this.to.push(KnownActors.get(profile))
} else {
// Collection ?
}
this.loadToActors(iter, callback)
}.bind(this))
}
}
},
// Load the "Cc" array
loadCcActors: function(iter, callback) {
var next = iter.next()
if (next.done) {
callback(true, 'ok')
} else {
var profile = next.value
if (profile === 'https://www.w3.org/ns/activitystreams#Public') {
this.public_visibility = 'cc'
this.loadCcActors(iter, callback)
} else {
KnownActors.retrieve(
profile,
function(load_ok, failure_message) {
if (load_ok) {
this.cc.push(KnownActors.get(profile))
} else {
// Collection ?
}
this.loadCcActors(iter, callback)
}.bind(this))
}
}
}
}
// Exported structures
exports.Activity = Activity

123
src/actor.js Normal file
View File

@ -0,0 +1,123 @@
// Actor class
// ActorInfo structure
var ActorInfo = function() {}
ActorInfo.prototype = {
display_name: undefined,
summary: undefined,
icon: undefined,
type: undefined
}
// ActorUrls structure
var ActorUrls = function() {}
ActorUrls.prototype = {
profile: undefined,
outbox: undefined,
inbox: undefined,
followers: undefined,
following: undefined,
oauth_token: undefined,
oauth_registration: undefined,
oauth_authorization: undefined
}
// Actor structure
var Actor = function() {
this.info = new ActorInfo()
this.urls = new ActorUrls()
}
Actor.prototype = {
// Attributes
name: undefined,
server: undefined,
info: undefined,
urls: undefined,
valid: false,
// Methods
// Load from name@server.
// Callback is a function accepting two arguments:
// - a boolean indicating if the loading is complete or in failure,
// - a string indicating the failure
loadFromNameAndServer: function(name, server, callback) {
this.name = name
this.server = server
// Use Webfinger to find the profile URL
var request = new XMLHttpRequest()
request.onreadystatechange = function() {
if (request.readyState == 4 && request.status == 200) {
var answer = JSON.parse(request.responseText)
if (answer && answer.links) {
var link = answer.links.find(e => e.type === 'application/activity+json');
var profile = undefined
if (link) {
profile = link.href
}
if (profile) {
this.loadFromProfileUrl(profile, callback)
} else {
callback(false, 'webfinger: account exists on server, but no ActivityPub account found.')
}
} else {
callback(false, 'webfinger: incorrect response from server.')
console.log(answer)
}
} else if (request.readyState == 4) {
callback(false, 'webfinger: server error')
}
}.bind(this)
request.open('GET', 'https://' + server + '/.well-known/webfinger' + '?resource=acct:' + name + '@' + server, true)
request.send()
},
// Indicate if the type is compatible with an actor
isExpectedActorType: function(actorType) {
return (actorType === 'Application') || (actorType === 'Group') || (actorType === 'Organization') || (actorType === 'Person') || (actorType === 'Service')
},
// Load from the link identifying the account
// Callback is a function accepting two arguments:
// - a boolean indicating if the loading is complete or in failure,
// - a string indicating the failure
loadFromProfileUrl: function(profile_url, callback) {
this.urls.profile = profile_url
// Use ActivityPub protocol to get the user infos
var request = new XMLHttpRequest()
request.onreadystatechange = function() {
if (request.readyState == 4 && request.status == 200) {
var answer = JSON.parse(request.responseText)
if (answer && this.isExpectedActorType(answer.type)) {
// name and server are previously filled if called from loadFromNameAndServer
if (!this.name) {
this.name = answer.preferredUsername
}
if (!this.server) {
this.server = profile_url.replace('http://', '').replace('https://', '').split(/[/?#]/)[0]
}
this.info.display_name = answer.name
this.info.summary = answer.summary
this.info.icon = answer.icon ? answer.icon.url : undefined
this.info.type = answer.type
this.urls.outbox = answer.outbox
this.urls.inbox = answer.inbox
this.urls.followers = answer.followers
this.urls.following = answer.following
if (answer.endpoints) {
this.urls.oauth_token = answer.endpoints.oauthTokenEndpoint
this.urls.oauth_registration = answer.endpoints.oauthRegistrationEndpoint
this.urls.oauth_authorization = answer.endpoints.oauthAuthorizationEndpoint
}
this.valid = true
callback(true, 'ok')
} else {
callback(false, 'user profile: incorrect response from server')
console.log(answer)
}
} else if (request.readyState == 4) {
callback(false, 'user profile: server error')
}
}.bind(this)
request.open('GET', profile_url, true)
request.setRequestHeader('Content-Type', 'application/activity+json')
request.setRequestHeader('Accept', 'application/activity+json')
request.send()
}
}
// Exported structures
exports.Actor = Actor

145
src/connected-user.js Normal file
View File

@ -0,0 +1,145 @@
const {Actor} = require('./actor.js')
// Connected user structure
var ConnectedUser = {
// Data
actor: new Actor(),
tokens: {
server: {
client_id: undefined,
client_secret: undefined
},
user: {
refresh_token: undefined,
access_token: undefined
}
},
// Methods
// Load data from local storage if possible
// Callback is used to get the status of the loading
loadFromLocalStorage: function (callback) {
var server_name = window.localStorage.getItem('last:server.name')
var user_name = window.localStorage.getItem('last:user.name')
if (server_name && user_name) {
ConnectedUser.tokens.server.client_id = window.localStorage.getItem('client_id:' + server_name)
ConnectedUser.tokens.server.client_secret = window.localStorage.getItem('client_secret:' + server_name)
ConnectedUser.tokens.user.refresh_token = window.localStorage.getItem('refresh_token:' + user_name + '@' + server_name)
ConnectedUser.tokens.user.access_token = window.localStorage.getItem('access_token:' + user_name + '@' + server_name)
// Load the rest with webfinger / activity pub requests
ConnectedUser.actor.loadFromNameAndServer(user_name, server_name, callback)
} else {
callback(false, 'local storage: no data')
}
},
saveToLocalStorage: function () {
window.localStorage.setItem('last:server.name', ConnectedUser.actor.server)
window.localStorage.setItem('last:user.name', ConnectedUser.actor.name)
window.localStorage.setItem('client_id:' + ConnectedUser.actor.server, ConnectedUser.tokens.server.client_id)
window.localStorage.setItem('client_secret:' + ConnectedUser.actor.server, ConnectedUser.tokens.server.client_secret)
window.localStorage.setItem('refresh_token:' + ConnectedUser.actor.name + '@' + ConnectedUser.actor.server, ConnectedUser.tokens.user.refresh_token)
window.localStorage.setItem('access_token:' + ConnectedUser.actor.name + '@' + ConnectedUser.actor.server, ConnectedUser.tokens.user.access_token)
},
// Disconnect user
disconnect: function() {
if (ConnectedUser.actor.name && ConnectedUser.actor.server) {
window.localStorage.removeItem('refresh_token:' + ConnectedUser.actor.name + '@' + ConnectedUser.actor.server)
window.localStorage.removeItem('access_token:' + ConnectedUser.actor.name + '@' + ConnectedUser.actor.server)
}
window.localStorage.removeItem('last:server.name')
window.localStorage.removeItem('last:user.name')
ConnectedUser.actor = new Actor()
ConnectedUser.tokens.server.client_id = undefined
ConnectedUser.tokens.server.client_secret = undefined
ConnectedUser.tokens.user.refresh_token = undefined
ConnectedUser.tokens.user.access_token = undefined
},
// Connection : get tokens from password
connect: function(password, callback) {
// Retrieve server tokens, or generate them
// Then ask for user tokens
ConnectedUser.getServerTokens(
function(load_ok, failure) {
if (load_ok) {
ConnectedUser.getUserTokens(password, callback)
} else {
callback(false, 'OAuth: unable to get client tokens for AP.Mail (' + failure + ')')
}
})
},
// Retrieve server tokens
getServerTokens: function(callback) {
var server = ConnectedUser.actor.server
if (server) {
// try to load the keys from the localStorage
var client_id = window.localStorage.getItem('client_id:' + server)
var client_secret = window.localStorage.getItem('client_secret:' + server)
if (client_id && client_secret) {
ConnectedUser.tokens.server.client_id = client_id
ConnectedUser.tokens.server.client_secret = client_secret
callback(true, 'ok')
} else {
// generate keys and store them
var request = new XMLHttpRequest()
request.onreadystatechange = function() {
if (request.readyState == 4 && request.status == 200) {
var answer = JSON.parse(request.responseText)
// Retrieve the tokens
var client_id = answer.client_id
var client_secret = answer.client_secret
if (client_id && client_secret) {
ConnectedUser.tokens.server.client_id = client_id
ConnectedUser.tokens.server.client_secret = client_secret
// Save tokens in localStorage
window.localStorage.setItem('client_id:' + ConnectedUser.actor.server, ConnectedUser.tokens.server.client_id)
window.localStorage.setItem('client_secret:' + ConnectedUser.actor.server, ConnectedUser.tokens.server.client_secret)
// Continue
callback(true, 'ok')
} else {
callback(false, 'OAuth: server did not register client.')
}
} else if (request.readyState == 4) {
callback(false, 'OAuth: server error when registering client.')
}
}
request.open('POST', ConnectedUser.actor.urls.oauth_registration, true)
var data = new URLSearchParams()
data.append('client_name', 'AP.Mail client')
data.append('redirect_uris', 'urn:ietf:wg:oauth:2.0:oob')
data.append('scopes', 'read write follow')
data.append('website', 'http://feuforeve.fr')
request.send(data)
}
} else {
callback(false, 'OAuth: unable to retrieve client tokens from an unknown server')
}
},
getUserTokens: function(password, callback) {
var request = new XMLHttpRequest()
request.onreadystatechange = function() {
if (request.readyState == 4 && request.status == 200) {
var answer = JSON.parse(request.responseText)
// Retrieve the tokens
ConnectedUser.tokens.user.refresh_token = answer.refresh_token
ConnectedUser.tokens.user.access_token = answer.access_token
// Save the tokens
ConnectedUser.saveToLocalStorage()
// OK, return to callback
callback(true, 'ok')
} else if (request.readyState == 4) {
callback(false, 'OAuth: cannot get user tokens')
}
}
request.open('POST', ConnectedUser.actor.urls.oauth_token, true)
var data = new URLSearchParams()
data.append('client_id', ConnectedUser.tokens.server.client_id)
data.append('client_secret', ConnectedUser.tokens.server.client_secret)
data.append('grant_type', 'password')
data.append('username', ConnectedUser.actor.name)
data.append('password', password)
data.append('scope', 'read write follow')
request.send(data)
}
}
// Exported structures
exports.ConnectedUser = ConnectedUser

29
src/known-actors.js Normal file
View File

@ -0,0 +1,29 @@
const {Actor} = require('./actor.js')
// A cache for actors, to avoid loading same actors several times when loading timelines
var KnownActors = {
actors: {},
get: function(profile) {
return KnownActors.actors[profile]
},
set: function(profile, actor) {
KnownActors.actors[profile] = actor
},
// Retrieve an actor
retrieve: function(profile, callback) {
if (KnownActors.get(profile)) {
callback(true, 'ok')
} else {
var actor = new Actor()
actor.loadFromProfileUrl(
profile,
function(load_ok, failure_message) {
KnownActors.set(profile, actor) // in case of failure, actor is not valid
callback(load_ok, failure_message)
})
}
}
}
// Exported structures
exports.KnownActors = KnownActors

99
src/message.js Normal file
View File

@ -0,0 +1,99 @@
const {ConnectedUser} = require('./connected-user.js')
// Outgoing messages
var Message = function() {
this.to = []
this.cc = []
}
Message.prototype = {
// Attributes
to: [],
cc: [],
public_visibility: 'to', // 'to', 'cc', 'non'
follower_visibility: 'cc', // 'to', 'cc', 'non'
subject: '',
content: '',
type: 'Note',
media_type: 'text/plain', // Pleroma also accepts: text/markdown, text/html, text/bbcode
// Methods
setContent: function(subject, content) {
this.subject = subject
this.content = content
},
setVisibility: function(pub, follower) {
// TODO: filter visibility
this.public_visibility = pub
this.follower_visibility = follower
},
// Add recipients
addToRecipient: function(actor) {
this.to.push(actor)
},
addCcRecipient: function(actor) {
this.cc.push(actor)
},
// Remove recipients
removeToRecipient: function(actor) {
var idx = this.to.findIndex(x => x.urls.profile === actor.urls.profile)
if (idx !== -1) {
this.to.splice(idx, 1)
}
},
removeCcRecipient: function(actor) {
var idx = this.cc.findIndex(x => x.urls.profile === actor.urls.profile)
if (idx !== -1) {
this.cc.splice(idx, 1)
}
},
// Send message
send: function(callback) {
// Recipients
var recipients_to = this.to.map(x => x.urls.profile)
var recipients_cc = this.cc.map(x => x.urls.profile)
if (this.public_visibility === 'to') {
recipients_to.push('https://www.w3.org/ns/activitystreams#Public')
} else if (this.public_visibility === 'cc') {
recipients_cc.push('https://www.w3.org/ns/activitystreams#Public')
}
if (this.follower_visibility === 'to') {
recipients_to.push(ConnectedUser.actor.urls.followers)
} else if (this.follower_visibility === 'cc') {
recipients_cc.push(ConnectedUser.actor.urls.followers)
}
//
var message = {
'@context': 'https://www.w3.org/ns/activitystreams',
type: this.type,
to: recipients_to,
cc: recipients_cc,
summary: this.subject !== '' ? this.subject : null,
content: this.content,
mediaType: this.media_type
}
// Encapsulate in a create activity, although this should not be mandatory
var activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Create',
to: message.to,
cc: message.cc,
object: message
}
var json_message = JSON.stringify(activity)
var request = new XMLHttpRequest()
request.onreadystatechange = function() {
if (request.readyState == 4 && request.status == 201) {
callback(true, 'ok')
} else if (request.readyState == 4) {
callback(false, 'Send: Message not created on server')
}
}
request.open('POST', ConnectedUser.actor.urls.outbox, true)
request.setRequestHeader('Authorization', 'Bearer ' + ConnectedUser.tokens.user.access_token)
request.setRequestHeader('Content-Type', 'application/activity+json')
request.setRequestHeader('Accept', 'application/activity+json')
request.send(json_message)
}
}
// Exported structures
exports.Message = Message

60
src/timeline.js Normal file
View File

@ -0,0 +1,60 @@
const {Activity} = require('./activity.js')
// Timeline class
// Represent a collection of activities
var Timeline = function() {}
Timeline.prototype = {
load: function(url, token, callback) {
var request = new XMLHttpRequest()
request.onreadystatechange = function() {
if (request.readyState == 4 && request.status == 200) {
var answer = JSON.parse(request.responseText)
if (answer.type === 'OrderedCollection' && answer.first && typeof answer.first === 'string') {
// First is a link => load again with the first
this.load(answer.first, token, callback)
} else if (answer.type === 'OrderedCollection' && answer.first && answer.first.type === 'OrderedCollectionPage') {
this.activities = []
this.prev = answer.first.prev
this.next = answer.first.next
this.parseActivities(answer.first.orderedItems, callback)
} else if (answer.type === 'OrderedCollectionPage') {
this.activities = []
this.prev = answer.prev
this.next = answer.next
this.parseActivities(answer.orderedItems, callback)
} else {
callback(false, 'Timeline: unexpected answer from server')
console.log(answer)
}
} else if (request.readyState == 4) {
callback(false, 'Timeline: server error')
}
}.bind(this)
request.open('GET', url, true)
if (token) {
request.setRequestHeader('Authorization', 'Bearer ' + token)
}
request.setRequestHeader('Content-Type', 'application/activity+json')
request.setRequestHeader('Accept', 'application/activity+json')
request.send()
},
parseActivities: function(raw_activities, callback) {
// Get the next activity
var raw_act = raw_activities.shift()
if (raw_act) {
var act = new Activity(raw_act)
act.loadActors(
function(load_ok, failure_message) {
if (load_ok) {
this.activities.push(act)
}
this.parseActivities(raw_activities, callback)
}.bind(this))
} else {
callback(true, 'ok')
}
}
}
// Exported structures
exports.Timeline = Timeline