From b811b09fa50a8c8c3e5a6e9634db3980e94a0f95 Mon Sep 17 00:00:00 2001 From: Feufochmar Date: Wed, 25 Mar 2020 13:37:11 +0100 Subject: [PATCH] New Electron application. Import old AP.Mail HTML/JS version and improve it a bit. --- apmail.css | 193 ++++++++++++++++++++++ index.html | 102 ++++++++++++ main.js | 47 ++++++ package.json | 11 ++ render.js | 361 ++++++++++++++++++++++++++++++++++++++++++ src/activity.js | 93 +++++++++++ src/actor.js | 123 ++++++++++++++ src/connected-user.js | 145 +++++++++++++++++ src/known-actors.js | 29 ++++ src/message.js | 99 ++++++++++++ src/timeline.js | 60 +++++++ 11 files changed, 1263 insertions(+) create mode 100644 apmail.css create mode 100644 index.html create mode 100644 main.js create mode 100644 package.json create mode 100644 render.js create mode 100644 src/activity.js create mode 100644 src/actor.js create mode 100644 src/connected-user.js create mode 100644 src/known-actors.js create mode 100644 src/message.js create mode 100644 src/timeline.js diff --git a/apmail.css b/apmail.css new file mode 100644 index 0000000..923c899 --- /dev/null +++ b/apmail.css @@ -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%); +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..be359c2 --- /dev/null +++ b/index.html @@ -0,0 +1,102 @@ + + + + AP.Mail + + + + + + + + +
+

Connection

+ Indicate the account to connect to.
+
+ +
+
+
+

Password

+
+ Enter the password for this account.
+
+ + +
+
+ +
+
+
+ +
+

Audiance

+ +
+ +
+ + + +
+ + + +
+
+
+

Message

+
+
+ +
+
+ +
+
+
+
+ +
+
+
+
+ +
+

+
+

+
+
+
+ + diff --git a/main.js b/main.js new file mode 100644 index 0000000..a568d79 --- /dev/null +++ b/main.js @@ -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() + }) +}) diff --git a/package.json b/package.json new file mode 100644 index 0000000..7a64773 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "apmail", + "version": "1.0.0", + "description": "An ActivityPub Client", + "main": "main.js", + "scripts": { + "start": "electron ." + }, + "author": "Feufochmar", + "license": "ISC" +} diff --git a/render.js b/render.js new file mode 100644 index 0000000..029fd9b --- /dev/null +++ b/render.js @@ -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 = '
' + if (actor.valid) { + if (actor.info.icon) { + display = display + ' ' + } + display = display + + '

' + actor.info.display_name + '
' + + '' + + actor.name + '@' + actor.server + + '

' + } else { + display = display + + '

' + + '' + + 'Other actor' + + '

' + } + display = display + '
' + 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 '
  • ' + UI.renderActorTag(element) + '
  • ' + }).join('') + window.document.getElementById('send-message-cc').innerHTML = UI.composed_message.cc.map( + function(element) { + return '
  • ' + UI.renderActorTag(element) + '
  • ' + }).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 = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + '\'': ''' + } + str_activity = str_activity.replace(/[&<>"']/g, x => replace_map[x]) + return '
    ' + + 'Raw activity' + + '' + + '
    ' + }, + renderObject: { + 'Note': function(activity) { + return '
    ' + + '
    From ' + (activity.actor ? UI.renderActorTag(activity.actor) : '') + + ' – ' + (activity.published ? activity.published.toLocaleString() : '') + '
    ' + + '
    To ' + (activity.to ? activity.to.map(x => UI.renderActorTag(x)).join(' ') : '' ) + '
    ' + + '
    Cc ' + (activity.cc ? activity.cc.map(x => UI.renderActorTag(x)).join(' ') : '' ) + '
    ' + + '
    Subject: ' + (activity.object.summary ? activity.object.summary : '' ) + '
    ' + + '
    ' + (activity.object.content ? activity.object.content : '' ) + '
    ' + + UI.renderRawActivity(activity) + + '
    ' + } + }, + renderActivity: { + 'Create': function(activity) { + if (UI.renderObject[activity.object.type]) { + return UI.renderObject[activity.object.type](activity) + } else { + return '
    ' + + 'Object creation (' + activity.object.type + ').' + + UI.renderRawActivity(activity) + + '
    ' + } + }, + 'Like': function(activity) { + return '
    ' + + (activity.actor ? UI.renderActorTag(activity.actor) : '') + + " liked " + + (activity.object ? '' + activity.object + '' : '') + + UI.renderRawActivity(activity) + + '
    ' + }, + 'Announce': function(activity) { + return '
    ' + + (activity.actor ? UI.renderActorTag(activity.actor) : '') + + " shared " + + (activity.object ? '' + activity.object + '' : '') + + UI.renderRawActivity(activity) + + '
    ' + }, + 'Delete': function(activity) { + return '
    ' + + (activity.actor ? UI.renderActorTag(activity.actor) : '') + + " deleted an object." + + UI.renderRawActivity(activity) + + '
    ' + } + }, + 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 '
    ' + + 'Other activity (' + activity.type + ').' + + UI.renderRawActivity(activity) + + '
    ' + } + }).join('') + content = content + '
    ' + if (timeline.prev) { + content = content + '' + } + if (timeline.next) { + content = content + '' + } + content = content + '
    ' + window.document.getElementById(id).innerHTML = content + } else { + window.document.getElementById(id + '-error').innerText = failure_message + } + }) + }, + renderActor: function(actor) { + var display = '
    ' + if (actor.valid) { + if (actor.info.icon) { + display = display + ' ' + } + display = display + + '

    ' + actor.info.display_name + '
    ' + + '' + + actor.name + '@' + actor.server + + '

    ' + + '

    ' + actor.info.summary + '

    ' + } else { + display = display + + '

    ' + + '' + + 'Other actor' + + '

    ' + } + display = display + '
    ' + 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 + ')' + } + }) + } +} diff --git a/src/activity.js b/src/activity.js new file mode 100644 index 0000000..2c9b204 --- /dev/null +++ b/src/activity.js @@ -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 diff --git a/src/actor.js b/src/actor.js new file mode 100644 index 0000000..d9de7cc --- /dev/null +++ b/src/actor.js @@ -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 diff --git a/src/connected-user.js b/src/connected-user.js new file mode 100644 index 0000000..cb3a727 --- /dev/null +++ b/src/connected-user.js @@ -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 diff --git a/src/known-actors.js b/src/known-actors.js new file mode 100644 index 0000000..e9ad10d --- /dev/null +++ b/src/known-actors.js @@ -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 diff --git a/src/message.js b/src/message.js new file mode 100644 index 0000000..d06924e --- /dev/null +++ b/src/message.js @@ -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 diff --git a/src/timeline.js b/src/timeline.js new file mode 100644 index 0000000..8a27839 --- /dev/null +++ b/src/timeline.js @@ -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