diff --git a/install/package.json b/install/package.json index cdc4a869fe..8d5ef5ba24 100644 --- a/install/package.json +++ b/install/package.json @@ -102,10 +102,10 @@ "nodebb-plugin-ntfy": "1.7.2", "nodebb-plugin-spam-be-gone": "2.2.0", "nodebb-rewards-essentials": "0.2.3", - "nodebb-theme-harmony": "1.1.63", + "nodebb-theme-harmony": "1.1.64", "nodebb-theme-lavender": "7.1.3", - "nodebb-theme-peace": "2.1.19", - "nodebb-theme-persona": "13.2.32", + "nodebb-theme-peace": "2.1.20", + "nodebb-theme-persona": "13.2.33", "nodebb-widget-essentials": "7.0.13", "nodemailer": "6.9.5", "nprogress": "0.2.0", diff --git a/public/language/en-GB/notifications.json b/public/language/en-GB/notifications.json index 040117fcd0..a6de1bf3fe 100644 --- a/public/language/en-GB/notifications.json +++ b/public/language/en-GB/notifications.json @@ -14,6 +14,7 @@ "all": "All", "topics": "Topics", + "tags": "Tags", "replies": "Replies", "chat": "Chats", "group-chat": "Group Chats", @@ -50,6 +51,12 @@ "user_posted_to_multiple" : "%1, %2 and %3 others have posted replies to: %4", "user_posted_topic": "%1 has posted a new topic: %2", "user_edited_post" : "%1 has edited a post in %2", + + "user_posted_topic_with_tag": "%1 has posted a new topic with tag %2", + "user_posted_topic_with_tag_dual": "%1 has posted a new topic with tags %2 and %3", + "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_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.", @@ -77,6 +84,7 @@ "notification_and_email": "Notification & Email", "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-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/language/en-GB/pages.json b/public/language/en-GB/pages.json index aebfd7f1fb..5cc791d7ad 100644 --- a/public/language/en-GB/pages.json +++ b/public/language/en-GB/pages.json @@ -54,6 +54,7 @@ "account/topics": "Topics created by %1", "account/groups": "%1's Groups", "account/watched_categories": "%1's Watched Categories", + "account/watched-tags": "%1's Watched Tags", "account/bookmarks": "%1's Bookmarked Posts", "account/settings": "User Settings", "account/settings-of": "Changing settings of %1", diff --git a/public/language/en-GB/tags.json b/public/language/en-GB/tags.json index 7159d4f542..f720a32140 100644 --- a/public/language/en-GB/tags.json +++ b/public/language/en-GB/tags.json @@ -7,5 +7,11 @@ "enter_tags_here_short": "Enter tags...", "no_tags": "There are no tags yet.", "select_tags": "Select Tags", - "tag-whitelist": "Tag Whitelist" + "tag-whitelist": "Tag Whitelist", + "watching": "Watching", + "not-watching": "Not Watching", + "watching.description": "Notify me of new topics.", + "not-watching.description": "Do not notify me of new topics.", + "following-tag.message": "You will now be receiving notifications when somebody posts a topic with this tag.", + "not-following-tag.message": "You will not receive notifications when somebody posts a topic with this tag." } \ No newline at end of file diff --git a/public/language/en-GB/user.json b/public/language/en-GB/user.json index e56cdfd4c1..a82c32cb9f 100644 --- a/public/language/en-GB/user.json +++ b/public/language/en-GB/user.json @@ -38,6 +38,7 @@ "reputation": "Reputation", "bookmarks":"Bookmarks", "watched_categories": "Watched categories", + "watched-tags": "Watched tags", "change_all": "Change All", "watched": "Watched", "ignored": "Ignored", diff --git a/public/openapi/components/schemas/SettingsObj.yaml b/public/openapi/components/schemas/SettingsObj.yaml index 3adba108b0..f12d0888a1 100644 --- a/public/openapi/components/schemas/SettingsObj.yaml +++ b/public/openapi/components/schemas/SettingsObj.yaml @@ -80,6 +80,9 @@ Settings: notificationType_new-topic: type: string description: Notification type for new topics + notificationType_new-topic-with-tag: + type: string + description: Notification type for new topics with followed tag notificationType_follow: type: string description: Notification type for another user following you diff --git a/public/openapi/components/schemas/UserObject.yaml b/public/openapi/components/schemas/UserObject.yaml index c2b3177637..cd7c33ddd1 100644 --- a/public/openapi/components/schemas/UserObject.yaml +++ b/public/openapi/components/schemas/UserObject.yaml @@ -391,6 +391,8 @@ UserObjectFull: type: number categoriesWatched: type: number + tagsWatched: + type: number downvoted: type: number followers: diff --git a/public/openapi/read.yaml b/public/openapi/read.yaml index 34326163bd..e8567b5a1f 100644 --- a/public/openapi/read.yaml +++ b/public/openapi/read.yaml @@ -262,6 +262,8 @@ paths: $ref: 'read/user/userslug/followers.yaml' "/api/user/{userslug}/categories": $ref: 'read/user/userslug/categories.yaml' + "/api/user/{userslug}/tags": + $ref: 'read/user/userslug/tags.yaml' "/api/user/{userslug}/posts": $ref: 'read/user/userslug/posts.yaml' "/api/user/{userslug}/topics": diff --git a/public/openapi/read/tags/tag.yaml b/public/openapi/read/tags/tag.yaml index cae02d0082..c9a49c5160 100644 --- a/public/openapi/read/tags/tag.yaml +++ b/public/openapi/read/tags/tag.yaml @@ -229,6 +229,9 @@ get: type: string canPost: type: boolean + isFollowing: + type: boolean + description: true is user is following this tag showSelect: type: boolean showTopicTools: diff --git a/public/openapi/read/user/userslug/tags.yaml b/public/openapi/read/user/userslug/tags.yaml new file mode 100644 index 0000000000..c13f8c337b --- /dev/null +++ b/public/openapi/read/user/userslug/tags.yaml @@ -0,0 +1,30 @@ +get: + tags: + - users + summary: Get user's watched tags + description: This route retrieves the list of tags the user is watching + parameters: + - name: userslug + in: path + required: true + schema: + type: string + example: admin + responses: + "200": + description: "" + content: + application/json: + schema: + allOf: + - $ref: ../../../components/schemas/UserObject.yaml#/UserObjectFull + - type: object + properties: + tags: + type: array + items: + type: string + title: + type: string + - $ref: ../../../components/schemas/Breadcrumbs.yaml#/Breadcrumbs + - $ref: ../../../components/schemas/CommonProps.yaml#/CommonProps \ No newline at end of file diff --git a/public/openapi/write.yaml b/public/openapi/write.yaml index a9a546e779..6207546cee 100644 --- a/public/openapi/write.yaml +++ b/public/openapi/write.yaml @@ -146,6 +146,8 @@ paths: $ref: 'write/topics/tid/read.yaml' /topics/{tid}/bump: $ref: 'write/topics/tid/bump.yaml' + /tags/{tag}/follow: + $ref: 'write/tags/tag/follow.yaml' /posts/{pid}: $ref: 'write/posts/pid.yaml' /posts/{pid}/index: diff --git a/public/openapi/write/tags/tag/follow.yaml b/public/openapi/write/tags/tag/follow.yaml new file mode 100644 index 0000000000..9eb211ec01 --- /dev/null +++ b/public/openapi/write/tags/tag/follow.yaml @@ -0,0 +1,52 @@ +put: + tags: + - tags + summary: follow a tag + description: This operation follows (or watches) a tag. + parameters: + - in: path + name: tag + schema: + type: string + required: true + description: a valid tag name + example: plugins + responses: + '200': + description: Tag successfully followed + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: {} +delete: + tags: + - tags + summary: unfollow a tag + description: This operation unfollows (or unwatches) a tag. + parameters: + - in: path + name: tag + schema: + type: string + required: true + description: a valid tag name + example: plugins + responses: + '200': + description: Tag successfully unwatched + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../../components/schemas/Status.yaml#/Status + response: + type: object + properties: {} \ No newline at end of file diff --git a/public/src/client/account/tags.js b/public/src/client/account/tags.js new file mode 100644 index 0000000000..c834732116 --- /dev/null +++ b/public/src/client/account/tags.js @@ -0,0 +1,56 @@ +'use strict'; + + +define('forum/account/tags', [ + 'forum/account/header', 'alerts', 'api', 'hooks', 'autocomplete', +], function (header, alerts, api, hooks, autocomplete) { + const Tags = {}; + + Tags.init = function () { + header.init(); + + const tagEl = $('[component="tags/watch"]'); + tagEl.tagsinput({ + tagClass: 'badge bg-info', + confirmKeys: [13, 44], + trimValue: true, + }); + const input = tagEl.siblings('.bootstrap-tagsinput').find('input'); + autocomplete.tag(input); + + ajaxify.data.tags.forEach(function (tag) { + tagEl.tagsinput('add', tag); + }); + + tagEl.on('itemAdded', function (event) { + if (input.length) { + input.autocomplete('close'); + } + api.put(`/tags/${event.item}/follow`, {}).then(() => { + alerts.alert({ + alert_id: 'follow_tag', + message: '[[tags:following-tag.message]]', + type: 'success', + timeout: 5000, + }); + + hooks.fire('action:tags.changeWatching', { tag: ajaxify.data.tag, type: 'follow' }); + }).catch(err => alerts.error(err)); + }); + + tagEl.on('itemRemoved', function (event) { + api.del(`/tags/${event.item}/follow`, {}).then(() => { + alerts.alert({ + alert_id: 'follow_tag', + message: '[[tags:not-following-tag.message]]', + type: 'info', + timeout: 5000, + }); + + hooks.fire('action:tags.changeWatching', { tag: ajaxify.data.tag, type: 'unfollow' }); + }).catch(err => alerts.error(err)); + }); + }; + + return Tags; +}); diff --git a/public/src/client/tag.js b/public/src/client/tag.js index 4d4a048a2c..199aaa362b 100644 --- a/public/src/client/tag.js +++ b/public/src/client/tag.js @@ -1,12 +1,66 @@ 'use strict'; -define('forum/tag', ['topicList', 'forum/infinitescroll'], function (topicList) { +define('forum/tag', [ + 'topicList', 'api', 'alerts', 'hooks', 'translator', 'bootstrap', 'components', +], function (topicList, api, alerts, hooks, translator, bootstrap, components) { const Tag = {}; Tag.init = function () { app.enterRoom('tags'); topicList.init('tag'); + + $('[component="tag/following"]').on('click', function () { + changeWatching('follow', 'put'); + }); + + $('[component="tag/not-following"]').on('click', function () { + changeWatching('unfollow', 'del'); + }); + + function changeWatching(type, method) { + api[method](`/tags/${ajaxify.data.tag}/follow`, {}).then(() => { + let message = ''; + if (type === 'follow') { + message = '[[tags:following-tag.message]]'; + } else if (type === 'unfollow') { + message = '[[tags:not-following-tag.message]]'; + } + + setFollowState(type); + + alerts.alert({ + alert_id: 'follow_tag', + message: message, + type: type === 'follow' ? 'success' : 'info', + timeout: 5000, + }); + + hooks.fire('action:tags.changeWatching', { tag: ajaxify.data.tag, type: type }); + }).catch(err => alerts.error(err)); + } + + function setFollowState(state) { + const titles = { + follow: '[[tags:watching]]', + unfollow: '[[tags:not-watching]]', + }; + + translator.translate(titles[state], function (translatedTitle) { + const tooltip = bootstrap.Tooltip.getInstance('[component="tag/watch"]'); + if (tooltip) { + tooltip.setContent({ '.tooltip-inner': translatedTitle }); + } + }); + + let menu = components.get('tag/following/menu'); + menu.toggleClass('hidden', state !== 'follow'); + components.get('tag/following/check').toggleClass('fa-check', state === 'follow'); + + menu = components.get('tag/not-following/menu'); + menu.toggleClass('hidden', state !== 'unfollow'); + components.get('tag/not-following/check').toggleClass('fa-check', state === 'unfollow'); + } }; return Tag; diff --git a/src/api/index.js b/src/api/index.js index 7c6a4e7552..9e5446c325 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -5,11 +5,11 @@ module.exports = { users: require('./users'), groups: require('./groups'), topics: require('./topics'), + tags: require('./tags'), posts: require('./posts'), chats: require('./chats'), categories: require('./categories'), flags: require('./flags'), files: require('./files'), - utils: require('./utils'), }; diff --git a/src/api/tags.js b/src/api/tags.js new file mode 100644 index 0000000000..8776e7a2b3 --- /dev/null +++ b/src/api/tags.js @@ -0,0 +1,13 @@ +'use strict'; + +const topics = require('../topics'); + +const tagsAPI = module.exports; + +tagsAPI.follow = async function (caller, data) { + await topics.followTag(data.tag, caller.uid); +}; + +tagsAPI.unfollow = async function (caller, data) { + await topics.unfollowTag(data.tag, caller.uid); +}; diff --git a/src/controllers/accounts.js b/src/controllers/accounts.js index eb2911dce3..603fff587d 100644 --- a/src/controllers/accounts.js +++ b/src/controllers/accounts.js @@ -5,6 +5,7 @@ const accountsController = { edit: require('./accounts/edit'), info: require('./accounts/info'), categories: require('./accounts/categories'), + tags: require('./accounts/tags'), settings: require('./accounts/settings'), groups: require('./accounts/groups'), follow: require('./accounts/follow'), diff --git a/src/controllers/accounts/helpers.js b/src/controllers/accounts/helpers.js index 592d3011db..98cbdac66d 100644 --- a/src/controllers/accounts/helpers.js +++ b/src/controllers/accounts/helpers.js @@ -178,6 +178,7 @@ async function getCounts(userData, callerUID) { promises.bookmarks = db.sortedSetCard(`uid:${uid}:bookmarks`); promises.uploaded = db.sortedSetCard(`uid:${uid}:uploads`); promises.categoriesWatched = user.getWatchedCategories(uid); + promises.tagsWatched = db.sortedSetCard(`uid:${uid}:followed_tags`); promises.blocks = user.getUserField(userData.uid, 'blocksCount'); } const counts = await utils.promiseParallel(promises); diff --git a/src/controllers/accounts/notifications.js b/src/controllers/accounts/notifications.js index ff9a21a550..ac983dd0e4 100644 --- a/src/controllers/accounts/notifications.js +++ b/src/controllers/accounts/notifications.js @@ -12,6 +12,7 @@ notificationsController.get = async function (req, res, next) { { name: '[[notifications:all]]', filter: '' }, { name: '[[global:topics]]', filter: 'new-topic' }, { name: '[[notifications:replies]]', filter: 'new-reply' }, + { name: '[[notifications:tags]]', filter: 'new-topic-with-tag' }, { 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/controllers/accounts/tags.js b/src/controllers/accounts/tags.js new file mode 100644 index 0000000000..736b67aae0 --- /dev/null +++ b/src/controllers/accounts/tags.js @@ -0,0 +1,25 @@ +'use strict'; + +const db = require('../../database'); +const user = require('../../user'); +const helpers = require('../helpers'); + +const tagsController = module.exports; + +tagsController.get = async function (req, res) { + if (req.uid !== res.locals.uid) { + return helpers.notAllowed(req, res); + } + const { username, userslug } = await user.getUserFields(res.locals.uid, ['username', 'userslug']); + const tagData = await db.getSortedSetRange(`uid:${res.locals.uid}:followed_tags`, 0, -1); + + const payload = {}; + payload.tags = tagData; + payload.title = `[[pages:account/watched-tags, ${username}]]`; + payload.breadcrumbs = helpers.buildBreadcrumbs([ + { text: username, url: `/user/${userslug}` }, + { text: '[[pages:tags]]' }, + ]); + + res.render('account/tags', payload); +}; diff --git a/src/controllers/tags.js b/src/controllers/tags.js index 1b1d1a98f2..9c25051b8a 100644 --- a/src/controllers/tags.js +++ b/src/controllers/tags.js @@ -25,12 +25,13 @@ tagsController.getTag = async function (req, res) { breadcrumbs: helpers.buildBreadcrumbs([{ text: '[[tags:tags]]', url: '/tags' }, { text: tag }]), title: `[[pages:tag, ${tag}]]`, }; - const [settings, cids, categoryData, canPost, isPrivileged] = await Promise.all([ + const [settings, cids, categoryData, canPost, isPrivileged, isFollowing] = await Promise.all([ user.getSettings(req.uid), cid || categories.getCidsByPrivilege('categories:cid', req.uid, 'topics:read'), helpers.getSelectedCategory(cid), privileges.categories.canPostTopic(req.uid), user.isPrivileged(req.uid), + topics.isFollowingTag(req.params.tag, req.uid), ]); const start = Math.max(0, (page - 1) * settings.topicsPerPage); const stop = start + settings.topicsPerPage - 1; @@ -44,6 +45,7 @@ tagsController.getTag = async function (req, res) { templateData.canPost = canPost; templateData.showSelect = isPrivileged; templateData.showTopicTools = isPrivileged; + templateData.isFollowing = isFollowing; templateData.allCategoriesUrl = `tags/${tag}${helpers.buildQueryString(req.query, 'cid', '')}`; templateData.selectedCategory = categoryData.selectedCategory; templateData.selectedCids = categoryData.selectedCids; diff --git a/src/controllers/write/index.js b/src/controllers/write/index.js index ad797c212c..46a8dd8110 100644 --- a/src/controllers/write/index.js +++ b/src/controllers/write/index.js @@ -6,6 +6,7 @@ Write.users = require('./users'); Write.groups = require('./groups'); Write.categories = require('./categories'); Write.topics = require('./topics'); +Write.tags = require('./tags'); Write.posts = require('./posts'); Write.chats = require('./chats'); Write.flags = require('./flags'); diff --git a/src/controllers/write/tags.js b/src/controllers/write/tags.js new file mode 100644 index 0000000000..75c73cf2bb --- /dev/null +++ b/src/controllers/write/tags.js @@ -0,0 +1,17 @@ +'use strict'; + +const api = require('../../api'); + +const helpers = require('../helpers'); + +const Tags = module.exports; + +Tags.follow = async (req, res) => { + await api.tags.follow(req, req.params); + helpers.formatApiResponse(200, res); +}; + +Tags.unfollow = async (req, res) => { + await api.tags.unfollow(req, req.params); + helpers.formatApiResponse(200, res); +}; diff --git a/src/notifications.js b/src/notifications.js index 86e2f37ba9..725bd85fbc 100644 --- a/src/notifications.js +++ b/src/notifications.js @@ -21,6 +21,7 @@ const Notifications = module.exports; Notifications.baseTypes = [ 'notificationType_upvote', 'notificationType_new-topic', + 'notificationType_new-topic-with-tag', 'notificationType_new-reply', 'notificationType_post-edit', 'notificationType_follow', @@ -395,10 +396,10 @@ Notifications.merge = async function (notifications) { }, []); differentiators.forEach((differentiator) => { - function typeFromUsernames(usernames) { - if (usernames.length === 2) { + function typeFromLength(items) { + if (items.length === 2) { return 'dual'; - } else if (usernames.length === 3) { + } else if (items.length === 3) { return 'triple'; } return 'multiple'; @@ -419,9 +420,9 @@ Notifications.merge = async function (notifications) { case 'notifications:user_posted_in_public_room': { const usernames = _.uniq(set.map(notifObj => notifObj && notifObj.user && notifObj.user.displayname)); if (usernames.length === 2 || usernames.length === 3) { - notifObj.bodyShort = `[[${mergeId}_${typeFromUsernames(usernames)}, ${usernames.join(', ')}, ${notifObj.roomIcon}, ${notifObj.roomName}]]`; + notifObj.bodyShort = `[[${mergeId}_${typeFromLength(usernames)}, ${usernames.join(', ')}, ${notifObj.roomIcon}, ${notifObj.roomName}]]`; } else if (usernames.length > 3) { - notifObj.bodyShort = `[[${mergeId}_${typeFromUsernames(usernames)}, ${usernames.slice(0, 2).join(', ')}, ${usernames.length - 2}, ${notifObj.roomIcon}, ${notifObj.roomName}]]`; + notifObj.bodyShort = `[[${mergeId}_${typeFromLength(usernames)}, ${usernames.slice(0, 2).join(', ')}, ${usernames.length - 2}, ${notifObj.roomIcon}, ${notifObj.roomName}]]`; } notifObj.path = set[set.length - 1].path; @@ -440,9 +441,9 @@ Notifications.merge = async function (notifications) { titleEscaped = titleEscaped ? (`, ${titleEscaped}`) : ''; if (numUsers === 2 || numUsers === 3) { - notifications[modifyIndex].bodyShort = `[[${mergeId}_${typeFromUsernames(usernames)}, ${usernames.join(', ')}${titleEscaped}]]`; + notifications[modifyIndex].bodyShort = `[[${mergeId}_${typeFromLength(usernames)}, ${usernames.join(', ')}${titleEscaped}]]`; } else if (numUsers > 2) { - notifications[modifyIndex].bodyShort = `[[${mergeId}_${typeFromUsernames(usernames)}, ${usernames.slice(0, 2).join(', ')}, ${numUsers - 2}${titleEscaped}]]`; + notifications[modifyIndex].bodyShort = `[[${mergeId}_${typeFromLength(usernames)}, ${usernames.slice(0, 2).join(', ')}, ${numUsers - 2}${titleEscaped}]]`; } notifications[modifyIndex].path = set[set.length - 1].path; diff --git a/src/routes/user.js b/src/routes/user.js index 9825fa58ea..3c953fa34e 100644 --- a/src/routes/user.js +++ b/src/routes/user.js @@ -27,6 +27,7 @@ module.exports = function (app, name, middleware, controllers) { setupPageRoute(app, `/${name}/:userslug/groups`, middlewares, controllers.accounts.groups.get); setupPageRoute(app, `/${name}/:userslug/categories`, accountMiddlewares, controllers.accounts.categories.get); + setupPageRoute(app, `/${name}/:userslug/tags`, accountMiddlewares, controllers.accounts.tags.get); setupPageRoute(app, `/${name}/:userslug/bookmarks`, accountMiddlewares, controllers.accounts.posts.getBookmarks); setupPageRoute(app, `/${name}/:userslug/watched`, accountMiddlewares, controllers.accounts.posts.getWatchedTopics); setupPageRoute(app, `/${name}/:userslug/ignored`, accountMiddlewares, controllers.accounts.posts.getIgnoredTopics); diff --git a/src/routes/write/index.js b/src/routes/write/index.js index 2209aefbda..8e29c3ddd1 100644 --- a/src/routes/write/index.js +++ b/src/routes/write/index.js @@ -37,6 +37,7 @@ Write.reload = async (params) => { router.use('/api/v3/groups', require('./groups')()); router.use('/api/v3/categories', require('./categories')()); router.use('/api/v3/topics', require('./topics')()); + router.use('/api/v3/tags', require('./tags')()); router.use('/api/v3/posts', require('./posts')()); router.use('/api/v3/chats', require('./chats')()); router.use('/api/v3/flags', require('./flags')()); diff --git a/src/routes/write/tags.js b/src/routes/write/tags.js new file mode 100644 index 0000000000..8e77ed0f2d --- /dev/null +++ b/src/routes/write/tags.js @@ -0,0 +1,17 @@ +'use strict'; + +const router = require('express').Router(); +const middleware = require('../../middleware'); +const controllers = require('../../controllers'); +const routeHelpers = require('../helpers'); + +const { setupApiRoute } = routeHelpers; + +module.exports = function () { + const middlewares = [middleware.ensureLoggedIn]; + + setupApiRoute(router, 'put', '/:tag/follow', [...middlewares], controllers.write.tags.follow); + setupApiRoute(router, 'delete', '/:tag/follow', [...middlewares], controllers.write.tags.unfollow); + + return router; +}; diff --git a/src/topics/create.js b/src/topics/create.js index 44755ec7ee..7ce913a7a2 100644 --- a/src/topics/create.js +++ b/src/topics/create.js @@ -152,6 +152,7 @@ module.exports = function (Topics) { if (parseInt(uid, 10) && !topicData.scheduled) { user.notifications.sendTopicNotificationToFollowers(uid, topicData, postData); + Topics.notifyTagFollowers(postData, uid); } return { @@ -229,7 +230,7 @@ module.exports = function (Topics) { topicInfo, ] = await Promise.all([ posts.getUserInfoForPosts([postData.uid], uid), - Topics.getTopicFields(tid, ['tid', 'uid', 'title', 'slug', 'cid', 'postcount', 'mainPid', 'scheduled']), + Topics.getTopicFields(tid, ['tid', 'uid', 'title', 'slug', 'cid', 'postcount', 'mainPid', 'scheduled', 'tags']), Topics.addParentPosts([postData]), Topics.syncBacklinks(postData), posts.parsePost(postData), diff --git a/src/topics/tags.js b/src/topics/tags.js index 1f4b140ded..fc5828aad4 100644 --- a/src/topics/tags.js +++ b/src/topics/tags.js @@ -10,6 +10,9 @@ const meta = require('../meta'); const user = require('../user'); const categories = require('../categories'); const plugins = require('../plugins'); +const privileges = require('../privileges'); +const notifications = require('../notifications'); +const translator = require('../translator'); const utils = require('../utils'); const batch = require('../batch'); const cache = require('../cache'); @@ -165,6 +168,18 @@ module.exports = function (Topics) { topicData.map(t => [`topic:${t.tid}`, { tags: t.tags.join(',') }]), ); }, {}); + const followers = await db.getSortedSetRangeWithScores(`tag:${tag}:followers`, 0, -1); + if (followers.length) { + const userKeys = followers.map(item => `uid:${item.value}:followed_tags`); + const scores = await db.sortedSetsScore(userKeys, tag); + await db.sortedSetsRemove(userKeys, tag); + await db.sortedSetsAdd(userKeys, scores, newTagName); + await db.sortedSetAdd( + `tag:${newTagName}:followers`, + followers.map(item => item.score), + followers.map(item => item.value), + ); + } await Topics.deleteTag(tag); await updateTagCount(newTagName); await Topics.updateCategoryTagsCount(Object.keys(allCids), [newTagName]); @@ -207,7 +222,10 @@ module.exports = function (Topics) { if (!Array.isArray(tags) || !tags.length) { return; } - await removeTagsFromTopics(tags); + await Promise.all([ + removeTagsFromTopics(tags), + removeTagsFromUsers(tags), + ]); const keys = tags.map(tag => `tag:${tag}:topics`); await db.deleteAll(keys); await db.sortedSetRemove('tags:topic:count', tags); @@ -219,6 +237,7 @@ module.exports = function (Topics) { const deleteKeys = []; tags.forEach((tag) => { deleteKeys.push(`tag:${tag}`); + deleteKeys.push(`tag:${tag}:followers`); cids.forEach((cid) => { deleteKeys.push(`cid:${cid}:tag:${tag}:topics`); }); @@ -245,6 +264,13 @@ module.exports = function (Topics) { }); } + async function removeTagsFromUsers(tags) { + await async.eachLimit(tags, 50, async (tag) => { + const uids = await db.getSortedSetRange(`tag:${tag}:followers`, 0, -1); + await db.sortedSetsRemove(uids.map(uid => `uid:${uid}:followed_tags`), tag); + }); + } + Topics.deleteTag = async function (tag) { await Topics.deleteTags([tag]); }; @@ -528,4 +554,80 @@ module.exports = function (Topics) { const topics = await Topics.getTopics(tids, uid); return topics.filter(t => t && !t.deleted && parseInt(t.uid, 10) !== parseInt(uid, 10)); }; + + Topics.isFollowingTag = async function (tag, uid) { + return await db.isSortedSetMember(`tag:${tag}:followers`, uid); + }; + + Topics.getTagFollowers = async function (tag, start = 0, stop = -1) { + return await db.getSortedSetRange(`tag:${tag}:followers`, start, stop); + }; + + Topics.followTag = async (tag, uid) => { + if (!(parseInt(uid, 10) > 0)) { + throw new Error('[[error:not-logged-in]]'); + } + const now = Date.now(); + await db.sortedSetAddBulk([ + [`tag:${tag}:followers`, now, uid], + [`uid:${uid}:followed_tags`, now, tag], + ]); + plugins.hooks.fire('action:tags.follow', { tag, uid }); + }; + + Topics.unfollowTag = async (tag, uid) => { + if (!(parseInt(uid, 10) > 0)) { + throw new Error('[[error:not-logged-in]]'); + } + await db.sortedSetRemoveBulk([ + [`tag:${tag}:followers`, uid], + [`uid:${uid}:followed_tags`, tag], + ]); + plugins.hooks.fire('action:tags.unfollow', { tag, uid }); + }; + + Topics.notifyTagFollowers = async function (postData, exceptUid) { + let { tags } = postData.topic; + if (!tags.length) { + return; + } + tags = tags.map(tag => tag.value); + + const [followersOfPoster, allFollowers] = await Promise.all([ + db.getSortedSetRange(`followers:${exceptUid}`, 0, -1), + db.getSortedSetRange(tags.map(tag => `tag:${tag}:followers`), 0, -1), + ]); + const followerSet = new Set(followersOfPoster); + // filter out followers of the poster since they get a notification already + let followers = _.uniq(allFollowers).filter(uid => !followerSet.has(uid) && uid !== String(exceptUid)); + followers = await privileges.topics.filterUids('topics:read', postData.topic.tid, followers); + if (!followers.length) { + return; + } + + const { displayname } = postData.user; + + const notifBase = 'notifications:user_posted_topic_with_tag'; + let bodyShort = translator.compile(notifBase, displayname, tags[0]); + if (tags.length === 2) { + bodyShort = translator.compile(`${notifBase}_dual`, displayname, tags[0], tags[1]); + } else if (tags.length === 3) { + bodyShort = translator.compile(`${notifBase}_triple`, displayname, tags[0], tags[1], tags[2]); + } else if (tags.length > 3) { + bodyShort = translator.compile(`${notifBase}_multiple`, displayname, tags.join(', ')); + } + + const notification = await notifications.create({ + type: 'new-topic-with-tag', + 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/user/delete.js b/src/user/delete.js index 1362df1621..681eabeec1 100644 --- a/src/user/delete.js +++ b/src/user/delete.js @@ -108,8 +108,6 @@ module.exports = function (User) { `uid:${uid}:bookmarks`, `uid:${uid}:tids_read`, `uid:${uid}:tids_unread`, - `uid:${uid}:followed_tids`, - `uid:${uid}:ignored_tids`, `uid:${uid}:blocked_uids`, `user:${uid}:settings`, `user:${uid}:usernames`, @@ -147,17 +145,39 @@ module.exports = function (User) { db.setRemove('invitation:uids', uid), deleteUserIps(uid), deleteUserFromFollowers(uid), + deleteUserFromFollowedTopics(uid), + deleteUserFromIgnoredTopics(uid), + deleteUserFromFollowedTags(uid), deleteImages(uid), groups.leaveAllGroups(uid), flags.resolveFlag('user', uid, uid), User.reset.cleanByUid(uid), User.email.expireValidation(uid), ]); - await db.deleteAll([`followers:${uid}`, `following:${uid}`, `user:${uid}`]); + await db.deleteAll([ + `followers:${uid}`, `following:${uid}`, `user:${uid}`, + `uid:${uid}:followed_tags`, `uid:${uid}:followed_tids`, + `uid:${uid}:ignored_tids`, + ]); delete deletesInProgress[uid]; return userData; }; + async function deleteUserFromFollowedTopics(uid) { + const tids = await db.getSortedSetRange(`uid:${uid}:followed_tids`, 0, -1); + await db.setsRemove(tids.map(tid => `tid:${tid}:followers`), uid); + } + + async function deleteUserFromIgnoredTopics(uid) { + const tids = await db.getSortedSetRange(`uid:${uid}:ignored_tids`, 0, -1); + await db.setsRemove(tids.map(tid => `tid:${tid}:ignorers`), uid); + } + + async function deleteUserFromFollowedTags(uid) { + const tags = await db.getSortedSetRange(`uid:${uid}:followed_tags`, 0, -1); + await db.sortedSetsRemove(tags.map(tag => `tag:${tag}:followers`), uid); + } + async function deleteVotes(uid) { const [upvotedPids, downvotedPids] = await Promise.all([ db.getSortedSetRange(`uid:${uid}:upvote`, 0, -1),