From 84fed97b41be09461ec5f6ffb0850ee2e70407d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Fri, 3 Nov 2023 12:49:17 -0400 Subject: [PATCH] feat: add tracking categories and make watching send notifications (#12147) * feat: add tracking categories and make watching send notifications upgrade script to change the defaults * add missing spec * test: one more spec --- .../language/en-GB/admin/settings/user.json | 2 +- public/language/en-GB/category.json | 7 +++- public/language/en-GB/notifications.json | 4 ++ .../components/schemas/SettingsObj.yaml | 3 ++ public/openapi/read/category/category_id.yaml | 2 + .../read/user/userslug/categories.yaml | 2 + public/src/client/account/categories.js | 11 ++++- public/src/client/category.js | 5 ++- public/src/modules/categorySearch.js | 2 +- public/src/modules/topicList.js | 2 +- src/api/search.js | 2 +- src/categories/index.js | 1 + src/categories/topics.js | 40 +++++++++++++++++++ src/categories/watch.js | 3 +- src/controllers/accounts/categories.js | 3 +- src/controllers/accounts/notifications.js | 1 + src/notifications.js | 1 + src/topics/create.js | 1 + src/topics/unread.js | 16 +++++++- src/upgrades/3.6.0/category_tracking.js | 32 +++++++++++++++ src/user/categories.js | 6 +-- src/views/admin/settings/user.tpl | 2 +- 22 files changed, 131 insertions(+), 17 deletions(-) create mode 100644 src/upgrades/3.6.0/category_tracking.js diff --git a/public/language/en-GB/admin/settings/user.json b/public/language/en-GB/admin/settings/user.json index 816bdfc150..88f14c76cd 100644 --- a/public/language/en-GB/admin/settings/user.json +++ b/public/language/en-GB/admin/settings/user.json @@ -79,7 +79,7 @@ "follow-replied-topics": "Follow topics that you reply to", "default-notification-settings": "Default notification settings", "categoryWatchState": "Default category watch state", - "categoryWatchState.watching": "Watching", + "categoryWatchState.tracking": "Tracking", "categoryWatchState.notwatching": "Not Watching", "categoryWatchState.ignoring": "Ignoring" } diff --git a/public/language/en-GB/category.json b/public/language/en-GB/category.json index 968d3d218d..7e3c6630c5 100644 --- a/public/language/en-GB/category.json +++ b/public/language/en-GB/category.json @@ -13,13 +13,16 @@ "watch": "Watch", "ignore": "Ignore", "watching": "Watching", + "tracking": "Tracking", "not-watching": "Not Watching", "ignoring": "Ignoring", - "watching.description": "Show topics in unread and recent", + "watching.description": "Notify me of new topics.
Show topics in unread & recent", + "tracking.description": "Shows topics in unread & recent", "not-watching.description": "Do not show topics in unread, show in recent", - "ignoring.description": "Do not show topics in unread and recent", + "ignoring.description": "Do not show topics in unread & recent", "watching.message": "You are now watching updates from this category and all subcategories", + "tracking.message": "You are now tracking updates from this category and all subcategories", "notwatching.message": "You are not watching updates from this category and all subcategories", "ignoring.message": "You are now ignoring updates from this category and all subcategories", diff --git a/public/language/en-GB/notifications.json b/public/language/en-GB/notifications.json index 8670874814..2782fdaff9 100644 --- a/public/language/en-GB/notifications.json +++ b/public/language/en-GB/notifications.json @@ -15,6 +15,7 @@ "all": "All", "topics": "Topics", "tags": "Tags", + "categories": "Categories", "replies": "Replies", "chat": "Chats", "group-chat": "Group Chats", @@ -61,6 +62,8 @@ "user-posted-topic-with-tag-triple": "%1 has posted a new topic with tags %2, %3 and %4", "user-posted-topic-with-tag-multiple": "%1 has posted a new topic with tags %2", + "user-posted-topic-in-category": "%1 has posted a new topic in %2", + "user-started-following-you": "%1 started following you.", "user-started-following-you-dual": "%1 and %2 started following you.", "user-started-following-you-triple": "%1, %2 and %3 started following you.", @@ -89,6 +92,7 @@ "notificationType-upvote": "When someone upvotes your post", "notificationType-new-topic": "When someone you follow posts a topic", "notificationType-new-topic-with-tag": "When a topic is posted with a tag you follow", + "notificationType-new-topic-in-category": "When a topic is posted in a category you are watching", "notificationType-new-reply": "When a new reply is posted in a topic you are watching", "notificationType-post-edit": "When a post is edited in a topic you are watching", "notificationType-follow": "When someone starts following you", diff --git a/public/openapi/components/schemas/SettingsObj.yaml b/public/openapi/components/schemas/SettingsObj.yaml index c9f990e5ac..2ccc8e161c 100644 --- a/public/openapi/components/schemas/SettingsObj.yaml +++ b/public/openapi/components/schemas/SettingsObj.yaml @@ -83,6 +83,9 @@ Settings: notificationType_new-topic-with-tag: type: string description: Notification type for new topics with followed tag + notificationType_new-topic-in-category: + type: string + description: Notification type for new topics in watched category notificationType_follow: type: string description: Notification type for another user following you diff --git a/public/openapi/read/category/category_id.yaml b/public/openapi/read/category/category_id.yaml index 2735ce5e90..c0d44fa413 100644 --- a/public/openapi/read/category/category_id.yaml +++ b/public/openapi/read/category/category_id.yaml @@ -52,6 +52,8 @@ get: type: number isWatched: type: boolean + isTracked: + type: boolean isNotWatched: type: boolean isIgnored: diff --git a/public/openapi/read/user/userslug/categories.yaml b/public/openapi/read/user/userslug/categories.yaml index 20cc798400..f25a168f91 100644 --- a/public/openapi/read/user/userslug/categories.yaml +++ b/public/openapi/read/user/userslug/categories.yaml @@ -52,6 +52,8 @@ get: type: boolean isWatched: type: boolean + isTracked: + type: boolean isNotWatched: type: boolean imageClass: diff --git a/public/src/client/account/categories.js b/public/src/client/account/categories.js index bb6849b166..19ada5d8dd 100644 --- a/public/src/client/account/categories.js +++ b/public/src/client/account/categories.js @@ -11,7 +11,9 @@ define('forum/account/categories', ['forum/account/header', 'alerts', 'api'], fu handleIgnoreWatch(category.cid); }); - $('[component="category/watch/all"]').find('[component="category/watching"], [component="category/ignoring"], [component="category/notwatching"]').on('click', async (e) => { + $('[component="category/watch/all"]').find( + '[component="category/watching"], [component="category/tracking"], [component="category/ignoring"], [component="category/notwatching"]' + ).on('click', async (e) => { const cids = []; const state = e.currentTarget.getAttribute('data-state'); const { uid } = ajaxify.data; @@ -30,7 +32,9 @@ define('forum/account/categories', ['forum/account/header', 'alerts', 'api'], fu function handleIgnoreWatch(cid) { const category = $('[data-cid="' + cid + '"]'); - category.find('[component="category/watching"], [component="category/ignoring"], [component="category/notwatching"]').on('click', async (e) => { + category.find( + '[component="category/watching"], [component="category/tracking"], [component="category/ignoring"], [component="category/notwatching"]' + ).on('click', async (e) => { const state = e.currentTarget.getAttribute('data-state'); const { uid } = ajaxify.data; @@ -46,6 +50,9 @@ define('forum/account/categories', ['forum/account/header', 'alerts', 'api'], fu category.find('[component="category/watching/menu"]').toggleClass('hidden', state !== 'watching'); category.find('[component="category/watching/check"]').toggleClass('fa-check', state === 'watching'); + category.find('[component="category/tracking/menu"]').toggleClass('hidden', state !== 'tracking'); + category.find('[component="category/tracking/check"]').toggleClass('fa-check', state === 'tracking'); + category.find('[component="category/notwatching/menu"]').toggleClass('hidden', state !== 'notwatching'); category.find('[component="category/notwatching/check"]').toggleClass('fa-check', state === 'notwatching'); diff --git a/public/src/client/category.js b/public/src/client/category.js index e1ab97431f..c452273761 100644 --- a/public/src/client/category.js +++ b/public/src/client/category.js @@ -65,7 +65,7 @@ define('forum/category', [ } function handleIgnoreWatch(cid) { - $('[component="category/watching"], [component="category/ignoring"], [component="category/notwatching"]').on('click', function () { + $('[component="category/watching"], [component="category/tracking"], [component="category/ignoring"], [component="category/notwatching"]').on('click', function () { const $this = $(this); const state = $this.attr('data-state'); @@ -77,6 +77,9 @@ define('forum/category', [ $('[component="category/watching/menu"]').toggleClass('hidden', state !== 'watching'); $('[component="category/watching/check"]').toggleClass('fa-check', state === 'watching'); + $('[component="category/tracking/menu"]').toggleClass('hidden', state !== 'tracking'); + $('[component="category/tracking/check"]').toggleClass('fa-check', state === 'tracking'); + $('[component="category/notwatching/menu"]').toggleClass('hidden', state !== 'notwatching'); $('[component="category/notwatching/check"]').toggleClass('fa-check', state === 'notwatching'); diff --git a/public/src/modules/categorySearch.js b/public/src/modules/categorySearch.js index ed9788ca3e..8c7461dcdf 100644 --- a/public/src/modules/categorySearch.js +++ b/public/src/modules/categorySearch.js @@ -7,7 +7,7 @@ define('categorySearch', ['alerts', 'bootstrap', 'api'], function (alerts, boots let categoriesList = null; options = options || {}; options.privilege = options.privilege || 'topics:read'; - options.states = options.states || ['watching', 'notwatching', 'ignoring']; + options.states = options.states || ['watching', 'tracking', 'notwatching', 'ignoring']; options.cacheList = options.hasOwnProperty('cacheList') ? options.cacheList : true; let localCategories = []; diff --git a/public/src/modules/topicList.js b/public/src/modules/topicList.js index f771eca47c..6396bb9e8e 100644 --- a/public/src/modules/topicList.js +++ b/public/src/modules/topicList.js @@ -34,7 +34,7 @@ define('topicList', [ categoryTools.init(); TopicList.watchForNewPosts(); - const states = ['watching']; + const states = ['watching', 'tracking']; if (ajaxify.data.selectedFilter && ajaxify.data.selectedFilter.filter === 'watched') { states.push('notwatching', 'ignoring'); } else if (template !== 'unread') { diff --git a/src/api/search.js b/src/api/search.js index 18bd9fa160..ac77347c26 100644 --- a/src/api/search.js +++ b/src/api/search.js @@ -17,7 +17,7 @@ searchApi.categories = async (caller, data) => { let cids = []; let matchedCids = []; const privilege = data.privilege || 'topics:read'; - data.states = (data.states || ['watching', 'notwatching', 'ignoring']).map( + data.states = (data.states || ['watching', 'tracking', 'notwatching', 'ignoring']).map( state => categories.watchStates[state] ); data.parentCid = parseInt(data.parentCid || 0, 10); diff --git a/src/categories/index.js b/src/categories/index.js index 3fe5d9d457..6b8db37edd 100644 --- a/src/categories/index.js +++ b/src/categories/index.js @@ -53,6 +53,7 @@ Categories.getCategoryById = async function (data) { category.nextStart = topics.nextStart; category.topic_count = topicCount; category.isWatched = watchState[0] === Categories.watchStates.watching; + category.isTracked = watchState[0] === Categories.watchStates.tracking; category.isNotWatched = watchState[0] === Categories.watchStates.notwatching; category.isIgnored = watchState[0] === Categories.watchStates.ignoring; category.parent = parent; diff --git a/src/categories/topics.js b/src/categories/topics.js index 2abb272475..2bc9aa58cf 100644 --- a/src/categories/topics.js +++ b/src/categories/topics.js @@ -6,6 +6,9 @@ const plugins = require('../plugins'); const meta = require('../meta'); const privileges = require('../privileges'); const user = require('../user'); +const notifications = require('../notifications'); +const translator = require('../translator'); +const batch = require('../batch'); module.exports = function (Categories) { Categories.getCategoryTopics = async function (data) { @@ -203,4 +206,41 @@ module.exports = function (Categories) { const now = Date.now(); return tids.filter((tid, index) => tid && (!scores[index] || scores[index] <= now)); } + + Categories.notifyCategoryFollowers = async (postData, exceptUid) => { + const { cid } = postData.topic; + const followers = []; + await batch.processSortedSet(`cid:${cid}:uid:watch:state`, async (uids) => { + followers.push( + ...await privileges.categories.filterUids('topics:read', cid, uids) + ); + }, { + batch: 500, + min: Categories.watchStates.watching, + max: Categories.watchStates.watching, + }); + + if (!followers.length) { + return; + } + + const { displayname } = postData.user; + const categoryName = await Categories.getCategoryField(cid, 'name'); + const notifBase = 'notifications:user-posted-topic-in-category'; + + const bodyShort = translator.compile(notifBase, displayname, categoryName); + + const notification = await notifications.create({ + type: 'new-topic-in-category', + nid: `new_topic:tid:${postData.topic.tid}:uid:${exceptUid}`, + subject: bodyShort, + bodyShort: bodyShort, + bodyLong: postData.content, + pid: postData.pid, + path: `/post/${postData.pid}`, + tid: postData.topic.tid, + from: exceptUid, + }); + notifications.push(notification, followers); + }; }; diff --git a/src/categories/watch.js b/src/categories/watch.js index 4a64fa9ec8..f80d0bf15d 100644 --- a/src/categories/watch.js +++ b/src/categories/watch.js @@ -7,7 +7,8 @@ module.exports = function (Categories) { Categories.watchStates = { ignoring: 1, notwatching: 2, - watching: 3, + tracking: 3, + watching: 4, }; Categories.isIgnored = async function (cids, uid) { diff --git a/src/controllers/accounts/categories.js b/src/controllers/accounts/categories.js index 1e8ccab576..4dfaf95f31 100644 --- a/src/controllers/accounts/categories.js +++ b/src/controllers/accounts/categories.js @@ -24,9 +24,10 @@ categoriesController.get = async function (req, res) { categoriesData.forEach((category) => { if (category) { - category.isIgnored = states[category.cid] === categories.watchStates.ignoring; category.isWatched = states[category.cid] === categories.watchStates.watching; + category.isTracked = states[category.cid] === categories.watchStates.tracking; category.isNotWatched = states[category.cid] === categories.watchStates.notwatching; + category.isIgnored = states[category.cid] === categories.watchStates.ignoring; } }); diff --git a/src/controllers/accounts/notifications.js b/src/controllers/accounts/notifications.js index fd5ce2529c..301851ca36 100644 --- a/src/controllers/accounts/notifications.js +++ b/src/controllers/accounts/notifications.js @@ -13,6 +13,7 @@ notificationsController.get = async function (req, res, next) { { name: '[[global:topics]]', filter: 'new-topic' }, { name: '[[notifications:replies]]', filter: 'new-reply' }, { name: '[[notifications:tags]]', filter: 'new-topic-with-tag' }, + { name: '[[notifications:categories]]', filter: 'new-topic-in-category' }, { name: '[[notifications:chat]]', filter: 'new-chat' }, { name: '[[notifications:group-chat]]', filter: 'new-group-chat' }, { name: '[[notifications:public-chat]]', filter: 'new-public-chat' }, diff --git a/src/notifications.js b/src/notifications.js index 681c293067..612c67d95f 100644 --- a/src/notifications.js +++ b/src/notifications.js @@ -31,6 +31,7 @@ Notifications.baseTypes = [ 'notificationType_upvote', 'notificationType_new-topic', 'notificationType_new-topic-with-tag', + 'notificationType_new-topic-in-category', 'notificationType_new-reply', 'notificationType_post-edit', 'notificationType_follow', diff --git a/src/topics/create.js b/src/topics/create.js index e87b48327a..c8a098a9ae 100644 --- a/src/topics/create.js +++ b/src/topics/create.js @@ -153,6 +153,7 @@ module.exports = function (Topics) { if (parseInt(uid, 10) && !topicData.scheduled) { user.notifications.sendTopicNotificationToFollowers(uid, topicData, postData); Topics.notifyTagFollowers(postData, uid); + categories.notifyCategoryFollowers(postData, uid); } return { diff --git a/src/topics/unread.js b/src/topics/unread.js index 9c6d2c35c2..9c54445233 100644 --- a/src/topics/unread.js +++ b/src/topics/unread.js @@ -154,7 +154,8 @@ module.exports = function (Topics) { (!filterCids || filterCids.includes(topic.cid)) && (!filterTags || filterTags.every(tag => topic.tags.find(topicTag => topicTag.value === tag))) && !blockedUids.includes(topic.uid)) { - if (isTopicsFollowed[topic.tid] || userCidState[topic.cid] === categories.watchStates.watching) { + if (isTopicsFollowed[topic.tid] || + [categories.watchStates.watching, categories.watchStates.tracking].includes(userCidState[topic.cid])) { tidsByFilter[''].push(topic.tid); } @@ -192,11 +193,22 @@ module.exports = function (Topics) { if (params.filter === 'watched') { return []; } - const cids = params.cid || await user.getWatchedCategories(params.uid); + const cids = params.cid || await getWatchedTrackedCids(params.uid); const keys = cids.map(cid => `cid:${cid}:tids:lastposttime`); return await db.getSortedSetRevRangeByScoreWithScores(keys, 0, -1, '+inf', params.cutoff); } + async function getWatchedTrackedCids(uid) { + if (!(parseInt(uid, 10) > 0)) { + return []; + } + const cids = await user.getCategoriesByStates(uid, [ + categories.watchStates.watching, categories.watchStates.tracking, + ]); + const categoryData = await categories.getCategoriesFields(cids, ['disabled']); + return cids.filter((cid, index) => categoryData[index] && !categoryData[index].disabled); + } + async function getFollowedTids(params) { let tids = await db.getSortedSetMembers(`uid:${params.uid}:followed_tids`); const filterCids = params.cid && params.cid.map(cid => parseInt(cid, 10)); diff --git a/src/upgrades/3.6.0/category_tracking.js b/src/upgrades/3.6.0/category_tracking.js new file mode 100644 index 0000000000..a30be983b6 --- /dev/null +++ b/src/upgrades/3.6.0/category_tracking.js @@ -0,0 +1,32 @@ +/* eslint-disable no-await-in-loop */ + +'use strict'; + +const db = require('../../database'); +const user = require('../../user'); +const batch = require('../../batch'); + +module.exports = { + name: 'Add tracking category state', + timestamp: Date.UTC(2023, 10, 3), + method: async function () { + const { progress } = this; + + const current = await db.getObjectField('config', 'categoryWatchState'); + if (current === 'watching') { + await db.setObjectField('config', 'categoryWatchState', 'tracking'); + } + + await batch.processSortedSet(`users:joindate`, async (uids) => { + const userSettings = await user.getMultipleUserSettings(uids); + const change = userSettings.filter(s => s && s.categoryWatchState === 'watching'); + await db.setObjectBulk( + change.map(s => [`user:${s.uid}:settings`, { categoryWatchState: 'tracking' }]) + ); + progress.incr(uids.length); + }, { + batch: 500, + progress, + }); + }, +}; diff --git a/src/user/categories.js b/src/user/categories.js index 80839a7ea7..1bae181ef5 100644 --- a/src/user/categories.js +++ b/src/user/categories.js @@ -60,10 +60,10 @@ module.exports = function (User) { }; User.getCategoriesByStates = async function (uid, states) { - if (!(parseInt(uid, 10) > 0)) { - return await categories.getAllCidsFromSet('categories:cid'); - } const cids = await categories.getAllCidsFromSet('categories:cid'); + if (!(parseInt(uid, 10) > 0)) { + return cids; + } const userState = await categories.getWatchState(cids, uid); return cids.filter((cid, index) => states.includes(userState[index])); }; diff --git a/src/views/admin/settings/user.tpl b/src/views/admin/settings/user.tpl index e2aad039a1..60d80f781e 100644 --- a/src/views/admin/settings/user.tpl +++ b/src/views/admin/settings/user.tpl @@ -297,7 +297,7 @@