From b24999682f9d5a33a08a049749c1f0eb4f00facc Mon Sep 17 00:00:00 2001 From: Julian Lam Date: Fri, 17 Jul 2020 15:08:23 -0400 Subject: [PATCH] feat: handling multiple reporters per flag, #8510 --- public/language/en-GB/flags.json | 5 +- public/src/client/flags/list.js | 5 ++ src/flags.js | 97 ++++++++++++++++++------ src/upgrades/1.14.3/consolidate_flags.js | 14 +++- 4 files changed, 91 insertions(+), 30 deletions(-) diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json index cca477a296..1259cf8cbb 100644 --- a/public/language/en-GB/flags.json +++ b/public/language/en-GB/flags.json @@ -1,8 +1,7 @@ { "state": "State", - "reporter": "Reporter", - "reported-at": "Reported At", - "description": "Description", + "reports": "Reports", + "first-reported": "First Reported", "no-flags": "Hooray! No flags found.", "assignee": "Assignee", "update": "Update", diff --git a/public/src/client/flags/list.js b/public/src/client/flags/list.js index 42eac69b11..3dcd99b389 100644 --- a/public/src/client/flags/list.js +++ b/public/src/client/flags/list.js @@ -6,6 +6,11 @@ define('forum/flags/list', ['components', 'Chart'], function (components, Chart) Flags.init = function () { Flags.enableFilterForm(); + components.get('flags/list').on('click', '[data-flag-id]', function () { + var flagId = this.getAttribute('data-flag-id'); + ajaxify.go('flags/' + flagId); + }); + var graphWrapper = $('#flags-daily-wrapper'); var graphFooter = graphWrapper.siblings('.panel-footer'); $('#flags-daily-wrapper').one('shown.bs.collapse', function () { diff --git a/src/flags.js b/src/flags.js index 0dc0a05dcd..bb0b018f42 100644 --- a/src/flags.js +++ b/src/flags.js @@ -84,31 +84,25 @@ Flags.init = async function () { }; Flags.get = async function (flagId) { - const [base, history, notes] = await Promise.all([ + const [base, history, notes, reports] = await Promise.all([ db.getObject('flag:' + flagId), Flags.getHistory(flagId), Flags.getNotes(flagId), + Flags.getReports(flagId), ]); if (!base) { return; } - const [userObj, targetObj] = await Promise.all([ - user.getUserFields(base.uid, ['username', 'userslug', 'picture', 'reputation']), - Flags.getTarget(base.type, base.targetId, 0), - ]); - const flagObj = { state: 'open', assignee: null, ...base, - description: validator.escape(base.description), - datetimeISO: utils.toISOString(base.datetime), target_readable: base.type.charAt(0).toUpperCase() + base.type.slice(1) + ' ' + base.targetId, - target: targetObj, + target: await Flags.getTarget(base.type, base.targetId, 0), history: history, notes: notes, - reporter: userObj, + reports: reports, }; const data = await plugins.fireHook('filter:flags.get', { flag: flagObj, @@ -160,19 +154,15 @@ Flags.list = async function (filters, uid) { const pageCount = Math.ceil(flagIds.length / flagsPerPage); flagIds = flagIds.slice((filters.page - 1) * flagsPerPage, filters.page * flagsPerPage); - const flags = await Promise.all(flagIds.map(async (flagId) => { + const reportCounts = await db.sortedSetsCard(flagIds.map(flagId => `flag:${flagId}:reports`)); + + const flags = await Promise.all(flagIds.map(async (flagId, idx) => { let flagObj = await db.getObject('flag:' + flagId); - const userObj = await user.getUserFields(flagObj.uid, ['username', 'picture']); flagObj = { state: 'open', assignee: null, + heat: reportCounts[idx], ...flagObj, - reporter: { - username: userObj.username, - picture: userObj.picture, - 'icon:bgColor': userObj['icon:bgColor'], - 'icon:text': userObj['icon:text'], - }, }; flagObj.labelClass = Flags._constants.state_class[flagObj.state]; @@ -242,6 +232,24 @@ Flags.getNote = async function (flagId, datetime) { return notes[0]; }; +Flags.getFlagIdByTarget = async function (type, id) { + let method; + switch (type) { + case 'post': + method = posts.getPostField; + break; + + case 'user': + method = user.getUserField; + break; + + default: + throw new Error('[[error:invalid-data]]'); + } + + return await method(id, 'flagId'); +}; + async function modifyNotes(notes) { const uids = []; notes = notes.map(function (note) { @@ -277,11 +285,13 @@ Flags.create = async function (type, id, uid, reason, timestamp) { timestamp = Date.now(); doHistoryAppend = true; } - const [flagExists, targetExists,, targetUid, targetCid] = await Promise.all([ + const [flagExists, targetExists,, targetFlagged, targetUid, targetCid] = await Promise.all([ // Sanity checks Flags.exists(type, id, uid), Flags.targetExists(type, id), Flags.canFlag(type, id, uid), + Flags.targetFlagged(type, id), + // Extra data for zset insertion Flags.getTargetUid(type, id), Flags.getTargetCid(type, id), @@ -292,18 +302,24 @@ Flags.create = async function (type, id, uid, reason, timestamp) { throw new Error('[[error:invalid-data]]'); } + // If the flag already exists, just add the report + if (targetFlagged) { + const flagId = await Flags.getFlagIdByTarget(type, id); + await Flags.addReport(flagId, uid, reason, timestamp); + + return await Flags.get(flagId); + } + const flagId = await db.incrObjectField('global', 'nextFlagId'); await db.setObject('flag:' + flagId, { flagId: flagId, type: type, targetId: id, - description: reason, - uid: uid, datetime: timestamp, }); + await Flags.addReport(flagId, uid, reason, timestamp); await db.sortedSetAdd('flags:datetime', timestamp, flagId); // by time, the default - await db.sortedSetAdd('flags:byReporter:' + uid, timestamp, flagId); // by reporter await db.sortedSetAdd('flags:byType:' + type, timestamp, flagId); // by flag type await db.sortedSetAdd('flags:hash', flagId, [type, id, uid].join(':')); // save zset for duplicate checking await db.sortedSetIncrBy('flags:byTarget', 1, [type, id].join(':')); // by flag target (score is count) @@ -322,6 +338,10 @@ Flags.create = async function (type, id, uid, reason, timestamp) { if (targetUid) { await user.incrementUserFlagsBy(targetUid, 1); } + + await posts.setPostField(id, 'flagId', flagId); + } else if (type === 'user') { + await user.setUserField(id, 'flagId', flagId); } if (doHistoryAppend) { @@ -331,6 +351,31 @@ Flags.create = async function (type, id, uid, reason, timestamp) { return await Flags.get(flagId); }; +Flags.getReports = async function (flagId) { + const [reports, reporterUids] = await Promise.all([ + db.getSortedSetRangeWithScores(`flag:${flagId}:reports`, 0, -1), + db.getSortedSetRange(`flag:${flagId}:reporters`, 0, -1), + ]); + + await Promise.all(reports.map(async (report, idx) => { + report.timestamp = report.score; + report.timestampISO = new Date(report.score).toISOString(); + delete report.score; + report.reporter = await user.getUserFields(reporterUids[idx], ['username', 'userslug', 'picture', 'reputation']); + })); + + return reports; +}; + +Flags.addReport = async function (flagId, uid, reason, timestamp) { + // adds to reporters/report zsets + await Promise.all([ + db.sortedSetAdd(`flags:byReporter:${uid}`, timestamp, flagId), + db.sortedSetAdd(`flag:${flagId}:reports`, timestamp, reason), + db.sortedSetAdd(`flag:${flagId}:reporters`, timestamp, uid), + ]); +}; + Flags.exists = async function (type, id, uid) { return await db.isSortedSetMember('flags:hash', [type, id, uid].join(':')); }; @@ -386,6 +431,10 @@ Flags.targetExists = async function (type, id) { throw new Error('[[error:invalid-data]]'); }; +Flags.targetFlagged = async function (type, id) { + return await db.sortedSetScore('flags:byTarget', [type, id].join(':')) >= 1; +}; + Flags.getTargetUid = async function (type, id) { if (type === 'post') { return await posts.getPostField(id, 'uid'); @@ -545,7 +594,7 @@ Flags.notify = async function (flagObj, uid) { notifObj = await notifications.create({ type: 'new-post-flag', - bodyShort: '[[notifications:user_flagged_post_in, ' + flagObj.reporter.username + ', ' + titleEscaped + ']]', + bodyShort: '[[notifications:user_flagged_post_in, ' + flagObj.reports[flagObj.reports.length - 1].reporter.username + ', ' + titleEscaped + ']]', bodyLong: flagObj.description, pid: flagObj.targetId, path: '/flags/' + flagObj.flagId, @@ -558,7 +607,7 @@ Flags.notify = async function (flagObj, uid) { } else if (flagObj.type === 'user') { notifObj = await notifications.create({ type: 'new-user-flag', - bodyShort: '[[notifications:user_flagged_user, ' + flagObj.reporter.username + ', ' + flagObj.target.username + ']]', + bodyShort: '[[notifications:user_flagged_user, ' + flagObj.reports[flagObj.reports.length - 1].reporter.username + ', ' + flagObj.target.username + ']]', bodyLong: flagObj.description, path: '/flags/' + flagObj.flagId, nid: 'flag:user:' + flagObj.targetId + ':uid:' + uid, diff --git a/src/upgrades/1.14.3/consolidate_flags.js b/src/upgrades/1.14.3/consolidate_flags.js index b7153c5b7b..49da790840 100644 --- a/src/upgrades/1.14.3/consolidate_flags.js +++ b/src/upgrades/1.14.3/consolidate_flags.js @@ -13,22 +13,30 @@ module.exports = { let flags = await db.getSortedSetRange('flags:datetime', 0, -1); flags = flags.map(flagId => `flag:${flagId}`); - flags = await db.getObjectsFields(flags, ['flagId', 'type', 'targetId']); + flags = await db.getObjectsFields(flags, ['flagId', 'type', 'targetId', 'uid', 'description', 'datetime']); progress.total = flags.length; await batch.processArray(flags, async function (subset) { progress.incr(subset.length); await Promise.all(subset.map(async (flagObj) => { + const methods = []; switch (flagObj.type) { case 'post': - await posts.setPostField(flagObj.targetId, 'flagId', flagObj.flagId); + methods.push(posts.setPostField.bind(posts, flagObj.targetId, 'flagId', flagObj.flagId)); break; case 'user': - await user.setUserField(flagObj.targetId, 'flagId', flagObj.flagId); + methods.push(user.setUserField.bind(user, flagObj.targetId, 'flagId', flagObj.flagId)); break; } + + methods.push( + db.sortedSetAdd.bind(db, `flag:${flagObj.flagId}:reports`, flagObj.datetime, flagObj.description), + db.sortedSetAdd.bind(db, `flag:${flagObj.flagId}:reporters`, flagObj.datetime, flagObj.uid) + ); + + await Promise.all(methods.map(async method => method())); })); }, { progress: progress,