Tag follow (#12041)

* feat: tag follow

* on tag delete remove it from following users

* feat: on tag rename update user followed tags

and move the

* add new filter on /notifications

* feat: openapi updates

* chore: up themes

* chore: up peace

* refactor: remove unused title
This commit is contained in:
Barış Soner Uşaklı
2023-09-27 10:57:00 -04:00
committed by GitHub
parent cf50b0fe49
commit 40d290c1a9
30 changed files with 443 additions and 19 deletions

View File

@@ -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",

View File

@@ -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" : "<strong>%1</strong>, <strong>%2</strong> and %3 others have posted replies to: <strong>%4</strong>",
"user_posted_topic": "<strong>%1</strong> has posted a new topic: <strong>%2</strong>",
"user_edited_post" : "<strong>%1</strong> has edited a post in <strong>%2</strong>",
"user_posted_topic_with_tag": "<strong>%1</strong> has posted a new topic with tag <strong>%2</strong>",
"user_posted_topic_with_tag_dual": "<strong>%1</strong> has posted a new topic with tags <strong>%2</strong> and <strong>%3</strong>",
"user_posted_topic_with_tag_triple": "<strong>%1</strong> has posted a new topic with tags <strong>%2</strong>, <strong>%3</strong> and <strong>%4</strong>",
"user_posted_topic_with_tag_multiple": "<strong>%1</strong> has posted a new topic with tags <strong>%2</strong>",
"user_started_following_you": "<strong>%1</strong> started following you.",
"user_started_following_you_dual": "<strong>%1</strong> and <strong>%2</strong> started following you.",
"user_started_following_you_triple": "<strong>%1</strong>, <strong>%2</strong> and <strong>%3</strong> 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",

View File

@@ -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",

View File

@@ -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."
}

View File

@@ -38,6 +38,7 @@
"reputation": "Reputation",
"bookmarks":"Bookmarks",
"watched_categories": "Watched categories",
"watched-tags": "Watched tags",
"change_all": "Change All",
"watched": "Watched",
"ignored": "Ignored",

View File

@@ -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

View File

@@ -391,6 +391,8 @@ UserObjectFull:
type: number
categoriesWatched:
type: number
tagsWatched:
type: number
downvoted:
type: number
followers:

View File

@@ -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":

View File

@@ -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:

View File

@@ -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

View File

@@ -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:

View File

@@ -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: {}

View File

@@ -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;
});

View File

@@ -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;

View File

@@ -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'),
};

13
src/api/tags.js Normal file
View File

@@ -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);
};

View File

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

View File

@@ -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);

View File

@@ -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' },

View File

@@ -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);
};

View File

@@ -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;

View File

@@ -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');

View File

@@ -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);
};

View File

@@ -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;

View File

@@ -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);

View File

@@ -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')());

17
src/routes/write/tags.js Normal file
View File

@@ -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;
};

View File

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

View File

@@ -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);
};
};

View File

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