'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 userReadTime = _.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) && (!userReadTime[t.value] || t.score > userReadTime[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'])).filter(t => !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 (!userReadTime[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']), db.sortedSetScores(`uid:${uid}:tids_read`, tids), ]); tids = topicScores.filter((t, i) => t.lastposttime && (!userScores[i] || userScores[i] < t.lastposttime)) .map(t => t.tid); if (!tids.length) { return false; } const now = Date.now(); const scores = tids.map(() => 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); }; };