feat: show unread categories based on unread topics (#12317)

* feat: show unread categories based on unread topics

if a category has unread topics in one of its children then mark category unread
deprecate cid:<cid>:read_by_uid sets
upgrade script to remove the old sets

* chore: up harmony
This commit is contained in:
Barış Soner Uşaklı
2024-01-30 17:47:06 -05:00
committed by GitHub
parent ef06be6d3f
commit 45cfb3691e
15 changed files with 107 additions and 39 deletions

View File

@@ -103,7 +103,7 @@
"nodebb-plugin-ntfy": "1.7.3", "nodebb-plugin-ntfy": "1.7.3",
"nodebb-plugin-spam-be-gone": "2.2.0", "nodebb-plugin-spam-be-gone": "2.2.0",
"nodebb-rewards-essentials": "1.0.0", "nodebb-rewards-essentials": "1.0.0",
"nodebb-theme-harmony": "1.2.10", "nodebb-theme-harmony": "1.2.11",
"nodebb-theme-lavender": "7.1.7", "nodebb-theme-lavender": "7.1.7",
"nodebb-theme-peace": "2.2.0", "nodebb-theme-peace": "2.2.0",
"nodebb-theme-persona": "13.3.3", "nodebb-theme-persona": "13.3.3",

View File

@@ -38,6 +38,9 @@ get:
type: array type: array
items: items:
type: string type: string
unread:
type: boolean
description: True if category or it's children have unread topics
unread-class: unread-class:
type: string type: string
children: children:

View File

@@ -38,6 +38,9 @@ get:
type: array type: array
items: items:
type: string type: string
unread:
type: boolean
description: True if category or it's children have unread topics
unread-class: unread-class:
type: string type: string
children: children:

View File

@@ -33,6 +33,9 @@ get:
type: array type: array
items: items:
type: string type: string
unread:
type: boolean
description: True if category or it's children have unread topics
unread-class: unread-class:
type: string type: string
children: children:

View File

@@ -49,7 +49,7 @@ categoriesAPI.create = async function (caller, data) {
await hasAdminPrivilege(caller.uid); await hasAdminPrivilege(caller.uid);
const response = await categories.create(data); const response = await categories.create(data);
const categoryObjs = await categories.getCategories([response.cid], caller.uid); const categoryObjs = await categories.getCategories([response.cid]);
return categoryObjs[0]; return categoryObjs[0];
}; };

View File

