From 36a2c3336df2d5bfc98fc5a3e28457a2dccbfdbb Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Mon, 17 Mar 2025 14:52:52 -0400 Subject: [PATCH] feat: asserted topics and posts to remote categories will notify and add to unread based on remote category watch state --- public/src/client/category.js | 2 +- src/api/categories.js | 5 +- src/categories/watch.js | 7 +- src/privileges/helpers.js | 5 ++ src/user/categories.js | 6 +- test/activitypub/notes.js | 131 ++++++++++++++++++++++++++-------- 6 files changed, 122 insertions(+), 34 deletions(-) diff --git a/public/src/client/category.js b/public/src/client/category.js index 515a871262..e0f77793c4 100644 --- a/public/src/client/category.js +++ b/public/src/client/category.js @@ -70,7 +70,7 @@ define('forum/category', [ const $this = $(this); const state = $this.attr('data-state'); - api.put(`/categories/${cid}/watch`, { state }, (err) => { + api.put(`/categories/${encodeURIComponent(cid)}/watch`, { state }, (err) => { if (err) { return alerts.error(err); } diff --git a/src/api/categories.js b/src/api/categories.js index 4768d813ac..693f8f15ee 100644 --- a/src/api/categories.js +++ b/src/api/categories.js @@ -7,6 +7,7 @@ const events = require('../events'); const user = require('../user'); const groups = require('../groups'); const privileges = require('../privileges'); +const utils = require('../utils'); const activitypubApi = require('./activitypub'); @@ -157,7 +158,9 @@ categoriesAPI.getTopics = async (caller, data) => { categoriesAPI.setWatchState = async (caller, { cid, state, uid }) => { let targetUid = caller.uid; - const cids = Array.isArray(cid) ? cid.map(cid => parseInt(cid, 10)) : [parseInt(cid, 10)]; + let cids = Array.isArray(cid) ? cid : [cid]; + cids = cids.map(cid => (utils.isNumber(cid) ? parseInt(cid, 10) : cid)); + if (uid) { targetUid = uid; } diff --git a/src/categories/watch.js b/src/categories/watch.js index 4f53ea01e5..8eb8e9b7c0 100644 --- a/src/categories/watch.js +++ b/src/categories/watch.js @@ -3,6 +3,7 @@ const db = require('../database'); const user = require('../user'); const activitypub = require('../activitypub'); +const utils = require('../utils'); module.exports = function (Categories) { Categories.watchStates = { @@ -32,7 +33,11 @@ module.exports = function (Categories) { user.getSettings(uid), db.sortedSetsScore(keys, uid), ]); - return states.map(state => state || Categories.watchStates[userSettings.categoryWatchState]); + + const fallbacks = cids.map(cid => (utils.isNumber(cid) ? + Categories.watchStates[userSettings.categoryWatchState] : Categories.watchStates.notwatching)); + + return states.map((state, idx) => state || fallbacks[idx]); }; Categories.getIgnorers = async function (cid, start, stop) { diff --git a/src/privileges/helpers.js b/src/privileges/helpers.js index cfdfee428d..7683264ef0 100644 --- a/src/privileges/helpers.js +++ b/src/privileges/helpers.js @@ -20,6 +20,11 @@ const uidToSystemGroup = { }; helpers.isUsersAllowedTo = async function (privilege, uids, cid) { + // Remote categories inherit world pseudo-category privileges + if (!utils.isNumber(cid)) { + cid = -1; + } + const [hasUserPrivilege, hasGroupPrivilege] = await Promise.all([ groups.isMembers(uids, `cid:${cid}:privileges:${privilege}`), groups.isMembersOfGroupList(uids, `cid:${cid}:privileges:groups:${privilege}`), diff --git a/src/user/categories.js b/src/user/categories.js index 38770138a9..f64ff53d3b 100644 --- a/src/user/categories.js +++ b/src/user/categories.js @@ -67,7 +67,11 @@ module.exports = function (User) { }; User.getCategoriesByStates = async function (uid, states) { - const cids = await categories.getAllCidsFromSet('categories:cid'); + const remoteCids = await db.getObjectValues('handle:cid'); + const cids = [ + (await categories.getAllCidsFromSet('categories:cid')), + ...remoteCids, + ]; if (!(parseInt(uid, 10) > 0)) { return cids; } diff --git a/test/activitypub/notes.js b/test/activitypub/notes.js index a2ba213940..e8198d946b 100644 --- a/test/activitypub/notes.js +++ b/test/activitypub/notes.js @@ -10,6 +10,7 @@ const user = require('../../src/user'); const categories = require('../../src/categories'); const posts = require('../../src/posts'); const topics = require('../../src/topics'); +const api = require('../../src/api'); const activitypub = require('../../src/activitypub'); const utils = require('../../src/utils'); @@ -22,7 +23,7 @@ describe('Notes', () => { await install.giveWorldPrivileges(); }); - describe.only('Public objects', () => { + describe('Public objects', () => { it('should pull a remote root-level object by its id and create a new topic', async () => { const { id } = helpers.mocks.note(); const assertion = await activitypub.notes.assert(0, id, { skipChecks: true }); @@ -64,48 +65,118 @@ describe('Notes', () => { assert(exists); }); - it('should slot newly created topic in local category if addressed', async () => { - const { cid } = await categories.create({ name: utils.generateUUID() }); - const { id } = helpers.mocks.note({ - cc: ['https://example.org/user/foobar/followers', `${nconf.get('url')}/category/${cid}`], + describe('Category-specific behaviours', () => { + it('should slot newly created topic in local category if addressed', async () => { + const { cid } = await categories.create({ name: utils.generateUUID() }); + const { id } = helpers.mocks.note({ + cc: [`${nconf.get('url')}/category/${cid}`], + }); + + const assertion = await activitypub.notes.assert(0, id); + assert(assertion); + + const { tid, count } = assertion; + assert(tid); + assert.strictEqual(count, 1); + + const topic = await topics.getTopicData(tid); + assert.strictEqual(topic.cid, cid); }); - const assertion = await activitypub.notes.assert(0, id); - assert(assertion); + it('should slot newly created topic in remote category if addressed', async () => { + const { id: cid, actor } = helpers.mocks.group(); + activitypub._cache.set(`0;${cid}`, actor); + await activitypub.actors.assertGroup([cid]); - const { tid, count } = assertion; - assert(tid); - assert.strictEqual(count, 1); + const { id } = helpers.mocks.note({ + cc: [cid], + }); - const topic = await topics.getTopicData(tid); - assert.strictEqual(topic.cid, cid); + const assertion = await activitypub.notes.assert(0, id); + assert(assertion); + + const { tid, count } = assertion; + assert(tid); + assert.strictEqual(count, 1); + + const topic = await topics.getTopicData(tid); + assert.strictEqual(topic.cid, cid); + + const tids = await db.getSortedSetMembers(`cid:${cid}:tids`); + assert(tids.includes(tid)); + + const category = await categories.getCategoryData(cid); + ['topic_count', 'post_count', 'totalPostCount', 'totalTopicCount'].forEach((prop) => { + assert.strictEqual(category[prop], 1); + }); + }); }); - it('should slot newly created topic in remote category if addressed', async () => { - const { id: cid, actor } = helpers.mocks.group(); - activitypub._cache.set(`0;${cid}`, actor); - await activitypub.actors.assertGroup([cid]); + describe('User-specific behaviours', () => { + let remoteCid; + let uid; - const { id } = helpers.mocks.note({ - cc: ['https://example.org/user/foobar/followers', cid], + before(async () => { + // Remote + const { id, actor } = helpers.mocks.group(); + remoteCid = id; + activitypub._cache.set(`0;${id}`, actor); + await activitypub.actors.assertGroup([id]); + + // User + uid = await user.create({ username: utils.generateUUID() }); + await topics.markAllRead(uid); }); - const assertion = await activitypub.notes.assert(0, id); - assert(assertion); + it('should not show up in my unread if it is in cid -1', async () => { + const { id } = helpers.mocks.note(); + const assertion = await activitypub.notes.assert(0, id, { skipChecks: 1 }); + assert(assertion); - const { tid, count } = assertion; - assert(tid); - assert.strictEqual(count, 1); + const unread = await topics.getTotalUnread(uid); + assert.strictEqual(unread, 0); + }); - const topic = await topics.getTopicData(tid); - assert.strictEqual(topic.cid, cid); + it('should show up in my recent/unread if I am tracking the remote category', async () => { + await api.categories.setWatchState({ uid }, { + cid: remoteCid, + state: categories.watchStates.tracking, + uid, + }); - const tids = await db.getSortedSetMembers(`cid:${cid}:tids`); - assert(tids.includes(tid)); + const { id } = helpers.mocks.note({ + cc: [remoteCid], + }); + const assertion = await activitypub.notes.assert(0, id); + assert(assertion); - const category = await categories.getCategoryData(cid); - ['topic_count', 'post_count', 'totalPostCount', 'totalTopicCount'].forEach((prop) => { - assert.strictEqual(category[prop], 1); + const unread = await topics.getTotalUnread(uid); + assert.strictEqual(unread, 1); + + await topics.markAllRead(uid); + }); + + it('should show up in recent/unread and notify me if I am watching the remote category', async () => { + await api.categories.setWatchState({ uid }, { + cid: remoteCid, + state: categories.watchStates.watching, + uid, + }); + + const { id, note } = helpers.mocks.note({ + cc: [remoteCid], + }); + const assertion = await activitypub.notes.assert(0, id); + assert(assertion); + + const unread = await topics.getTotalUnread(uid); + assert.strictEqual(unread, 1); + + // Notification inbox delivery is async so can't test directly + const exists = await db.exists(`notifications:new_topic:tid:${assertion.tid}:uid:${note.attributedTo}`); + assert(exists); + + await topics.markAllRead(uid); }); }); });