feat: ability to browse to any ActivityPub note and have the entire topic chain render

Added methods for going up the inReplyTo chain to parent, asserting the topic, etc.
This commit is contained in:
Julian Lam
2024-01-12 15:23:30 -05:00
parent d992239d7b
commit 485cf20006
5 changed files with 175 additions and 0 deletions

82
src/activitypub/notes.js Normal file
View File

@@ -0,0 +1,82 @@
'use strict';
const winston = require('winston');
const db = require('../database');
const posts = require('../posts');
const activitypub = module.parent.exports;
const Notes = module.exports;
// todo: when asserted, notes aren't added to a global sorted set
// also, db.exists call is probably expensive
Notes.assert = async (uid, input) => {
// Ensures that each note has been saved to the database
await Promise.all(input.map(async (item) => {
const id = activitypub.helpers.isUri(item) ? item : item.id;
const key = `post:${id}`;
const exists = await db.exists(key);
winston.verbose(`[activitypub/notes.assert] Asserting note id ${id}`);
let postData;
if (!exists) {
winston.verbose(`[activitypub/notes.assert] Not found, saving note to database`);
const object = activitypub.helpers.isUri(item) ? await activitypub.get(uid, item) : item;
postData = await activitypub.mocks.post(object);
if (postData) {
await db.setObject(key, postData);
}
}
}));
};
Notes.getParentChain = async (uid, input) => {
// Traverse upwards via `inReplyTo` until you find the root-level Note
const id = activitypub.helpers.isUri(input) ? input : input.id;
const chain = new Set();
const traverse = async (uid, id) => {
const exists = await db.exists(`post:${id}`);
if (exists) {
const { toPid, timestamp } = await posts.getPostFields(id, ['toPid', 'timestamp']);
chain.add({ id, timestamp });
if (toPid) {
await traverse(uid, toPid);
}
} else {
let object = await activitypub.get(uid, id);
object = await activitypub.mocks.post(object);
if (object) {
chain.add({ id, timestamp: object.timestamp });
if (object.hasOwnProperty('toPid') && object.toPid) {
await traverse(uid, object.toPid);
}
}
}
};
await traverse(uid, id);
return chain;
};
Notes.assertTopic = async (uid, id) => {
// Given the id of any post, traverses up (and soon, down) to cache the entire threaded context
const chain = Array.from(await Notes.getParentChain(uid, id));
const tid = chain[chain.length - 1].id;
const sorted = chain.sort((a, b) => a.timestamp - b.timestamp);
const [ids, timestamps] = [
sorted.map(n => n.id),
sorted.map(n => n.timestamp),
];
await db.sortedSetAdd(`topicRemote:${tid}`, timestamps, ids);
await Notes.assert(uid, chain);
return tid;
};
Notes.getTopicPosts = async (tid, uid, start, stop) => {
const pids = await db.getSortedSetRange(`topicRemote:${tid}`, start, stop);
return await posts.getPostsByPids(pids, uid);
};

View File

@@ -10,6 +10,7 @@ const helpers = require('../helpers');
const Controller = module.exports;
Controller.profiles = require('./profiles');
Controller.topics = require('./topics');
Controller.getActor = async (req, res) => {
// todo: view:users priv gate

View File

@@ -0,0 +1,81 @@
'use strict';
const user = require('../../user');
const topics = require('../../topics');
const { notes } = require('../../activitypub');
// const helpers = require('../helpers');
// const pagination = require('../../pagination');
const controller = module.exports;
controller.get = async function (req, res, next) {
const tid = await notes.assertTopic(req.uid, req.query.resource);
let postIndex = parseInt(req.params.post_index, 10) || 1;
const [
// userPrivileges,
settings,
// topicData,
] = await Promise.all([
// privileges.topics.get(tid, req.uid),
user.getSettings(req.uid),
// topics.getTopicData(tid),
]);
const topicData = {
tid,
postCount: 6,
category: {}, // todo
};
let currentPage = parseInt(req.query.page, 10) || 1;
const pageCount = Math.max(1, Math.ceil((topicData && topicData.postcount) / settings.postsPerPage));
const invalidPagination = (settings.usePagination && (currentPage < 1 || currentPage > pageCount));
if (
!topicData ||
// userPrivileges.disabled ||
invalidPagination// ||
// (topicData.scheduled && !userPrivileges.view_scheduled)
) {
return next();
}
if (!req.query.page) {
currentPage = calculatePageFromIndex(postIndex, settings);
}
if (settings.usePagination && req.query.page) {
const top = ((currentPage - 1) * settings.postsPerPage) + 1;
const bottom = top + settings.postsPerPage;
if (!req.params.post_index || (postIndex < top || postIndex > bottom)) {
postIndex = top;
}
}
const { start, stop } = calculateStartStop(currentPage, postIndex, settings);
topicData.posts = await notes.getTopicPosts(tid, req.uid, start, stop);
topicData.posts = await topics.addPostData(topicData.posts, req.uid);
res.render('topic', topicData);
};
// todo: expose from topic controller?
function calculatePageFromIndex(postIndex, settings) {
return 1 + Math.floor((postIndex - 1) / settings.postsPerPage);
}
// todo: expose from topic controller?
function calculateStartStop(page, postIndex, settings) {
let startSkip = 0;
if (!settings.usePagination) {
if (postIndex > 1) {
page = 1;
}
startSkip = Math.max(0, postIndex - Math.ceil(settings.postsPerPage / 2));
}
const start = ((page - 1) * settings.postsPerPage) + startSkip;
const stop = start + settings.postsPerPage - 1;
return { start: Math.max(0, start), stop: Math.max(0, stop) };
}

View File

@@ -9,11 +9,14 @@ const topics = require('../topics');
const categories = require('../categories');
const posts = require('../posts');
const privileges = require('../privileges');
const activitypub = require('../activitypub');
const helpers = require('./helpers');
const pagination = require('../pagination');
const utils = require('../utils');
const analytics = require('../analytics');
const activitypubController = require('./activitypub');
const topicsController = module.exports;
const url = nconf.get('url');
@@ -21,6 +24,10 @@ const relative_path = nconf.get('relative_path');
const upload_url = nconf.get('upload_url');
topicsController.get = async function getTopic(req, res, next) {
if (req.params.topic_id === 'remote' && activitypub.helpers.isUri(req.query.resource)) {
return activitypubController.topics.get(req, res, next);
}
const tid = req.params.topic_id;
if (
(req.params.post_index && !utils.isNumber(req.params.post_index) && req.params.post_index !== 'unread') ||

View File

@@ -1,5 +1,9 @@
'use strict';
/**
* These controllers only respond if the sender is making an json+activitypub style call (i.e. S2S-only)
*/
module.exports = function (app, middleware, controllers) {
const middlewares = [middleware.proceedOnActivityPub, middleware.exposeUid];