diff --git a/art/themes/luciano_blocktronics/activitypub_actor_search_main.ans b/art/themes/luciano_blocktronics/activitypub_actor_search_main.ans index 711255cc..1f7a6074 100644 Binary files a/art/themes/luciano_blocktronics/activitypub_actor_search_main.ans and b/art/themes/luciano_blocktronics/activitypub_actor_search_main.ans differ diff --git a/art/themes/luciano_blocktronics/activitypub_actor_view.ans b/art/themes/luciano_blocktronics/activitypub_actor_view.ans index d54ec773..9f6f447b 100644 Binary files a/art/themes/luciano_blocktronics/activitypub_actor_view.ans and b/art/themes/luciano_blocktronics/activitypub_actor_view.ans differ diff --git a/art/themes/luciano_blocktronics/activitypub_social_manager.ans b/art/themes/luciano_blocktronics/activitypub_social_manager.ans new file mode 100644 index 00000000..56af586e Binary files /dev/null and b/art/themes/luciano_blocktronics/activitypub_social_manager.ans differ diff --git a/art/themes/luciano_blocktronics/theme.hjson b/art/themes/luciano_blocktronics/theme.hjson index 934ef6f7..f1af5eb9 100644 --- a/art/themes/luciano_blocktronics/theme.hjson +++ b/art/themes/luciano_blocktronics/theme.hjson @@ -484,16 +484,42 @@ } } + activityPubFollowingManager: { + config: { + selectedActorInfoFormat: "|00|15{preferredUsername}\n|02{name}\n{summary}" + statusIndicatorEnabled: "|00|10√" + staticIndicatorDisabled: "|00|12X" + } + 0: { + mci: { + VM1: { + height: 15 + width: 30 + itemFormat: "|00|03{subject}|00 {statusIndicator}" + focusItemFormat: "|00|19|15{subject}|00 {statusIndicator}" + } + MT2: { + height: 15 + width: 32 + } + TM3: { + focusTextStyle: first upper + } + } + } + } + // :TODO: move this to the right area, rename, etc. mainMenuActivityPubActorSearch: { + config: { + followingIndicator: "|00|14> |10Following|08!|14 <" + notFollowingIndicator: "|00|14> |02Not Following|14 <" + viewInfoFormat10: "{actorFollowingIndicator}" + } 0: { mci: { TL1: { width: 70 - } - BT2: { - width: 20 - focusTextStyle: upper submit: true } } @@ -524,14 +550,9 @@ height: 3 mode: preview } - BT8: { - focusTextStyle: upper - submit: true - } - BT9: { - text: back - focusTextStyle: upper - submit: true + TL10: { + width: 24 + } } } diff --git a/core/activitypub/actor.js b/core/activitypub/actor.js index b5427b4e..1d49752a 100644 --- a/core/activitypub/actor.js +++ b/core/activitypub/actor.js @@ -11,7 +11,7 @@ const { queryWebFinger } = require('../webfinger'); const EnigAssert = require('../enigma_assert'); const ActivityPubSettings = require('./settings'); const ActivityPubObject = require('./object'); -const { ActivityStreamMediaType } = require('./const'); +const { ActivityStreamMediaType, Collections } = require('./const'); const apDb = require('../database').dbs.activitypub; const Config = require('../config').get; @@ -73,7 +73,12 @@ module.exports = class Actor extends ActivityPubObject { } static get WellKnownLinkTypes() { - return ['inbox', 'outbox', 'following', 'followers']; + return [ + Collections.Inbox, + Collections.Outbox, + Collections.Following, + Collections.Followers, + ]; } static fromLocalUser(user, webServer, cb) { diff --git a/core/activitypub/actor_search.js b/core/activitypub/actor_search.js index 6ce5c563..36045e45 100644 --- a/core/activitypub/actor_search.js +++ b/core/activitypub/actor_search.js @@ -3,11 +3,15 @@ const { Errors } = require('../enig_error'); const Actor = require('../activitypub/actor'); const moment = require('moment'); const { htmlToMessageBody } = require('./util'); +const { Collections } = require('./const'); const Collection = require('./collection'); +const EnigAssert = require('../enigma_assert'); +const { sendFollowRequest, sendUnfollowRequest } = require('./follow_util'); +const { getServer } = require('../listening_server'); // deps const async = require('async'); -const { get, truncate, isEmpty } = require('lodash'); +const { get, isEmpty, isObject, cloneDeep } = require('lodash'); exports.moduleInfo = { name: 'ActivityPub Actor Search', @@ -22,8 +26,7 @@ const FormIds = { const MciViewIds = { main: { - searchUrl: 1, - searchButton: 2, + searchQuery: 1, }, view: { userName: 1, @@ -33,8 +36,8 @@ const MciViewIds = { numberFollowers: 5, numberFollowing: 6, summary: 7, - followButton: 8, - cancelButton: 9, + + customRangeStart: 10, }, }; @@ -47,27 +50,34 @@ exports.getModule = class ActivityPubActorSearch extends MenuModule { }); this.menuMethods = { - submit: (formData, extraArgs, cb) => { - switch (formData.submitId) { - case MciViewIds.main.searchUrl: { - return this._search(formData.value, cb); - } - case MciViewIds.main.searchButton: { - return this._search(formData.value, cb); - } - - default: - cb( - Errors.UnexpectedState( - `Unexpected submitId: ${formData.submitId}` - ) + search: (formData, extraArgs, cb) => { + return this._search(formData.value, cb); + }, + toggleFollowKeyPressed: (formData, extraArgs, cb) => { + return this._toggleFollowStatus(err => { + if (err) { + this.client.log.error( + { error: err.message }, + 'Failed to toggle follow status' ); - } + } + return cb(err); + }); + }, + backKeyPressed: (formData, extraArgs, cb) => { + return this._displayMainPage(true, cb); }, }; } initSequence() { + this.webServer = getServer('codes.l33t.enigma.web.server'); + if (!this.webServer) { + this.client.log('Could not get Web server'); + return this.prevMenu(); + } + this.webServer = this.webServer.instance; + async.series( [ callback => { @@ -84,7 +94,7 @@ exports.getModule = class ActivityPubActorSearch extends MenuModule { } _search(values, cb) { - const searchString = values['searchUrl'].trim(); + const searchString = values.searchQuery.trim(); //TODO: Handle empty searchString Actor.fromId(searchString, (err, remoteActor) => { if (err) { @@ -95,11 +105,15 @@ exports.getModule = class ActivityPubActorSearch extends MenuModule { // TODO: Add error to page for failure to find actor return this._displayMainPage(true, cb); } - return this._displayListScreen(remoteActor, cb); + + this.selectedActorInfo = remoteActor; + return this._displayViewPage(cb); }); } - _displayListScreen(remoteActor, cb) { + _displayViewPage(cb) { + EnigAssert(isObject(this.selectedActorInfo), 'No Actor selected!'); + async.series( [ callback => { @@ -122,59 +136,65 @@ exports.getModule = class ActivityPubActorSearch extends MenuModule { callback => { return this.validateMCIByViewIds( 'view', - Object.values(MciViewIds.view), + Object.values(MciViewIds.view).filter( + id => id !== MciViewIds.view.customRangeStart + ), callback ); }, + callback => { + this._updateCollectionItemCount(Collections.Following, () => { + return this._updateCollectionItemCount( + Collections.Followers, + callback + ); + }); + }, callback => { const v = id => this.getView('view', id); const nameView = v(MciViewIds.view.userName); - nameView.setText( - truncate(remoteActor.preferredUsername, { - length: nameView.getWidth(), - }) - ); + nameView.setText(this.selectedActorInfo.preferredUsername); const fullNameView = v(MciViewIds.view.fullName); - fullNameView.setText( - truncate(remoteActor.name, { length: fullNameView.getWidth() }) - ); + fullNameView.setText(this.selectedActorInfo.name); const datePublishedView = v(MciViewIds.view.datePublished); - if (isEmpty(remoteActor.published)) { + if (isEmpty(this.selectedActorInfo.published)) { datePublishedView.setText('Not available.'); } else { - const publishedDate = moment(remoteActor.published); + const publishedDate = moment(this.selectedActorInfo.published); datePublishedView.setText( publishedDate.format(this.getDateFormat()) ); } const manualFollowersView = v(MciViewIds.view.manualFollowers); - manualFollowersView.setText(remoteActor.manuallyApprovesFollowers); + manualFollowersView.setText( + this.selectedActorInfo.manuallyApprovesFollowers + ); const followerCountView = v(MciViewIds.view.numberFollowers); - this._updateViewWithCollectionItemCount( - remoteActor.followers, - followerCountView + followerCountView.setText( + this.selectedActorInfo._followersCount > -1 + ? this.selectedActorInfo._followersCount + : '--' ); const followingCountView = v(MciViewIds.view.numberFollowing); - this._updateViewWithCollectionItemCount( - remoteActor.following, - followingCountView + followingCountView.setText( + this.selectedActorInfo._followingCount > -1 + ? this.selectedActorInfo._followingCount + : '--' ); const summaryView = v(MciViewIds.view.summary); - summaryView.setText(htmlToMessageBody(remoteActor.summary)); + summaryView.setText( + htmlToMessageBody(this.selectedActorInfo.summary) + ); summaryView.redraw(); - const followButtonView = v(MciViewIds.view.followButton); - // TODO: FIXME: Real status - followButtonView.setText('follow'); - - return callback(null); + return this._setFollowStatus(callback); }, ], err => { @@ -183,6 +203,102 @@ exports.getModule = class ActivityPubActorSearch extends MenuModule { ); } + _setFollowStatus(cb) { + Collection.ownedObjectByNameAndId( + Collections.Following, + this.client.user, + this.selectedActorInfo.id, + (err, followingActorEntry) => { + if (err) { + return cb(err); + } + + this.selectedActorInfo._isFollowing = followingActorEntry ? true : false; + this.selectedActorInfo._followingIndicator = + this._getFollowingIndicator(); + + this.updateCustomViewTextsWithFilter( + 'view', + MciViewIds.view.customRangeStart, + this._getCustomInfoFormatObject() + ); + + return cb(null); + } + ); + } + + _toggleFollowStatus(cb) { + // catch early key presses + if (!this.selectedActorInfo) { + return; + } + + this.selectedActorInfo._isFollowing = !this.selectedActorInfo._isFollowing; + this.selectedActorInfo._followingIndicator = this._getFollowingIndicator(); + + const finish = e => { + this.updateCustomViewTextsWithFilter( + 'view', + MciViewIds.view.customRangeStart, + this._getCustomInfoFormatObject() + ); + + return cb(e); + }; + + const actor = this._getSelectedActor(); // actor info -> actor + return this.selectedActorInfo._isFollowing + ? sendFollowRequest(this.client.user, actor, this.webServer, finish) + : sendUnfollowRequest(this.client.user, actor, this.webServer, finish); + } + + _getSelectedActor() { + const actor = cloneDeep(this.selectedActorInfo); + + // nuke our added properties + delete actor._isFollowing; + delete actor._followingIndicator; + delete actor._followingCount; + delete actor._followersCount; + + return actor; + } + + _getFollowingIndicator() { + return this.selectedActorInfo._isFollowing + ? this.config.followingIndicator || 'Following' + : this.config.notFollowingIndicator || 'Not following'; + } + + _getCustomInfoFormatObject() { + const formatObj = { + followingCount: this.selectedActorInfo._followingCount, + followerCount: this.selectedActorInfo._followersCount, + }; + + const v = f => { + return this.selectedActorInfo[f] || ''; + }; + + Object.assign(formatObj, { + actorId: v('id'), + actorSubject: v('subject'), + actorType: v('type'), + actorName: v('name'), + actorSummary: v('summary'), + actorPreferredUsername: v('preferredUsername'), + actorUrl: v('url'), + actorImage: v('image'), + actorIcon: v('icon'), + actorFollowing: this.selectedActorInfo._isFollowing, + actorFollowingIndicator: v('_followingIndicator'), + text: v('name'), + }); + + return formatObj; + } + _displayMainPage(clearScreen, cb) { async.series( [ @@ -208,17 +324,20 @@ exports.getModule = class ActivityPubActorSearch extends MenuModule { ); } - _updateViewWithCollectionItemCount(collectionUrl, view) { + _updateCollectionItemCount(collectionName, cb) { + const collectionUrl = this.selectedActorInfo[collectionName]; this._retrieveCountFromCollectionUrl(collectionUrl, (err, count) => { if (err) { this.client.log.warn( { err: err }, `Unable to get Collection count for ${collectionUrl}` ); - view.setText('--'); + this.selectedActorInfo[`_${collectionName}Count`] = -1; } else { - view.setText(count); + this.selectedActorInfo[`_${collectionName}Count`] = count; } + + return cb(null); }); } @@ -229,7 +348,7 @@ exports.getModule = class ActivityPubActorSearch extends MenuModule { } Collection.getRemoteCollectionStats(collectionUrl, (err, stats) => { - return cb(err, stats.totalItems); + return cb(err, err ? null : stats.totalItems); }); } }; diff --git a/core/activitypub/collection.js b/core/activitypub/collection.js index d613a0b9..ad94a61c 100644 --- a/core/activitypub/collection.js +++ b/core/activitypub/collection.js @@ -7,6 +7,7 @@ const { Errors } = require('../enig_error.js'); const { PublicCollectionId: APPublicCollectionId, ActivityStreamMediaType, + Collections, } = require('./const'); const UserProps = require('../user_property'); const { getJson } = require('../http_util'); @@ -55,7 +56,7 @@ module.exports = class Collection extends ActivityPubObject { static followers(collectionId, page, cb) { return Collection.publicOrderedById( - 'followers', + Collections.Followers, collectionId, page, e => e.id, @@ -65,7 +66,7 @@ module.exports = class Collection extends ActivityPubObject { static following(collectionId, page, cb) { return Collection.publicOrderedById( - 'following', + Collections.Following, collectionId, page, e => e.id, @@ -74,13 +75,19 @@ module.exports = class Collection extends ActivityPubObject { } static outbox(collectionId, page, cb) { - return Collection.publicOrderedById('outbox', collectionId, page, null, cb); + return Collection.publicOrderedById( + Collections.Outbox, + collectionId, + page, + null, + cb + ); } static addFollower(owningUser, followingActor, webServer, ignoreDupes, cb) { const collectionId = Endpoints.followers(webServer, owningUser); return Collection.addToCollection( - 'followers', + Collections.Followers, owningUser, collectionId, followingActor.id, // Actor following owningUser @@ -95,7 +102,7 @@ module.exports = class Collection extends ActivityPubObject { const collectionId = Endpoints.makeUserUrl(webServer, owningUser) + 'follow-requests'; return Collection.addToCollection( - 'follow-requests', + Collections.FollowRequests, owningUser, collectionId, requestingActor.id, // Actor requesting to follow owningUser @@ -109,7 +116,7 @@ module.exports = class Collection extends ActivityPubObject { static addFollowing(owningUser, followingActor, webServer, ignoreDupes, cb) { const collectionId = Endpoints.following(webServer, owningUser); return Collection.addToCollection( - 'following', + Collections.Following, owningUser, collectionId, followingActor.id, // Actor owningUser is following @@ -123,7 +130,7 @@ module.exports = class Collection extends ActivityPubObject { static addOutboxItem(owningUser, outboxItem, isPrivate, webServer, ignoreDupes, cb) { const collectionId = Endpoints.outbox(webServer, owningUser); return Collection.addToCollection( - 'outbox', + Collections.Outbox, owningUser, collectionId, outboxItem.id, @@ -137,7 +144,7 @@ module.exports = class Collection extends ActivityPubObject { static addInboxItem(inboxItem, owningUser, webServer, ignoreDupes, cb) { const collectionId = Endpoints.inbox(webServer, owningUser); return Collection.addToCollection( - 'inbox', + Collections.Inbox, owningUser, collectionId, inboxItem.id, @@ -150,7 +157,7 @@ module.exports = class Collection extends ActivityPubObject { static addSharedInboxItem(inboxItem, ignoreDupes, cb) { return Collection.addToCollection( - 'sharedInbox', + Collections.SharedInbox, null, // N/A Collection.PublicCollectionId, inboxItem.id, @@ -161,13 +168,79 @@ module.exports = class Collection extends ActivityPubObject { ); } - static objectById(objectId, cb) { + // Get Object(s) by ID; There may be multiples as they may be + // e.g. Actors belonging to multiple followers collections. + // This method also returns information about the objects + // and any items that can't be parsed + static objectsById(objectId, cb) { + apDb.all( + `SELECT name, timestamp, owner_actor_id, object_json, is_private + FROM collection + WHERE object_id = ?;`, + [objectId], + (err, rows) => { + if (err) { + return cb(err); + } + + const results = (rows || []).map(r => { + const info = { + info: this._rowToObjectInfo(r), + object: ActivityPubObject.fromJsonString(r.object_json), + }; + if (!info.object) { + info.raw = r.object_json; + } + return info; + }); + + return cb(null, results); + } + ); + } + + static ownedObjectByNameAndId(collectionName, owningUser, objectId, cb) { + const actorId = owningUser.getProperty(UserProps.ActivityPubActorId); + if (!actorId) { + return cb( + Errors.MissingProperty( + `User "${owningUser.username}" is missing property '${UserProps.ActivityPubActorId}'` + ) + ); + } + + apDb.get( + `SELECT name, timestamp, owner_actor_id, object_json, is_private + FROM collection + WHERE name = ? AND owner_actor_id = ? AND object_id = ? + LIMIT 1;`, + [collectionName, actorId, objectId], + (err, row) => { + if (err) { + return cb(err); + } + + if (!row) { + return cb(null, null); + } + + const obj = ActivityPubObject.fromJsonString(row.object_json); + if (!obj) { + return cb(Errors.Invalid('Failed to parse Object JSON')); + } + + return cb(null, obj, Collection._rowToObjectInfo(row)); + } + ); + } + + static objectByNameAndId(collectionName, objectId, cb) { apDb.get( `SELECT name, timestamp, owner_actor_id, object_json, is_private FROM collection WHERE name = ? AND object_id = ? LIMIT 1;`, - [objectId], + [collectionName, objectId], (err, row) => { if (err) { return cb(err); diff --git a/core/activitypub/const.js b/core/activitypub/const.js index 56221a03..751b2ea4 100644 --- a/core/activitypub/const.js +++ b/core/activitypub/const.js @@ -31,3 +31,13 @@ exports.HttpSignatureSignHeaders = [ 'digest', 'content-type', ]; + +const Collections = { + Following: 'following', + Followers: 'followers', + FollowRequests: 'followRequests', + Outbox: 'outbox', + Inbox: 'inbox', + SharedInbox: 'sharedInbox', +}; +exports.Collections = Collections; diff --git a/core/activitypub/follow_util.js b/core/activitypub/follow_util.js new file mode 100644 index 00000000..8256ea1e --- /dev/null +++ b/core/activitypub/follow_util.js @@ -0,0 +1,64 @@ +const { Collections, WellKnownActivity } = require('./const'); +const ActivityPubObject = require('./object'); +const UserProps = require('../user_property'); +const { Errors } = require('../enig_error'); +const Collection = require('./collection'); + +exports.sendFollowRequest = sendFollowRequest; +exports.sendUnfollowRequest = sendUnfollowRequest; + +function sendFollowRequest(fromUser, toActor, webServer, cb) { + const fromActorId = fromUser.getProperty(UserProps.ActivityPubActorId); + if (!fromActorId) { + return cb( + Errors.MissingProperty( + `User missing "${UserProps.ActivityPubActorId}" property` + ) + ); + } + + // We always add to the following collection; + // We expect an async follow up request to our server of + // Accept or Reject but it's not guaranteed + Collection.addFollowing(fromUser, toActor, webServer, true, err => { + if (err) { + return cb(err); + } + + const followRequest = new ActivityPubObject({ + id: ActivityPubObject.makeObjectId(webServer, 'follow'), + type: WellKnownActivity.Follow, + actor: fromActorId, + object: toActor.id, + }); + + return followRequest.sendTo(toActor.inbox, fromUser, webServer, cb); + }); +} + +function sendUnfollowRequest(fromUser, toActor, webServer, cb) { + const fromActorId = fromUser.getProperty(UserProps.ActivityPubActorId); + if (!fromActorId) { + return cb( + Errors.MissingProperty( + `User missing "${UserProps.ActivityPubActorId}" property` + ) + ); + } + + // Always remove from the local collection, notify the remote server + Collection.removeOwnedById(Collections.Following, fromUser, toActor.inbox, err => { + if (err) { + return cb(err); + } + + const undoRequest = new ActivityPubObject({ + id: ActivityPubObject.makeObjectId(webServer, 'undo'), + type: WellKnownActivity.Undo, + actor: fromActorId, + object: toActor.id, + }); + + return undoRequest.sendTo(toActor.inbox, fromUser, webServer, cb); + }); +} diff --git a/core/activitypub/social_manager.js b/core/activitypub/social_manager.js new file mode 100644 index 00000000..588fe3bf --- /dev/null +++ b/core/activitypub/social_manager.js @@ -0,0 +1,372 @@ +const { MenuModule } = require('../menu_module'); +const Collection = require('./collection'); +const { getServer } = require('../listening_server'); +const Endpoints = require('./endpoint'); +const Actor = require('./actor'); +const stringFormat = require('../string_format'); +const { pipeToAnsi } = require('../color_codes'); +const MultiLineEditTextView = + require('../multi_line_edit_text_view').MultiLineEditTextView; +const { sendFollowRequest, sendUnfollowRequest } = require('./follow_util'); +const { Collections } = require('./const'); + +// deps +const async = require('async'); +const { get, cloneDeep } = require('lodash'); + +exports.moduleInfo = { + name: 'ActivityPub Social Manager', + desc: 'Manages ActivityPub Actors the current user is following or being followed by.', + author: 'NuSkooler', +}; + +const FormIds = { + main: 0, +}; + +const MciViewIds = { + main: { + actorList: 1, + selectedActorInfo: 2, + navMenu: 3, + + customRangeStart: 10, + }, +}; + +exports.getModule = class ActivityPubFollowingManager extends MenuModule { + constructor(options) { + super(options); + this.setConfigWithExtraArgs(options); + + this.followingActors = []; + this.followerActors = []; + this.currentCollection = Collections.Following; + + this.menuMethods = { + spaceKeyPressed: (formData, extraArgs, cb) => { + return this._toggleSelectedActorStatus(cb); + }, + listKeyPressed: (formData, extraArgs, cb) => { + const actorListView = this.getView('main', MciViewIds.main.actorList); + if (actorListView) { + const keyName = get(formData, 'key.name'); + switch (keyName) { + case 'down arrow': + actorListView.focusNext(); + break; + case 'up arrow': + actorListView.focusPrevious(); + break; + } + } + return cb(null); + }, + }; + } + + initSequence() { + this.webServer = getServer('codes.l33t.enigma.web.server'); + if (!this.webServer) { + this.client.log('Could not get Web server'); + return this.prevMenu(); + } + this.webServer = this.webServer.instance; + + async.series( + [ + callback => { + return this.beforeArt(callback); + }, + callback => { + return this._displayMainPage(callback); + }, + ], + () => { + this.finishedLoading(); + } + ); + } + + _displayMainPage(cb) { + async.series( + [ + callback => { + return this.displayArtAndPrepViewController( + 'main', + FormIds.main, + { clearScreen: true }, + callback + ); + }, + callback => { + return this.validateMCIByViewIds( + 'main', + Object.values(MciViewIds.main).filter( + id => id !== MciViewIds.main.customRangeStart + ), + callback + ); + }, + callback => { + this._fetchActorList( + Collections.Following, + (err, followingActors) => { + if (err) { + return callback(err); + } + return this._fetchActorList( + Collections.Followers, + (err, followerActors) => { + if (err) { + return callback(err); + } + + this.followingActors = followingActors; + this.followerActors = followerActors; + + return callback(null); + } + ); + } + ); + }, + callback => { + const v = id => this.getView('main', id); + + const actorListView = v(MciViewIds.main.actorList); + const selectedActorInfoView = v(MciViewIds.main.selectedActorInfo); + const navMenuView = v(MciViewIds.main.navMenu); + + // We start with following + this._switchTo(Collections.Following); + + actorListView.on('index update', index => { + const selectedActor = this._getSelectedActorItem(index); + this._updateSelectedActorInfo( + selectedActorInfoView, + selectedActor + ); + }); + + navMenuView.on('index update', index => { + if (0 === index) { + this._switchTo(Collections.Following); + } else { + this._switchTo(Collections.Followers); + } + }); + + return callback(null); + }, + ], + err => { + return cb(err); + } + ); + } + + _switchTo(collectionName) { + this.currentCollection = collectionName; + const actorListView = this.getView('main', MciViewIds.main.actorList); + if (Collections.Following === collectionName) { + actorListView.setItems(this.followingActors); + } else { + actorListView.setItems(this.followerActors); + } + actorListView.redraw(); + + const selectedActor = this._getSelectedActorItem( + actorListView.getFocusItemIndex() + ); + const selectedActorInfoView = this.getView( + 'main', + MciViewIds.main.selectedActorInfo + ); + if (selectedActor) { + this._updateSelectedActorInfo(selectedActorInfoView, selectedActor); + } else { + selectedActorInfoView.setText(''); + } + } + + _getSelectedActorItem(index) { + if (this.currentCollection === Collections.Following) { + return this.followingActors[index]; + } else { + return this.followerActors[index]; + } + } + + _getCurrentActorList() { + return this.currentCollection === Collections.Following + ? this.followingActors + : this.followerActors; + } + + _updateSelectedActorInfo(view, actorInfo) { + if (actorInfo) { + const selectedActorInfoFormat = + this.config.selectedActorInfoFormat || '{text}'; + + const s = stringFormat(selectedActorInfoFormat, actorInfo); + + if (view instanceof MultiLineEditTextView) { + view.setAnsi(pipeToAnsi(s, this.client)); + } else { + view.setText(s); + } + } + + this.updateCustomViewTextsWithFilter( + 'main', + MciViewIds.main.customRangeStart, + this._getCustomInfoFormatObject(actorInfo) + ); + } + + _toggleSelectedActorStatus(cb) { + const actorListView = this.getView('main', MciViewIds.main.actorList); + const selectedActor = this._getSelectedActorItem( + actorListView.getFocusItemIndex() + ); + if (selectedActor) { + selectedActor.status = !selectedActor.status; + selectedActor.statusIndicator = this._getStatusIndicator( + selectedActor.status + ); + + async.series( + [ + callback => { + if (Collections.Following === this.currentCollection) { + return this._followingActorToggled(selectedActor, callback); + } else { + return this._followerActorToggled(selectedActor, callback); + } + }, + ], + err => { + if (err) { + this.client.log.error( + { error: err.message, type: this.currentCollection }, + `Failed to toggle "${this.currentCollection}" status` + ); + } + + // :TODO: we really need updateItem() call on MenuView + actorListView.setItems(this._getCurrentActorList()); + actorListView.redraw(); // oof + + return cb(null); + } + ); + } + } + + _followingActorToggled(actorInfo, cb) { + // Local user/Actor wants to follow or un-follow + const wantsToFollow = actorInfo.status; + const actor = this._actorInfoToActor(actorInfo); + + return wantsToFollow + ? sendFollowRequest(this.client.user, actor, this.webServer, cb) + : sendUnfollowRequest(this.client.user, actor, this.webServer, cb); + } + + _actorInfoToActor(actorInfo) { + const actor = cloneDeep(actorInfo); + + // nuke our added properties + delete actor.subject; + delete actor.text; + delete actor.status; + delete actor.statusIndicator; + + return actor; + } + + _followerActorToggled(actorInfo, cb) { + return cb(null); + } + + _getCustomInfoFormatObject(actorInfo) { + const formatObj = { + followingCount: this.followingActors.length, + followerCount: this.followerActors.length, + }; + + const v = f => { + return actorInfo ? actorInfo[f] || '' : ''; + }; + + Object.assign(formatObj, { + selectedActorId: v('id'), + selectedActorSubject: v('subject'), + selectedActorType: v('type'), + selectedActorName: v('name'), + selectedActorSummary: v('summary'), + selectedActorPreferredUsername: v('preferredUsername'), + selectedActorUrl: v('url'), + selectedActorImage: v('image'), + selectedActorIcon: v('icon'), + selectedActorStatus: actorInfo ? actorInfo.status : false, + selectedActorStatusIndicator: v('statusIndicator'), + text: v('name'), + }); + + return formatObj; + } + + _getStatusIndicator(enabled) { + return enabled + ? this.config.statusIndicatorEnabled || '√' + : this.config.statusIndicatorDisabled || 'X'; + } + + _fetchActorList(collectionName, cb) { + const collectionId = Endpoints[collectionName](this.webServer, this.client.user); + Collection[collectionName](collectionId, 'all', (err, collection) => { + if (err) { + return cb(err); + } + + if (!collection.orderedItems || collection.orderedItems.length < 1) { + return cb(null, []); + } + + const statusIndicator = this._getStatusIndicator(true); + + async.mapLimit( + collection.orderedItems, + 4, + (actorId, nextActorId) => { + Actor.fromId(actorId, (err, actor, subject) => { + if (err) { + this.client.log.warn({ actorId }, 'Failed to retrieve Actor'); + return nextActorId(null, null); + } + + // Add some of our own properties + Object.assign(actor, { + subject, + status: true, + statusIndicator, + text: actor.name, + }); + + return nextActorId(null, actor); + }); + }, + (err, actorsList) => { + if (err) { + return cb(err); + } + + actorsList = actorsList.filter(f => f); // drop nulls + return cb(null, actorsList); + } + ); + }); + } +}; diff --git a/core/activitypub/user_config.js b/core/activitypub/user_config.js index 09d1386b..11657b60 100644 --- a/core/activitypub/user_config.js +++ b/core/activitypub/user_config.js @@ -51,10 +51,7 @@ const EnabledViewGroup = [ exports.getModule = class ActivityPubUserConfig extends MenuModule { constructor(options) { super(options); - - this.config = Object.assign({}, get(options, 'menuConfig.config'), { - extraArgs: options.extraArgs, - }); + this.setConfigWithExtraArgs(options); this.menuMethods = { mainSubmit: (formData, extraArgs, cb) => { diff --git a/core/activitypub/util.js b/core/activitypub/util.js index 9b1c6eac..34d6ea40 100644 --- a/core/activitypub/util.js +++ b/core/activitypub/util.js @@ -3,8 +3,7 @@ const { Errors, ErrorReasons } = require('../enig_error'); const UserProps = require('../user_property'); const ActivityPubSettings = require('./settings'); const { stripAnsiControlCodes } = require('../string_util'); -const { WellKnownRecipientFields, WellKnownActivity } = require('./const'); -const ActivityPubObject = require('./object'); +const { WellKnownRecipientFields } = require('./const'); const Log = require('../logger').log; // deps @@ -29,7 +28,6 @@ exports.userNameFromSubject = userNameFromSubject; exports.userNameToSubject = userNameToSubject; exports.extractMessageMetadata = extractMessageMetadata; exports.recipientIdsFromObject = recipientIdsFromObject; -exports.sendFollowRequest = sendFollowRequest; // :TODO: more info in default // this profile template is the *default* for both WebFinger @@ -264,23 +262,3 @@ function recipientIdsFromObject(obj) { return Array.from(new Set(ids)); } - -function sendFollowRequest(fromUser, toActor, webServer, cb) { - const fromActorId = fromUser.getProperty(UserProps.ActivityPubActorId); - if (!fromActorId) { - return cb( - Errors.MissingProperty( - `User missing "${UserProps.ActivityPubActorId}" property` - ) - ); - } - - const followRequest = new ActivityPubObject({ - id: ActivityPubObject.makeObjectId(webServer, 'follow'), - type: WellKnownActivity.Follow, - actor: fromActorId, - object: toActor.id, - }); - - return followRequest.sendTo(toActor.inbox, fromUser, webServer, cb); -} diff --git a/core/servers/content/web_handlers/activitypub.js b/core/servers/content/web_handlers/activitypub.js index 44802e73..ce8af721 100644 --- a/core/servers/content/web_handlers/activitypub.js +++ b/core/servers/content/web_handlers/activitypub.js @@ -8,6 +8,7 @@ const Endpoints = require('../../../activitypub/endpoint'); const { ActivityStreamMediaType, WellKnownActivity, + Collections, } = require('../../../activitypub/const'); const Config = require('../../../config').get; const Activity = require('../../../activitypub/activity'); @@ -64,7 +65,12 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { req, resp, (req, resp, signature) => { - return this._inboxPostHandler(req, resp, signature, 'inbox'); + return this._inboxPostHandler( + req, + resp, + signature, + Collections.Inbox + ); } ); }, @@ -82,7 +88,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { req, resp, signature, - 'sharedInbox' + Collections.SharedInbox ); } ); @@ -262,7 +268,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { switch (activity.type) { case WellKnownActivity.Accept: - break; + return this._inboxAcceptActivity(resp, activity); case WellKnownActivity.Add: break; @@ -292,17 +298,17 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { case WellKnownActivity.Follow: // Follow requests are only allowed directly - if ('inbox' === inboxType) { + if (Collections.Inbox === inboxType) { return this._inboxFollowActivity(resp, remoteActor, activity); } break; case WellKnownActivity.Reject: - break; + return this._inboxRejectActivity(resp, activity); case WellKnownActivity.Undo: // We only Undo from private inboxes - if ('inbox' === inboxType) { + if (Collections.Inbox === inboxType) { // Only Follow Undo's currently supported const type = _.get(activity, 'object.type'); if (WellKnownActivity.Follow === type) { @@ -330,6 +336,21 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { }); } + _inboxAcceptActivity(resp, activity) { + const acceptWhat = _.get(activity, 'object.type'); + switch (acceptWhat) { + case WellKnownActivity.Follow: + return this._inboxAcceptFollowActivity(resp, activity); + + default: + this.log.warn( + { type: acceptWhat }, + 'Invalid or unsupported "Accept" type' + ); + return this.webServer.notImplemented(resp); + } + } + _inboxCreateActivity(resp, activity) { const createWhat = _.get(activity, 'object.type'); switch (createWhat) { @@ -341,10 +362,23 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { { type: createWhat }, 'Invalid or unsupported "Create" type' ); - return this.webServer.resourceNotFound(resp); + return this.webServer.notImplemented(resp); } } + _inboxAcceptFollowActivity(resp, activity) { + // Currently Accept's to Follow's are really just a formality; + // we'll log it, but that's about it for now + this.log.info( + { + remoteActorId: activity.actor, + localActorId: _.get(activity, 'object.actor'), + }, + 'Follow request Accepted' + ); + return this.webServer.accepted(resp); + } + _inboxCreateNoteActivity(resp, activity) { const note = new Note(activity.object); if (!note.isValid()) { @@ -380,10 +414,107 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { ); } - _inboxDeleteActivity(inboxType, signature, resp /*, activity*/) { - // :TODO: Implement me! + _inboxRejectFollowActivity(resp, activity) { + // A user Rejected our local Actor/user's Follow request; + // Update the local Collection to reflect this fact. + const remoteActorId = activity.actor; + const localActorId = _.get(activity, 'object.actor'); + + if (!remoteActorId || !localActorId) { + return this.webServer.badRequest(resp); + } + + userFromActorId(localActorId, (err, localUser) => { + if (err) { + return this.webServer.resourceNotFound(resp); + } + + Collection.removeOwnedById( + Collections.Following, + localUser, + remoteActorId, + err => { + if (err) { + this.log.error( + { remoteActorId, localActorId }, + 'Failed removing "following" record' + ); + } + return this.webServer.accepted(resp); + } + ); + }); + } + + _inboxDeleteActivity(inboxType, signature, resp, activity) { + const objectId = _.get(activity, 'object.id', activity.object); + + this.log.info({ inboxType, objectId }, 'Incoming Delete request'); + // :TODO: we need to DELETE the existing stored Message object if this is a Note, or associated if this is an Actor // :TODO: delete / invalidate any actor cache if actor + Collection.objectsById(objectId, (err, objectsInfo) => { + if (err) { + this.log.warn({ objectId }); + // We'll respond accepted so they don't keep trying + return this.webServer.accepted(resp); + } + + if (objectsInfo.length === 0) { + return this.webServer.resourceNotFound(resp); + } + + // Generally we'd have a 1:1 objectId -> object here, but it's + // possible for example, that we're being asked to delete an Actor; + // If this is the case, they may be following multiple local Actor/users + // and we have multiple entries. + async.forEachSeries( + objectsInfo, + (objInfo, nextObjInfo) => { + if (objInfo.object) { + // Based on the collection we find this entry in, + // we may have additional validation or actions + switch (objInfo.info.name) { + case Collections.Inbox: + if (inboxType !== Collections.Inbox) { + // :TODO: LOG ME + return nextObjInfo(null); + } + break; + + case Collections.SharedInbox: + if (inboxType !== Collections.SharedInbox) { + // :TODO: log me + return nextObjInfo(null); + } + break; + default: + break; + } + + return nextObjInfo(null); + } else { + // it's unparsable, so we'll delete it + Collection.removeById(objInfo.info.name, objectId, err => { + if (err) { + this.log.warn( + { objectId, collectionName: objInfo.info.name }, + 'Failed to remove object' + ); + } + return nextObjInfo(null); + }); + } + }, + err => { + if (err) { + // :TODO: log me + } + return this.webServer.accepted(resp); + } + ); + }); + return this.webServer.accepted(resp); } @@ -402,7 +533,7 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { const activityPubSettings = ActivityPubSettings.fromUser(localUser); if (!activityPubSettings.manuallyApproveFollowers) { this._recordAcceptedFollowRequest(localUser, remoteActor, activity); - return this.webServer.ok(resp); + return this.webServer.accepted(resp); } // User manually approves requests; add them to their requests collection @@ -416,12 +547,27 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { return this.internalServerError(resp, err); } - return this.webServer.ok(resp); + return this.webServer.accepted(resp); } ); }); } + _inboxRejectActivity(resp, activity) { + const rejectWhat = _.get(activity, 'object.type'); + switch (rejectWhat) { + case WellKnownActivity.Follow: + return this._inboxRejectFollowActivity(resp, activity); + + default: + this.log.warn( + { type: rejectWhat }, + 'Invalid or unsupported "Reject" type' + ); + return this.webServer.notImplemented(resp); + } + } + _inboxUndoActivity(resp, remoteActor, activity) { const localActorId = _.get(activity, 'object.object'); @@ -435,22 +581,27 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { return this.webServer.resourceNotFound(resp); } - Collection.removeOwnedById('followers', localUser, remoteActor.id, err => { - if (err) { - return this.webServer.internalServerError(resp, err); + Collection.removeOwnedById( + Collections.Followers, + localUser, + remoteActor.id, + err => { + if (err) { + return this.webServer.internalServerError(resp, err); + } + + this.log.info( + { + username: localUser.username, + userId: localUser.userId, + remoteActorId: remoteActor.id, + }, + 'Undo "Follow" (un-follow) success' + ); + + return this.webServer.accepted(resp); } - - this.log.info( - { - username: localUser.username, - userId: localUser.userId, - remoteActorId: remoteActor.id, - }, - 'Undo "Follow" (un-follow) success' - ); - - return this.webServer.accepted(resp); - }); + ); }); } @@ -712,18 +863,18 @@ exports.getModule = class ActivityPubWebHandler extends WebHandlerModule { _followingGetHandler(req, resp) { this.log.debug({ url: req.url }, 'Request for "following"'); - return this._actorCollectionRequest('following', req, resp); + return this._actorCollectionRequest(Collections.Following, req, resp); } _followersGetHandler(req, resp) { this.log.debug({ url: req.url }, 'Request for "followers"'); - return this._actorCollectionRequest('followers', req, resp); + return this._actorCollectionRequest(Collections.Followers, req, resp); } // https://docs.gotosocial.org/en/latest/federation/behaviors/outbox/ _outboxGetHandler(req, resp) { this.log.debug({ url: req.url }, 'Request for "outbox"'); - return this._actorCollectionRequest('outbox', req, resp); + return this._actorCollectionRequest(Collections.Outbox, req, resp); } _singlePublicNoteGetHandler(req, resp) { diff --git a/core/vertical_menu_view.js b/core/vertical_menu_view.js index be5ce132..3379ca17 100644 --- a/core/vertical_menu_view.js +++ b/core/vertical_menu_view.js @@ -11,7 +11,6 @@ const pipeToAnsi = require('./color_codes.js').pipeToAnsi; // deps const util = require('util'); const _ = require('lodash'); -const { throws } = require('assert'); exports.VerticalMenuView = VerticalMenuView;