feat: asserted topics and posts to remote categories will notify and add to unread based on remote category watch state

This commit is contained in:
Julian Lam
2025-03-17 14:52:52 -04:00
parent 804052f272
commit f483e883a7
6 changed files with 122 additions and 34 deletions

View File

@@ -70,7 +70,7 @@ define('forum/category', [
const $this = $(this); const $this = $(this);
const state = $this.attr('data-state'); const state = $this.attr('data-state');
api.put(`/categories/${cid}/watch`, { state }, (err) => { api.put(`/categories/${encodeURIComponent(cid)}/watch`, { state }, (err) => {
if (err) { if (err) {
return alerts.error(err); return alerts.error(err);
} }

View File

@@ -7,6 +7,7 @@ const events = require('../events');
const user = require('../user'); const user = require('../user');
const groups = require('../groups'); const groups = require('../groups');
const privileges = require('../privileges'); const privileges = require('../privileges');
const utils = require('../utils');
const activitypubApi = require('./activitypub'); const activitypubApi = require('./activitypub');
@@ -157,7 +158,9 @@ categoriesAPI.getTopics = async (caller, data) => {
categoriesAPI.setWatchState = async (caller, { cid, state, uid }) => { categoriesAPI.setWatchState = async (caller, { cid, state, uid }) => {
let targetUid = caller.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) { if (uid) {
targetUid = uid; targetUid = uid;
} }

View File

@@ -3,6 +3,7 @@
const db = require('../database'); const db = require('../database');
const user = require('../user'); const user = require('../user');
const activitypub = require('../activitypub'); const activitypub = require('../activitypub');
const utils = require('../utils');
module.exports = function (Categories) { module.exports = function (Categories) {
Categories.watchStates = { Categories.watchStates = {
@@ -32,7 +33,11 @@ module.exports = function (Categories) {
user.getSettings(uid), user.getSettings(uid),
db.sortedSetsScore(keys, 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) { Categories.getIgnorers = async function (cid, start, stop) {

View File

@@ -20,6 +20,11 @@ const uidToSystemGroup = {
}; };
helpers.isUsersAllowedTo = async function (privilege, uids, cid) { 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([ const [hasUserPrivilege, hasGroupPrivilege] = await Promise.all([
groups.isMembers(uids, `cid:${cid}:privileges:${privilege}`), groups.isMembers(uids, `cid:${cid}:privileges:${privilege}`),
groups.isMembersOfGroupList(uids, `cid:${cid}:privileges:groups:${privilege}`), groups.isMembersOfGroupList(uids, `cid:${cid}:privileges:groups:${privilege}`),

View File

@@ -67,7 +67,11 @@ module.exports = function (User) {
}; };
User.getCategoriesByStates = async function (uid, states) { 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)) { if (!(parseInt(uid, 10) > 0)) {
return cids; return cids;
} }

View File

@@ -10,6 +10,7 @@ const user = require('../../src/user');
const categories = require('../../src/categories'); const categories = require('../../src/categories');
const posts = require('../../src/posts'); const posts = require('../../src/posts');
const topics = require('../../src/topics'); const topics = require('../../src/topics');
const api = require('../../src/api');
const activitypub = require('../../src/activitypub'); const activitypub = require('../../src/activitypub');
const utils = require('../../src/utils'); const utils = require('../../src/utils');
@@ -22,7 +23,7 @@ describe('Notes', () => {
await install.giveWorldPrivileges(); 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 () => { it('should pull a remote root-level object by its id and create a new topic', async () => {
const { id } = helpers.mocks.note(); const { id } = helpers.mocks.note();
const assertion = await activitypub.notes.assert(0, id, { skipChecks: true }); const assertion = await activitypub.notes.assert(0, id, { skipChecks: true });
@@ -64,48 +65,118 @@ describe('Notes', () => {
assert(exists); assert(exists);
}); });
it('should slot newly created topic in local category if addressed', async () => { describe('Category-specific behaviours', () => {
const { cid } = await categories.create({ name: utils.generateUUID() }); it('should slot newly created topic in local category if addressed', async () => {
const { id } = helpers.mocks.note({ const { cid } = await categories.create({ name: utils.generateUUID() });
cc: ['https://example.org/user/foobar/followers', `${nconf.get('url')}/category/${cid}`], 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); it('should slot newly created topic in remote category if addressed', async () => {
assert(assertion); const { id: cid, actor } = helpers.mocks.group();
activitypub._cache.set(`0;${cid}`, actor);
await activitypub.actors.assertGroup([cid]);
const { tid, count } = assertion; const { id } = helpers.mocks.note({
assert(tid); cc: [cid],
assert.strictEqual(count, 1); });
const topic = await topics.getTopicData(tid); const assertion = await activitypub.notes.assert(0, id);
assert.strictEqual(topic.cid, cid); 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 () => { describe('User-specific behaviours', () => {
const { id: cid, actor } = helpers.mocks.group(); let remoteCid;
activitypub._cache.set(`0;${cid}`, actor); let uid;
await activitypub.actors.assertGroup([cid]);
const { id } = helpers.mocks.note({ before(async () => {
cc: ['https://example.org/user/foobar/followers', cid], // 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); it('should not show up in my unread if it is in cid -1', async () => {
assert(assertion); const { id } = helpers.mocks.note();
const assertion = await activitypub.notes.assert(0, id, { skipChecks: 1 });
assert(assertion);
const { tid, count } = assertion; const unread = await topics.getTotalUnread(uid);
assert(tid); assert.strictEqual(unread, 0);
assert.strictEqual(count, 1); });
const topic = await topics.getTopicData(tid); it('should show up in my recent/unread if I am tracking the remote category', async () => {
assert.strictEqual(topic.cid, cid); await api.categories.setWatchState({ uid }, {
cid: remoteCid,
state: categories.watchStates.tracking,
uid,
});
const tids = await db.getSortedSetMembers(`cid:${cid}:tids`); const { id } = helpers.mocks.note({
assert(tids.includes(tid)); cc: [remoteCid],
});
const assertion = await activitypub.notes.assert(0, id);
assert(assertion);
const category = await categories.getCategoryData(cid); const unread = await topics.getTotalUnread(uid);
['topic_count', 'post_count', 'totalPostCount', 'totalTopicCount'].forEach((prop) => { assert.strictEqual(unread, 1);
assert.strictEqual(category[prop], 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);
}); });
}); });
}); });