@@ -5,6 +5,7 @@ const _ = require('lodash');
const db = require('../database'); const db = require('../database');
const user = require('../user'); const user = require('../user');
const topics = require('../topics');
const plugins = require('../plugins'); const plugins = require('../plugins');
const privileges = require('../privileges'); const privileges = require('../privileges');
const cache = require('../cache'); const cache = require('../cache');
@@ -30,7 +31,7 @@ Categories.exists = async function (cids) {
}; };
Categories.getCategoryById = async function (data) { Categories.getCategoryById = async function (data) {
const categories = await Categories.getCategories([data.cid], data.uid); const categories = await Categories.getCategories([data.cid]);
if (!categories[0]) { if (!categories[0]) {
return null; return null;
} }
@@ -78,9 +79,9 @@ Categories.getAllCidsFromSet = async function (key) {
return cids.slice(); return cids.slice();
}; };
Categories.getAllCategories = async function (uid) { Categories.getAllCategories = async function () {
const cids = await Categories.getAllCidsFromSet('categories:cid'); const cids = await Categories.getAllCidsFromSet('categories:cid');
return await Categories.getCategories(cids, uid); return await Categories.getCategories(cids);
}; };
Categories.getCidsByPrivilege = async function (set, uid, privilege) { Categories.getCidsByPrivilege = async function (set, uid, privilege) {
@@ -90,7 +91,7 @@ Categories.getCidsByPrivilege = async function (set, uid, privilege) {
Categories.getCategoriesByPrivilege = async function (set, uid, privilege) { Categories.getCategoriesByPrivilege = async function (set, uid, privilege) {
const cids = await Categories.getCidsByPrivilege(set, uid, privilege); const cids = await Categories.getCidsByPrivilege(set, uid, privilege);
return await Categories.getCategories(cids, uid); return await Categories.getCategories(cids);
}; };
Categories.getModerators = async function (cid) { Categories.getModerators = async function (cid) {
@@ -102,7 +103,7 @@ Categories.getModeratorUids = async function (cids) {
return await privileges.categories.getUidsWithPrivilege(cids, 'moderate'); return await privileges.categories.getUidsWithPrivilege(cids, 'moderate');
}; };
Categories.getCategories = async function (cids, uid) { Categories.getCategories = async function (cids) {
if (!Array.isArray(cids)) { if (!Array.isArray(cids)) {
throw new Error('[[error:invalid-cid]]'); throw new Error('[[error:invalid-cid]]');
} }
@@ -110,22 +111,46 @@ Categories.getCategories = async function (cids, uid) {
if (!cids.length) { if (!cids.length) {
return []; return [];
} }
uid = parseInt(uid, 10);
const [categories, tagWhitelist, hasRead] = await Promise.all([ const [categories, tagWhitelist] = await Promise.all([
Categories.getCategoriesData(cids), Categories.getCategoriesData(cids),
Categories.getTagWhitelist(cids), Categories.getTagWhitelist(cids),
Categories.hasReadCategories(cids, uid),
]); ]);
categories.forEach((category, i) => { categories.forEach((category, i) => {
if (category) { if (category) {
category.tagWhitelist = tagWhitelist[i]; category.tagWhitelist = tagWhitelist[i];
category['unread-class'] = (category.topic_count === 0 || (hasRead[i] && uid !== 0)) ? '' : 'unread';
} }
}); });
return categories; return categories;
}; };
Categories.setUnread = async function (tree, cids, uid) {
if (uid <= 0) {
return;
}
const { unreadCids } = await topics.getUnreadData({
uid: uid,
cid: cids,
});
if (!unreadCids.length) {
return;
}
function setCategoryUnread(category) {
if (category) {
category.unread = false;
if (unreadCids.includes(category.cid)) {
category.unread = category.topic_count > 0 && true;
} else if (category.children.length) {
category.children.forEach(setCategoryUnread);
category.unread = category.children.some(c => c && c.unread);
}
category['unread-class'] = category.unread ? 'unread' : '';
}
}
tree.forEach(setCategoryUnread);
};
Categories.getTagWhitelist = async function (cids) { Categories.getTagWhitelist = async function (cids) {
const cachedData = {}; const cachedData = {};
@@ -210,10 +235,6 @@ async function getChildrenTree(category, uid) {
let childrenData = await Categories.getCategoriesData(childrenCids); let childrenData = await Categories.getCategoriesData(childrenCids);
childrenData = childrenData.filter(Boolean); childrenData = childrenData.filter(Boolean);
childrenCids = childrenData.map(child => child.cid); childrenCids = childrenData.map(child => child.cid);
const hasRead = await Categories.hasReadCategories(childrenCids, uid);
childrenData.forEach((child, i) => {
child['unread-class'] = (child.topic_count === 0 || (hasRead[i] && uid !== 0)) ? '' : 'unread';
});
Categories.getTree([category].concat(childrenData), category.parentCid); Categories.getTree([category].concat(childrenData), category.parentCid);
} }

View File

@@ -38,7 +38,7 @@ module.exports = function (Categories) {
const childrenCids = await getChildrenCids(cids, uid); const childrenCids = await getChildrenCids(cids, uid);
const uniqCids = _.uniq(cids.concat(childrenCids)); const uniqCids = _.uniq(cids.concat(childrenCids));
const categoryData = await Categories.getCategories(uniqCids, uid); const categoryData = await Categories.getCategories(uniqCids);
Categories.getTree(categoryData, 0); Categories.getTree(categoryData, 0);
await Categories.getRecentTopicReplies(categoryData, uid, data.qs); await Categories.getRecentTopicReplies(categoryData, uid, data.qs);

View File

@@ -4,6 +4,8 @@ const db = require('../database');
module.exports = function (Categories) { module.exports = function (Categories) {
Categories.markAsRead = async function (cids, uid) { Categories.markAsRead = async function (cids, uid) {
// TODO: remove in 4.0
console.warn('[deprecated] Categories.markAsRead deprecated');
if (!Array.isArray(cids) || !cids.length || parseInt(uid, 10) <= 0) { if (!Array.isArray(cids) || !cids.length || parseInt(uid, 10) <= 0) {
return; return;
} }
@@ -14,6 +16,8 @@ module.exports = function (Categories) {
}; };
Categories.markAsUnreadForAll = async function (cid) { Categories.markAsUnreadForAll = async function (cid) {
// TODO: remove in 4.0
console.warn('[deprecated] Categories.markAsUnreadForAll deprecated');
if (!parseInt(cid, 10)) { if (!parseInt(cid, 10)) {
return; return;
} }
@@ -21,6 +25,8 @@ module.exports = function (Categories) {
}; };
Categories.hasReadCategories = async function (cids, uid) { Categories.hasReadCategories = async function (cids, uid) {
// TODO: remove in 4.0
console.warn('[deprecated] Categories.hasReadCategories deprecated, see Categories.setUnread');
if (parseInt(uid, 10) <= 0) { if (parseInt(uid, 10) <= 0) {
return cids.map(() => false); return cids.map(() => false);
} }
@@ -30,6 +36,8 @@ module.exports = function (Categories) {
}; };
Categories.hasReadCategory = async function (cid, uid) { Categories.hasReadCategory = async function (cid, uid) {
// TODO: remove in 4.0
console.warn('[deprecated] Categories.hasReadCategory deprecated, see Categories.setUnread');
if (parseInt(uid, 10) <= 0) { if (parseInt(uid, 10) <= 0) {
return false; return false;
} }

View File

@@ -14,7 +14,7 @@ const categoriesController = module.exports;
categoriesController.get = async function (req, res, next) { categoriesController.get = async function (req, res, next) {
const [categoryData, parent, selectedData] = await Promise.all([ const [categoryData, parent, selectedData] = await Promise.all([
categories.getCategories([req.params.category_id], req.uid), categories.getCategories([req.params.category_id]),
categories.getParents([req.params.category_id]), categories.getParents([req.params.category_id]),
helpers.getSelectedCategory(req.params.category_id), helpers.getSelectedCategory(req.params.category_id),
]); ]);

View File

@@ -30,9 +30,12 @@ categoriesController.list = async function (req, res) {
const allChildCids = _.flatten(await Promise.all(pageCids.map(categories.getChildrenCids))); const allChildCids = _.flatten(await Promise.all(pageCids.map(categories.getChildrenCids)));
const childCids = await privileges.categories.filterCids('find', allChildCids, req.uid); const childCids = await privileges.categories.filterCids('find', allChildCids, req.uid);
const categoryData = await categories.getCategories(pageCids.concat(childCids), req.uid); const categoryData = await categories.getCategories(pageCids.concat(childCids));
const tree = categories.getTree(categoryData, 0); const tree = categories.getTree(categoryData, 0);
await categories.getRecentTopicReplies(categoryData, req.uid, req.query); await Promise.all([
categories.getRecentTopicReplies(categoryData, req.uid, req.query),
categories.setUnread(tree, pageCids.concat(childCids), req.uid),
]);
const data = { const data = {
title: meta.config.homePageTitle || '[[pages:home]]', title: meta.config.homePageTitle || '[[pages:home]]',

View File

@@ -98,10 +98,15 @@ categoryController.get = async function (req, res, next) {
categories.modifyTopicsByPrivilege(categoryData.topics, userPrivileges); categories.modifyTopicsByPrivilege(categoryData.topics, userPrivileges);
categoryData.tagWhitelist = categories.filterTagWhitelist(categoryData.tagWhitelist, userPrivileges.isAdminOrMod); categoryData.tagWhitelist = categories.filterTagWhitelist(categoryData.tagWhitelist, userPrivileges.isAdminOrMod);
await buildBreadcrumbs(req, categoryData);
if (categoryData.children.length) {
const allCategories = []; const allCategories = [];
categories.flattenCategories(allCategories, categoryData.children); categories.flattenCategories(allCategories, categoryData.children);
await Promise.all([
buildBreadcrumbs(req, categoryData),
categories.setUnread([categoryData], allCategories.map(c => c.cid).concat(cid), req.uid),
]);
if (categoryData.children.length) {
await categories.getRecentTopicReplies(allCategories, req.uid, req.query); await categories.getRecentTopicReplies(allCategories, req.uid, req.query);
categoryData.subCategoriesLeft = Math.max(0, categoryData.children.length - categoryData.subCategoriesPerPage); categoryData.subCategoriesLeft = Math.max(0, categoryData.children.length - categoryData.subCategoriesPerPage);
categoryData.hasMoreSubCategories = categoryData.children.length > categoryData.subCategoriesPerPage; categoryData.hasMoreSubCategories = categoryData.children.length > categoryData.subCategoriesPerPage;
@@ -124,9 +129,6 @@ categoryController.get = async function (req, res, next) {
categoryData.topicIndex = topicIndex; categoryData.topicIndex = topicIndex;
categoryData.selectedTag = tagData.selectedTag; categoryData.selectedTag = tagData.selectedTag;
categoryData.selectedTags = tagData.selectedTags; categoryData.selectedTags = tagData.selectedTags;
if (req.loggedIn) {
categories.markAsRead([cid], req.uid);
}
if (!meta.config['feeds:disableRSS']) { if (!meta.config['feeds:disableRSS']) {
categoryData.rssFeedUrl = `${url}/category/${categoryData.cid}.rss`; categoryData.rssFeedUrl = `${url}/category/${categoryData.cid}.rss`;

View File

@@ -224,7 +224,6 @@ module.exports = function (Topics) {
async function onNewPost(postData, data) { async function onNewPost(postData, data) {
const { tid, uid } = postData; const { tid, uid } = postData;
await Topics.markCategoryUnreadForAll(tid);
await Topics.markAsRead([tid], uid); await Topics.markAsRead([tid], uid);
const [ const [
userInfo, userInfo,

View File

@@ -80,6 +80,7 @@ module.exports = function (Topics) {
tids: data.tids, tids: data.tids,
counts: data.counts, counts: data.counts,
tidsByFilter: data.tidsByFilter, tidsByFilter: data.tidsByFilter,
unreadCids: data.unreadCids,
cid: params.cid, cid: params.cid,
filter: params.filter, filter: params.filter,
query: params.query || {}, query: params.query || {},
@@ -90,9 +91,9 @@ module.exports = function (Topics) {
async function getTids(params) { async function getTids(params) {
const counts = { '': 0, new: 0, watched: 0, unreplied: 0 }; const counts = { '': 0, new: 0, watched: 0, unreplied: 0 };
const tidsByFilter = { '': [], new: [], watched: [], unreplied: [] }; const tidsByFilter = { '': [], new: [], watched: [], unreplied: [] };
const unreadCids = [];
if (params.uid <= 0) { if (params.uid <= 0) {
return { counts: counts, tids: [], tidsByFilter: tidsByFilter }; return { counts, tids: [], tidsByFilter, unreadCids };
} }
params.cutoff = await Topics.unreadCutoff(params.uid); params.cutoff = await Topics.unreadCutoff(params.uid);
@@ -126,7 +127,7 @@ module.exports = function (Topics) {
let tids = _.uniq(unreadTopics.map(topic => topic.value)).slice(0, 200); let tids = _.uniq(unreadTopics.map(topic => topic.value)).slice(0, 200);
if (!tids.length) { if (!tids.length) {
return { counts: counts, tids: tids, tidsByFilter: tidsByFilter }; return { counts, tids, tidsByFilter, unreadCids };
} }
const blockedUids = await user.blocks.list(params.uid); const blockedUids = await user.blocks.list(params.uid);
@@ -157,6 +158,7 @@ module.exports = function (Topics) {
if (isTopicsFollowed[topic.tid] || if (isTopicsFollowed[topic.tid] ||
[categories.watchStates.watching, categories.watchStates.tracking].includes(userCidState[topic.cid])) { [categories.watchStates.watching, categories.watchStates.tracking].includes(userCidState[topic.cid])) {
tidsByFilter[''].push(topic.tid); tidsByFilter[''].push(topic.tid);
unreadCids.push(topic.cid);
} }
if (isTopicsFollowed[topic.tid]) { if (isTopicsFollowed[topic.tid]) {
@@ -182,6 +184,7 @@ module.exports = function (Topics) {
counts: counts, counts: counts,
tids: tidsByFilter[params.filter], tids: tidsByFilter[params.filter],
tidsByFilter: tidsByFilter, tidsByFilter: tidsByFilter,
unreadCids: _.uniq(unreadCids),
}; };
} }
@@ -280,7 +283,6 @@ module.exports = function (Topics) {
Topics.markAsUnreadForAll = async function (tid) { Topics.markAsUnreadForAll = async function (tid) {
const now = Date.now(); const now = Date.now();
const cid = await Topics.getTopicField(tid, 'cid'); const cid = await Topics.getTopicField(tid, 'cid');
await Topics.markCategoryUnreadForAll(tid);
await Topics.updateRecent(tid, now); await Topics.updateRecent(tid, now);
await db.sortedSetAdd(`cid:${cid}:tids:lastposttime`, now, tid); await db.sortedSetAdd(`cid:${cid}:tids:lastposttime`, now, tid);
await Topics.setTopicField(tid, 'lastposttime', now); await Topics.setTopicField(tid, 'lastposttime', now);
@@ -312,15 +314,11 @@ module.exports = function (Topics) {
} }
const scores = topics.map(topic => (topic.scheduled ? topic.lastposttime : now)); const scores = topics.map(topic => (topic.scheduled ? topic.lastposttime : now));
const [topicData] = await Promise.all([ await Promise.all([
Topics.getTopicsFields(tids, ['cid']),
db.sortedSetAdd(`uid:${uid}:tids_read`, scores, tids), db.sortedSetAdd(`uid:${uid}:tids_read`, scores, tids),
db.sortedSetRemove(`uid:${uid}:tids_unread`, tids), db.sortedSetRemove(`uid:${uid}:tids_unread`, tids),
]); ]);
const cids = _.uniq(topicData.map(t => t && t.cid).filter(Boolean));
await categories.markAsRead(cids, uid);
plugins.hooks.fire('action:topics.markAsRead', { uid: uid, tids: tids }); plugins.hooks.fire('action:topics.markAsRead', { uid: uid, tids: tids });
return true; return true;
}; };
@@ -343,9 +341,11 @@ module.exports = function (Topics) {
user.notifications.pushCount(uid); user.notifications.pushCount(uid);
}; };
Topics.markCategoryUnreadForAll = async function (tid) { Topics.markCategoryUnreadForAll = async function (/* tid */) {
const cid = await Topics.getTopicField(tid, 'cid'); // TODO: remove in 4.x
await categories.markAsUnreadForAll(cid); console.warn('[deprecated] Topics.markCategoryUnreadForAll deprecated');
// const cid = await Topics.getTopicField(tid, 'cid');
// await categories.markAsUnreadForAll(cid);
}; };
Topics.hasReadTopics = async function (tids, uid) { Topics.hasReadTopics = async function (tids, uid) {

View File

@@ -0,0 +1,26 @@
/* eslint-disable no-await-in-loop */
'use strict';
const db = require('../../database');
const batch = require('../../batch');
module.exports = {
name: 'Remove cid:<cid>:read_by_uid sets',
timestamp: Date.UTC(2024, 0, 29),
method: async function () {
const { progress } = this;
const nextCid = await db.getObjectField('global', 'nextCid');
const allCids = [];
for (let i = 1; i <= nextCid; i++) {
allCids.push(i);
}
await batch.processArray(allCids, async (cids) => {
await db.deleteAll(cids.map(cid => `cid:${cid}:read_by_uid`));
progress.incr(cids.length);
}, {
batch: 500,
progress,
});
},
};

View File

@@ -68,7 +68,7 @@ describe('Categories', () => {
}); });
it('should get all categories', (done) => { it('should get all categories', (done) => {
Categories.getAllCategories(1, (err, data) => { Categories.getAllCategories((err, data) => {
assert.ifError(err); assert.ifError(err);
assert(Array.isArray(data)); assert(Array.isArray(data));
assert.equal(data[0].cid, categoryObj.cid); assert.equal(data[0].cid, categoryObj.cid);