You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
387 lines
12 KiB
JavaScript
387 lines
12 KiB
JavaScript
|
|
'use strict';
|
|
|
|
const async = require('async');
|
|
const _ = require('lodash');
|
|
|
|
const db = require('../database');
|
|
const user = require('../user');
|
|
const posts = require('../posts');
|
|
const notifications = require('../notifications');
|
|
const categories = require('../categories');
|
|
const privileges = require('../privileges');
|
|
const meta = require('../meta');
|
|
const utils = require('../utils');
|
|
const plugins = require('../plugins');
|
|
|
|
module.exports = function (Topics) {
|
|
Topics.getTotalUnread = async function (uid, filter) {
|
|
filter = filter || '';
|
|
const counts = await Topics.getUnreadTids({ cid: 0, uid: uid, count: true });
|
|
return counts && counts[filter];
|
|
};
|
|
|
|
Topics.getUnreadTopics = async function (params) {
|
|
const unreadTopics = {
|
|
showSelect: true,
|
|
nextStart: 0,
|
|
topics: [],
|
|
};
|
|
let tids = await Topics.getUnreadTids(params);
|
|
unreadTopics.topicCount = tids.length;
|
|
|
|
if (!tids.length) {
|
|
return unreadTopics;
|
|
}
|
|
|
|
tids = tids.slice(params.start, params.stop !== -1 ? params.stop + 1 : undefined);
|
|
|
|
const topicData = await Topics.getTopicsByTids(tids, params.uid);
|
|
if (!topicData.length) {
|
|
return unreadTopics;
|
|
}
|
|
Topics.calculateTopicIndices(topicData, params.start);
|
|
unreadTopics.topics = topicData;
|
|
unreadTopics.nextStart = params.stop + 1;
|
|
return unreadTopics;
|
|
};
|
|
|
|
Topics.unreadCutoff = async function (uid) {
|
|
const cutoff = Date.now() - (meta.config.unreadCutoff * 86400000);
|
|
const data = await plugins.hooks.fire('filter:topics.unreadCutoff', { uid: uid, cutoff: cutoff });
|
|
return parseInt(data.cutoff, 10);
|
|
};
|
|
|
|
Topics.getUnreadTids = async function (params) {
|
|
const results = await Topics.getUnreadData(params);
|
|
return params.count ? results.counts : results.tids;
|
|
};
|
|
|
|
Topics.getUnreadData = async function (params) {
|
|
const uid = parseInt(params.uid, 10);
|
|
|
|
params.filter = params.filter || '';
|
|
|
|
if (params.cid && !Array.isArray(params.cid)) {
|
|
params.cid = [params.cid];
|
|
}
|
|
|
|
const data = await getTids(params);
|
|
if (uid <= 0 || !data.tids || !data.tids.length) {
|
|
return data;
|
|
}
|
|
|
|
const result = await plugins.hooks.fire('filter:topics.getUnreadTids', {
|
|
uid: uid,
|
|
tids: data.tids,
|
|
counts: data.counts,
|
|
tidsByFilter: data.tidsByFilter,
|
|
cid: params.cid,
|
|
filter: params.filter,
|
|
query: params.query || {},
|
|
});
|
|
return result;
|
|
};
|
|
|
|
async function getTids(params) {
|
|
const counts = { '': 0, new: 0, watched: 0, unreplied: 0 };
|
|
const tidsByFilter = { '': [], new: [], watched: [], unreplied: [] };
|
|
|
|
if (params.uid <= 0) {
|
|
return { counts: counts, tids: [], tidsByFilter: tidsByFilter };
|
|
}
|
|
|
|
params.cutoff = await Topics.unreadCutoff(params.uid);
|
|
|
|
const [followedTids, ignoredTids, categoryTids, userScores, tids_unread] = await Promise.all([
|
|
getFollowedTids(params),
|
|
user.getIgnoredTids(params.uid, 0, -1),
|
|
getCategoryTids(params),
|
|
db.getSortedSetRevRangeByScoreWithScores(`uid:${params.uid}:tids_read`, 0, -1, '+inf', params.cutoff),
|
|
db.getSortedSetRevRangeWithScores(`uid:${params.uid}:tids_unread`, 0, -1),
|
|
]);
|
|
|
|
const userReadTimes = _.mapValues(_.keyBy(userScores, 'value'), 'score');
|
|
const isTopicsFollowed = {};
|
|
followedTids.forEach((t) => {
|
|
isTopicsFollowed[t.value] = true;
|
|
});
|
|
const unreadFollowed = await db.isSortedSetMembers(
|
|
`uid:${params.uid}:followed_tids`, tids_unread.map(t => t.value)
|
|
);
|
|
|
|
tids_unread.forEach((t, i) => {
|
|
isTopicsFollowed[t.value] = unreadFollowed[i];
|
|
});
|
|
|
|
const unreadTopics = _.unionWith(categoryTids, followedTids, (a, b) => a.value === b.value)
|
|
.filter(t => !ignoredTids.includes(t.value) && (!userReadTimes[t.value] || t.score > userReadTimes[t.value]))
|
|
.concat(tids_unread.filter(t => !ignoredTids.includes(t.value)))
|
|
.sort((a, b) => b.score - a.score);
|
|
|
|
let tids = _.uniq(unreadTopics.map(topic => topic.value)).slice(0, 200);
|
|
|
|
if (!tids.length) {
|
|
return { counts: counts, tids: tids, tidsByFilter: tidsByFilter };
|
|
}
|
|
|
|
const blockedUids = await user.blocks.list(params.uid);
|
|
|
|
tids = await filterTidsThatHaveBlockedPosts({
|
|
uid: params.uid,
|
|
tids: tids,
|
|
blockedUids: blockedUids,
|
|
recentTids: categoryTids,
|
|
});
|
|
|
|
tids = await privileges.topics.filterTids('topics:read', tids, params.uid);
|
|
const topicData = (await Topics.getTopicsFields(tids, ['tid', 'cid', 'uid', 'postcount', 'deleted', 'scheduled']))
|
|
.filter(t => t.scheduled || !t.deleted);
|
|
const topicCids = _.uniq(topicData.map(topic => topic.cid)).filter(Boolean);
|
|
|
|
const categoryWatchState = await categories.getWatchState(topicCids, params.uid);
|
|
const userCidState = _.zipObject(topicCids, categoryWatchState);
|
|
|
|
const filterCids = params.cid && params.cid.map(cid => parseInt(cid, 10));
|
|
|
|
topicData.forEach((topic) => {
|
|
if (topic && topic.cid && (!filterCids || filterCids.includes(topic.cid)) && !blockedUids.includes(topic.uid)) {
|
|
if (isTopicsFollowed[topic.tid] || userCidState[topic.cid] === categories.watchStates.watching) {
|
|
tidsByFilter[''].push(topic.tid);
|
|
}
|
|
|
|
if (isTopicsFollowed[topic.tid]) {
|
|
tidsByFilter.watched.push(topic.tid);
|
|
}
|
|
|
|
if (topic.postcount <= 1) {
|
|
tidsByFilter.unreplied.push(topic.tid);
|
|
}
|
|
|
|
if (!userReadTimes[topic.tid]) {
|
|
tidsByFilter.new.push(topic.tid);
|
|
}
|
|
}
|
|
});
|
|
|
|
counts[''] = tidsByFilter[''].length;
|
|
counts.watched = tidsByFilter.watched.length;
|
|
counts.unreplied = tidsByFilter.unreplied.length;
|
|
counts.new = tidsByFilter.new.length;
|
|
|
|
return {
|
|
counts: counts,
|
|
tids: tidsByFilter[params.filter],
|
|
tidsByFilter: tidsByFilter,
|
|
};
|
|
}
|
|
|
|
async function getCategoryTids(params) {
|
|
if (plugins.hooks.hasListeners('filter:topics.unread.getCategoryTids')) {
|
|
const result = await plugins.hooks.fire('filter:topics.unread.getCategoryTids', { params: params, tids: [] });
|
|
return result.tids;
|
|
}
|
|
if (params.filter === 'watched') {
|
|
return [];
|
|
}
|
|
const cids = params.cid || await user.getWatchedCategories(params.uid);
|
|
const keys = cids.map(cid => `cid:${cid}:tids:lastposttime`);
|
|
return await db.getSortedSetRevRangeByScoreWithScores(keys, 0, -1, '+inf', params.cutoff);
|
|
}
|
|
|
|
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));
|
|
if (filterCids) {
|
|
const topicData = await Topics.getTopicsFields(tids, ['tid', 'cid']);
|
|
tids = topicData.filter(t => filterCids.includes(t.cid)).map(t => t.tid);
|
|
}
|
|
const scores = await db.sortedSetScores('topics:recent', tids);
|
|
const data = tids.map((tid, index) => ({ value: String(tid), score: scores[index] }));
|
|
return data.filter(item => item.score > params.cutoff);
|
|
}
|
|
|
|
async function filterTidsThatHaveBlockedPosts(params) {
|
|
if (!params.blockedUids.length) {
|
|
return params.tids;
|
|
}
|
|
const topicScores = _.mapValues(_.keyBy(params.recentTids, 'value'), 'score');
|
|
|
|
const results = await db.sortedSetScores(`uid:${params.uid}:tids_read`, params.tids);
|
|
|
|
const userScores = _.zipObject(params.tids, results);
|
|
|
|
return await async.filter(params.tids, async tid => await doesTidHaveUnblockedUnreadPosts(tid, {
|
|
blockedUids: params.blockedUids,
|
|
topicTimestamp: topicScores[tid],
|
|
userLastReadTimestamp: userScores[tid],
|
|
}));
|
|
}
|
|
|
|
async function doesTidHaveUnblockedUnreadPosts(tid, params) {
|
|
const { userLastReadTimestamp } = params;
|
|
if (!userLastReadTimestamp) {
|
|
return true;
|
|
}
|
|
let start = 0;
|
|
const count = 3;
|
|
let done = false;
|
|
let hasUnblockedUnread = params.topicTimestamp > userLastReadTimestamp;
|
|
if (!params.blockedUids.length) {
|
|
return hasUnblockedUnread;
|
|
}
|
|
while (!done) {
|
|
/* eslint-disable no-await-in-loop */
|
|
const pidsSinceLastVisit = await db.getSortedSetRangeByScore(`tid:${tid}:posts`, start, count, userLastReadTimestamp, '+inf');
|
|
if (!pidsSinceLastVisit.length) {
|
|
return hasUnblockedUnread;
|
|
}
|
|
let postData = await posts.getPostsFields(pidsSinceLastVisit, ['pid', 'uid']);
|
|
postData = postData.filter(post => !params.blockedUids.includes(parseInt(post.uid, 10)));
|
|
|
|
done = postData.length > 0;
|
|
hasUnblockedUnread = postData.length > 0;
|
|
start += count;
|
|
}
|
|
return hasUnblockedUnread;
|
|
}
|
|
|
|
Topics.pushUnreadCount = async function (uid) {
|
|
if (!uid || parseInt(uid, 10) <= 0) {
|
|
return;
|
|
}
|
|
const results = await Topics.getUnreadTids({ uid: uid, count: true });
|
|
require('../socket.io').in(`uid_${uid}`).emit('event:unread.updateCount', {
|
|
unreadTopicCount: results[''],
|
|
unreadNewTopicCount: results.new,
|
|
unreadWatchedTopicCount: results.watched,
|
|
unreadUnrepliedTopicCount: results.unreplied,
|
|
});
|
|
};
|
|
|
|
Topics.markAsUnreadForAll = async function (tid) {
|
|
await Topics.markCategoryUnreadForAll(tid);
|
|
};
|
|
|
|
Topics.markAsRead = async function (tids, uid) {
|
|
if (!Array.isArray(tids) || !tids.length) {
|
|
return false;
|
|
}
|
|
|
|
tids = _.uniq(tids).filter(tid => tid && utils.isNumber(tid));
|
|
|
|
if (!tids.length) {
|
|
return false;
|
|
}
|
|
const [topicScores, userScores] = await Promise.all([
|
|
Topics.getTopicsFields(tids, ['tid', 'lastposttime', 'scheduled']),
|
|
db.sortedSetScores(`uid:${uid}:tids_read`, tids),
|
|
]);
|
|
|
|
const topics = topicScores.filter((t, i) => t.lastposttime && (!userScores[i] || userScores[i] < t.lastposttime));
|
|
tids = topics.map(t => t.tid);
|
|
|
|
if (!tids.length) {
|
|
return false;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const scores = topics.map(topic => (topic.scheduled ? topic.lastposttime : now));
|
|
const [topicData] = await Promise.all([
|
|
Topics.getTopicsFields(tids, ['cid']),
|
|
db.sortedSetAdd(`uid:${uid}:tids_read`, scores, tids),
|
|
db.sortedSetRemove(`uid:${uid}:tids_unread`, tids),
|
|
]);
|
|
|
|
const cids = _.uniq(topicData.map(t => t && t.cid).filter(Boolean));
|
|
await categories.markAsRead(cids, uid);
|
|
|
|
plugins.hooks.fire('action:topics.markAsRead', { uid: uid, tids: tids });
|
|
return true;
|
|
};
|
|
|
|
Topics.markAllRead = async function (uid) {
|
|
const cutoff = await Topics.unreadCutoff(uid);
|
|
const tids = await db.getSortedSetRevRangeByScore('topics:recent', 0, -1, '+inf', cutoff);
|
|
Topics.markTopicNotificationsRead(tids, uid);
|
|
await Topics.markAsRead(tids, uid);
|
|
await db.delete(`uid:${uid}:tids_unread`);
|
|
};
|
|
|
|
Topics.markTopicNotificationsRead = async function (tids, uid) {
|
|
if (!Array.isArray(tids) || !tids.length) {
|
|
return;
|
|
}
|
|
const nids = await user.notifications.getUnreadByField(uid, 'tid', tids);
|
|
await notifications.markReadMultiple(nids, uid);
|
|
user.notifications.pushCount(uid);
|
|
};
|
|
|
|
Topics.markCategoryUnreadForAll = async function (tid) {
|
|
const cid = await Topics.getTopicField(tid, 'cid');
|
|
await categories.markAsUnreadForAll(cid);
|
|
};
|
|
|
|
Topics.hasReadTopics = async function (tids, uid) {
|
|
if (!(parseInt(uid, 10) > 0)) {
|
|
return tids.map(() => false);
|
|
}
|
|
const [topicScores, userScores, tids_unread, blockedUids] = await Promise.all([
|
|
db.sortedSetScores('topics:recent', tids),
|
|
db.sortedSetScores(`uid:${uid}:tids_read`, tids),
|
|
db.sortedSetScores(`uid:${uid}:tids_unread`, tids),
|
|
user.blocks.list(uid),
|
|
]);
|
|
|
|
const cutoff = await Topics.unreadCutoff(uid);
|
|
const result = tids.map((tid, index) => {
|
|
const read = !tids_unread[index] &&
|
|
(topicScores[index] < cutoff ||
|
|
!!(userScores[index] && userScores[index] >= topicScores[index]));
|
|
return { tid: tid, read: read, index: index };
|
|
});
|
|
|
|
return await async.map(result, async (data) => {
|
|
if (data.read) {
|
|
return true;
|
|
}
|
|
const hasUnblockedUnread = await doesTidHaveUnblockedUnreadPosts(data.tid, {
|
|
topicTimestamp: topicScores[data.index],
|
|
userLastReadTimestamp: userScores[data.index],
|
|
blockedUids: blockedUids,
|
|
});
|
|
if (!hasUnblockedUnread) {
|
|
data.read = true;
|
|
}
|
|
return data.read;
|
|
});
|
|
};
|
|
|
|
Topics.hasReadTopic = async function (tid, uid) {
|
|
const hasRead = await Topics.hasReadTopics([tid], uid);
|
|
return Array.isArray(hasRead) && hasRead.length ? hasRead[0] : false;
|
|
};
|
|
|
|
Topics.markUnread = async function (tid, uid) {
|
|
const exists = await Topics.exists(tid);
|
|
if (!exists) {
|
|
throw new Error('[[error:no-topic]]');
|
|
}
|
|
await db.sortedSetRemove(`uid:${uid}:tids_read`, tid);
|
|
await db.sortedSetAdd(`uid:${uid}:tids_unread`, Date.now(), tid);
|
|
};
|
|
|
|
Topics.filterNewTids = async function (tids, uid) {
|
|
if (parseInt(uid, 10) <= 0) {
|
|
return [];
|
|
}
|
|
const scores = await db.sortedSetScores(`uid:${uid}:tids_read`, tids);
|
|
return tids.filter((tid, index) => tid && !scores[index]);
|
|
};
|
|
|
|
Topics.filterUnrepliedTids = async function (tids) {
|
|
const scores = await db.sortedSetScores('topics:posts', tids);
|
|
return tids.filter((tid, index) => tid && scores[index] !== null && scores[index] <= 1);
|
|
};
|
|
};
|