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),