diff --git a/dDashboardServer/DashboardServer.cpp b/dDashboardServer/DashboardServer.cpp index 37e204c3b..130a3fda4 100644 --- a/dDashboardServer/DashboardServer.cpp +++ b/dDashboardServer/DashboardServer.cpp @@ -55,6 +55,7 @@ #include "Permissions.h" #include "VanityRoutes.h" #include "ChatRoutes.h" +#include "ChatFlagRoutes.h" #include "Strikes.h" #include "ModerationTools.h" #include "ModeratorHelper.h" @@ -547,6 +548,7 @@ int main(int argc, char** argv) { Game::web.SetWSApiAccessCallback([](const WSAuth& auth) { return Permissions::Allowed(auth.level, "api_access", nullptr, auth.grants.get()); }); RegisterVanityRoutes(); RegisterChatRoutes(); + ChatFlagRoutes::RegisterRoutes(); RegisterStrikeRoutes(); RegisterModerationToolRoutes(); ModeratorHelper::RegisterRoutes(); diff --git a/dDashboardServer/routes/APIRoutes.cpp b/dDashboardServer/routes/APIRoutes.cpp index f02cc3c28..95c80e3c7 100644 --- a/dDashboardServer/routes/APIRoutes.cpp +++ b/dDashboardServer/routes/APIRoutes.cpp @@ -474,7 +474,8 @@ namespace { {"pendingGuildNames", snapshot.pendingGuildNames}, {"pendingProperties", snapshot.pendingProperties}, {"unresolvedBugReports", snapshot.unresolvedBugReports}, - {"openEconomyFlags", snapshot.openEconomyFlags} + {"openEconomyFlags", snapshot.openEconomyFlags}, + {"openChatFlags", snapshot.openChatFlags} }); }); diff --git a/dDashboardServer/routes/CMakeLists.txt b/dDashboardServer/routes/CMakeLists.txt index ca2c66682..72fbaa0b8 100644 --- a/dDashboardServer/routes/CMakeLists.txt +++ b/dDashboardServer/routes/CMakeLists.txt @@ -38,6 +38,7 @@ set(DASHBOARDROUTES_SOURCES "VanityRoutes.cpp" "ChatRoutes.cpp" "ChatHistory.cpp" + "ChatFlagRoutes.cpp" "Strikes.cpp" "Inspector.cpp" "CDClientBrowser.cpp" diff --git a/dDashboardServer/routes/ChatFlagRoutes.cpp b/dDashboardServer/routes/ChatFlagRoutes.cpp new file mode 100644 index 000000000..a65d2a5af --- /dev/null +++ b/dDashboardServer/routes/ChatFlagRoutes.cpp @@ -0,0 +1,230 @@ +#include "ChatFlagRoutes.h" + +#include +#include +#include + +#include "ChatHistory.h" +#include "RouteUtils.h" +#include "WSRoutes.h" +#include "Database.h" +#include "GeneralUtils.h" +#include "eHTTPMethod.h" + +using namespace RouteUtils; + +namespace { + constexpr size_t MAX_MESSAGES = 100; // flagged at once + constexpr uint32_t CONTEXT = 10; // messages kept before and after + constexpr size_t MAX_NOTE = 2000; + + int64_t Now() { return static_cast(std::time(nullptr)); } + + ChatHistory::Access AccessOf(const HTTPContext& context) { + return { Can(context, "chat_private"), Can(context, "chat_dms") }; + } + + std::optional FindMessage(uint64_t id) { + if (id == 0) return std::nullopt; + IChatLog::ChatQuery q; + q.includePrivate = true; + q.includeWhispers = true; + q.afterId = id - 1; + q.limit = 1; + const auto found = Database::Get()->GetChatMessages(q); + if (found.empty() || found.front().id != id) return std::nullopt; + return found.front(); + } + + // The list's view of a flag: what it's about, without the copy of the chat + nlohmann::json FlagJson(const IChatFlags::ChatFlag& f, const ChatHistory::Access& access) { + const bool readable = ChatHistory::CanRead(f.channel, access); + return { {"id", f.id}, {"created_at", f.createdAt}, {"created_by", f.createdBy}, {"status", f.status}, {"channel", f.channel}, + {"character_id", std::to_string(f.characterId)}, {"character_name", f.characterName}, {"account_id", f.accountId}, + {"first_message_id", f.firstMessageId}, {"last_message_id", f.lastMessageId}, {"first_time", f.firstTime}, {"last_time", f.lastTime}, + {"excerpt", readable ? f.excerpt : ""}, {"redacted", !readable}, {"note", f.note}, {"player_report_id", f.playerReportId}, + {"updated_at", f.updatedAt}, {"updated_by", f.updatedBy} }; + } + + void Event(const HTTPContext& context, uint64_t flagId, const std::string& action, const std::string& detail) { + Database::Get()->InsertChatFlagEvent({ 0, flagId, Now(), context.accountId, context.authenticatedUser, action, detail }); + } + + std::string Trimmed(std::string text, size_t max) { + std::erase_if(text, [](char c) { return c == '\r'; }); + if (text.size() > max) text.resize(max); + return text; + } +} + +void ChatFlagRoutes::RegisterRoutes() { + Route(eHTTPMethod::GET, "/chat_flags", Perm("chat_flag"), "Chat flags: chat staff flagged for review", [](HTTPReply& reply, const HTTPContext& context) { + RenderPage(reply, context, "chat_flags.jinja2", "chat_flags"); + }); + + Route(eHTTPMethod::POST, "/api/chat/flags", Perm("chat_flag"), + "Flag chat for review. Body: {message_ids (1-100, from one conversation), character (optional: which sender it is about; default the first), note}. " + "The flag keeps a copy of the messages and the 10 before and after them in the conversation. Returns {id}", + [](HTTPReply& reply, const HTTPContext& context) { + const auto body = ParseBody(context); + if (!body || !body->contains("message_ids") || !(*body)["message_ids"].is_array()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "message_ids is a list of message IDs"); + std::set ids; + for (const auto& value : (*body)["message_ids"]) { + const auto id = value.is_number_unsigned() ? std::optional(value.get()) : value.is_string() ? GeneralUtils::TryParse(value.get()) : std::nullopt; + if (!id || *id == 0) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid message ID"); + ids.insert(*id); + } + if (ids.empty() || ids.size() > MAX_MESSAGES) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Flag 1 to 100 messages at once"); + const auto access = AccessOf(context); + std::vector flagged; + for (const auto id : ids) { + const auto message = FindMessage(id); + if (!message) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Message " + std::to_string(id) + " is not in the chat log (chat is kept for log_chat_days)"); + if (!ChatHistory::CanRead(message->channel, access)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Reading " + message->channel + " chat needs another permission"); + if (!flagged.empty() && !ChatHistory::SameConversation(flagged.front(), *message)) { + return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "The messages are from different conversations; flag each conversation on its own"); + } + flagged.push_back(*message); + } + const auto chosen = GeneralUtils::TryParse(body->value("character", std::string{})).value_or(0); + const auto subject = ChatHistory::Subject(flagged, chosen); + + // The chat around them, from the same conversation + auto beforeQuery = ChatHistory::ConversationQuery(flagged.front(), access); + beforeQuery.beforeId = flagged.front().id; + beforeQuery.newestFirst = true; + beforeQuery.limit = CONTEXT; + auto before = Database::Get()->GetChatMessages(beforeQuery); + std::reverse(before.begin(), before.end()); + auto afterQuery = ChatHistory::ConversationQuery(flagged.front(), access); + afterQuery.afterId = flagged.back().id; + afterQuery.limit = CONTEXT; + const auto after = Database::Get()->GetChatMessages(afterQuery); + // Messages between the first and last flagged one that weren't picked stay in the copy too + auto betweenQuery = ChatHistory::ConversationQuery(flagged.front(), access); + betweenQuery.afterId = flagged.front().id; + betweenQuery.beforeId = flagged.back().id; + betweenQuery.limit = 500; + for (const auto& m : Database::Get()->GetChatMessages(betweenQuery)) { + if (!ids.contains(m.id)) before.push_back(m); + } + + IChatFlags::ChatFlag flag; + flag.createdAt = Now(); + flag.createdById = context.accountId; + flag.createdBy = context.authenticatedUser; + flag.channel = flagged.front().channel; + if (subject) { + flag.characterId = subject->senderId; + flag.characterName = subject->senderName; + flag.accountId = subject->accountId; + } + flag.firstMessageId = flagged.front().id; + flag.lastMessageId = flagged.back().id; + flag.firstTime = flagged.front().time; + flag.lastTime = flagged.back().time; + flag.excerpt = ChatHistory::Excerpt(flagged); + flag.note = Trimmed(body->value("note", std::string{}), MAX_NOTE); + flag.messages = ChatHistory::Snapshot(before, flagged, after).dump(); + const auto id = Database::Get()->InsertChatFlag(flag, { ids.begin(), ids.end() }); + Event(context, id, "created", std::to_string(ids.size()) + " message(s)" + (flag.note.empty() ? "" : ": " + flag.note)); + Audit(context, "chat_flag", "Flagged " + std::to_string(ids.size()) + " " + flag.channel + " message(s) as chat flag " + std::to_string(id) + + (flag.characterName.empty() ? "" : " about " + flag.characterName), subject ? AuditTarget::Account(subject->accountId) : AuditTarget{}); + BroadcastTableChanged("chat_flags", std::to_string(id)); + JsonSuccess(reply, { {"id", id} }); + }); + + ReadRoute(eHTTPMethod::POST, "/api/tables/chat_flags", Perm("chat_flag"), + "Chat flags (DataTables), newest first. Body adds {status (open, actioned, dismissed; empty: any), character (name or ID), account}. " + "Flags on team and guild chat need chat_private, on whispers chat_dms", + [](HTTPReply& reply, const HTTPContext& context) { + const auto request = ParseDataTablesRequest(context.body); + const auto body = ParseBody(context); + if (!request || !body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON"); + const auto access = AccessOf(context); + IChatFlags::ChatFlagQuery q; + q.includePrivate = access.group; + q.includeWhispers = access.whispers; + q.status = ChatHistory::ParseStatus(body->value("status", std::string{})).value_or(""); + const auto character = body->value("character", std::string{}); + if (!character.empty()) q.characterId = ResolveCharacter(character).value_or(-1); + q.accountId = body->contains("account") && (*body)["account"].is_number_unsigned() ? (*body)["account"].get() + : GeneralUtils::TryParse(body->value("account", std::string{})).value_or(0); + q.offset = request->start; + q.limit = std::clamp(request->length, 1, 200); + IChatFlags::ChatFlagQuery all; + all.includePrivate = q.includePrivate; + all.includeWhispers = q.includeWhispers; + nlohmann::json rows = nlohmann::json::array(); + for (const auto& flag : Database::Get()->GetChatFlags(q)) rows.push_back(FlagJson(flag, access)); + JsonReply(reply, eHTTPStatusCode::OK, { {"draw", request->draw}, {"recordsTotal", Database::Get()->CountChatFlags(all)}, + {"recordsFiltered", Database::Get()->CountChatFlags(q)}, {"data", rows} }); + }); + + Route(eHTTPMethod::GET, "/api/chat/flags/:id", Perm("chat_flag"), + "One chat flag: {flag, messages (the copy of the chat, flagged ones marked; text of channels you may not read left out), events (its history, oldest first)}", + [](HTTPReply& reply, const HTTPContext& context) { + const auto id = PathId(context.path, 3); + if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid flag ID"); + const auto flag = Database::Get()->GetChatFlag(*id); + if (!flag) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such chat flag"); + const auto access = AccessOf(context); + auto snapshot = nlohmann::json::parse(flag->messages, nullptr, false); + nlohmann::json events = nlohmann::json::array(); + for (const auto& e : Database::Get()->GetChatFlagEvents(*id)) { + events.push_back({ {"id", e.id}, {"time", e.time}, {"actor", e.actor}, {"action", e.action}, {"detail", e.detail} }); + } + if (flag->channel == "whisper" && access.whispers) { + Audit(context, "chat_dms_view", "Whispers in chat flag " + std::to_string(*id), AuditTarget::Character(flag->characterId)); + } + JsonSuccess(reply, { {"flag", FlagJson(*flag, access)}, {"messages", ChatHistory::RedactSnapshot(snapshot, access)}, {"events", events}, + {"canReview", Can(context, "chat_flag_review")} }); + }); + + Route(eHTTPMethod::POST, "/api/chat/flags/:id", Perm("chat_flag_review"), + "Review a chat flag. Body (each optional): {status (open, actioned, dismissed), note (replaces it), player_report_id (0 to unlink), comment (added to its history)}", + [](HTTPReply& reply, const HTTPContext& context) { + const auto id = PathId(context.path, 3); + if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid flag ID"); + const auto body = ParseBody(context); + if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON"); + const auto flag = Database::Get()->GetChatFlag(*id); + if (!flag) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such chat flag"); + // Only chat the reviewer may read + if (!ChatHistory::CanRead(flag->channel, AccessOf(context))) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Reviewing " + flag->channel + " chat needs another permission"); + + std::string status = flag->status; + if (body->contains("status")) { + const auto parsed = ChatHistory::ParseStatus(body->value("status", std::string{})); + if (!parsed) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "status is open, actioned or dismissed"); + status = *parsed; + } + const std::string note = body->contains("note") ? Trimmed(body->value("note", std::string{}), MAX_NOTE) : flag->note; + uint64_t report = flag->playerReportId; + if (body->contains("player_report_id")) { + const auto& value = (*body)["player_report_id"]; + const auto parsed = value.is_number_unsigned() ? std::optional(value.get()) : value.is_string() ? GeneralUtils::TryParse(value.get()) : std::nullopt; + if (!parsed) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "player_report_id is a number"); + report = *parsed; + } + const auto comment = Trimmed(body->value("comment", std::string{}), MAX_NOTE); + + const auto action = ChatHistory::StatusAction(flag->status, status); + const bool changed = !action.empty() || note != flag->note || report != flag->playerReportId; + if (!changed && comment.empty()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Nothing to change"); + if (changed) Database::Get()->UpdateChatFlag(*id, status, note, report, Now(), context.authenticatedUser); + if (!action.empty()) Event(context, *id, action, comment); + if (note != flag->note) Event(context, *id, "note", note); + if (report != flag->playerReportId) Event(context, *id, "report", report ? "Player report " + std::to_string(report) : "No player report"); + if (action.empty() && !comment.empty()) Event(context, *id, "comment", comment); + + std::string what = "Chat flag " + std::to_string(*id) + ":"; + if (!action.empty()) what += " " + action; + if (note != flag->note) what += " note changed"; + if (report != flag->playerReportId) what += " report " + std::to_string(report); + if (!comment.empty()) what += " (" + comment + ")"; + Audit(context, "chat_flag_review", what, AuditTarget::Account(flag->accountId)); + BroadcastTableChanged("chat_flags", std::to_string(*id)); + JsonSuccess(reply); + }); +} diff --git a/dDashboardServer/routes/ChatFlagRoutes.h b/dDashboardServer/routes/ChatFlagRoutes.h new file mode 100644 index 000000000..0b37d2c82 --- /dev/null +++ b/dDashboardServer/routes/ChatFlagRoutes.h @@ -0,0 +1,11 @@ +#pragma once + +/** + * Chat flags: staff flag a chat message, or several from one conversation, for review. Each flag keeps a copy of the + * flagged messages and the chat around them, has a status (open, actioned, dismissed), a note, a link to the character + * and account it is about, and a history of who did what (also in the audit log). The Chat Flags page is the queue; + * open flags count on the sidebar badge. + */ +namespace ChatFlagRoutes { + void RegisterRoutes(); +} diff --git a/dDashboardServer/routes/WSRoutes.cpp b/dDashboardServer/routes/WSRoutes.cpp index 1d34a6106..4173d93ec 100644 --- a/dDashboardServer/routes/WSRoutes.cpp +++ b/dDashboardServer/routes/WSRoutes.cpp @@ -25,7 +25,8 @@ namespace { {"pendingGuildNames", snapshot.pendingGuildNames}, {"pendingProperties", snapshot.pendingProperties}, {"unresolvedBugReports", snapshot.unresolvedBugReports}, - {"openEconomyFlags", snapshot.openEconomyFlags} + {"openEconomyFlags", snapshot.openEconomyFlags}, + {"openChatFlags", snapshot.openChatFlags} }; } @@ -47,6 +48,7 @@ namespace { changed("command_log", previous.commandLogMaxId != current.commandLogMaxId); changed("audit_log", previous.auditLogMaxId != current.auditLogMaxId); changed("economy_flags", previous.openEconomyFlags != current.openEconomyFlags || previous.economyFlagsMaxId != current.economyFlagsMaxId); + changed("chat_flags", previous.openChatFlags != current.openChatFlags || previous.chatFlagsMaxId != current.chatFlagsMaxId || previous.chatFlagsUpdatedAt != current.chatFlagsUpdatedAt); } } diff --git a/dDashboardServer/static/js/chat-flags.js b/dDashboardServer/static/js/chat-flags.js new file mode 100644 index 000000000..f60dc580c --- /dev/null +++ b/dDashboardServer/static/js/chat-flags.js @@ -0,0 +1,138 @@ +/** + * The Chat Flags page: the queue of flagged chat (open first by default), and one flag in detail: its copy of the chat, + * links to the character, account (mute, warn, ban), their player reports and the conversation, its history, and + * (with chat_flag_review) the review form. #flag= opens a flag, #character= and #account= filter the queue. + */ +(function () { + 'use strict'; + + var CHANNELS = { zone: ['Zone', 'secondary'], whisper: ['Whisper', 'info'], team: ['Team', 'primary'], guild: ['Guild', 'success'], web: ['Web', 'warning'] }; + var STATUS = { open: ['Open', 'warning'], actioned: ['Actioned', 'success'], dismissed: ['Dismissed', 'secondary'] }; + var ACTIONS = { created: 'Flagged', actioned: 'Marked actioned', dismissed: 'Dismissed', reopened: 'Reopened', note: 'Changed the note', report: 'Linked a report', comment: 'Commented' }; + var NEEDS = { whisper: 'chat_dms', team: 'chat_private', guild: 'chat_private' }; + var state = { status: 'open', character: '', account: '' }; + var shown = null; + + function hashValue(name) { var m = location.hash.match(new RegExp('[#&]' + name + '=([^&]*)')); return m ? decodeURIComponent(m[1]) : ''; } + if (hashValue('character')) { state.character = hashValue('character'); document.getElementById('flagCharacter').value = state.character; } + if (hashValue('account')) { state.account = hashValue('account'); document.getElementById('flagAccount').value = state.account; } + if (state.character || state.account || hashValue('flag')) { + state.status = ''; + document.getElementById('flagStatusAll').checked = true; + } + + function channelBadge(c) { var b = CHANNELS[c] || [c, 'secondary']; return fmt.badge(b[0], b[1]); } + function statusBadge(s) { var b = STATUS[s] || [s, 'secondary']; return fmt.badge(b[0], b[1]); } + function about(f) { + return (f.character_id !== '0' ? fmt.character(f.character_id, f.character_name) : '-') + + (f.account_id ? '' : ''); + } + function excerpt(f) { + return f.redacted ? 'Hidden: needs ' + esc(NEEDS[f.channel] || 'another permission') + '' : esc(f.excerpt); + } + + var table = serverTable('#flagsTable', '/api/tables/chat_flags', [ + { data: 'id', orderable: false }, + { data: 'created_at', orderable: false, render: function (d, t, row) { return esc(fmt.unix(d)) + '
by ' + esc(row.created_by) + '
'; } }, + { data: 'channel', orderable: false, render: channelBadge }, + { data: 'character_id', orderable: false, render: function (d, t, row) { return about(row); } }, + { data: 'excerpt', orderable: false, render: function (d, t, row) { return excerpt(row); } }, + { data: 'note', orderable: false, render: function (d) { return esc(d); } }, + { data: 'status', orderable: false, render: function (d, t, row) { + return statusBadge(d) + (row.updated_by ? '
' + esc(row.updated_by) + ', ' + esc(fmt.unix(row.updated_at)) + '
' : ''); + } }, + { data: null, orderable: false, render: function (d) { return ''; } } + ], { + liveTable: 'chat_flags', + dataTable: { order: [], searching: false }, + extra: function () { return { status: state.status, character: state.character, account: state.account }; } + }); + + function conversationLink(f, messages) { + var first = messages.filter(function (m) { return m.flagged; })[0]; + if (!first || f.redacted) return ''; + if (f.channel === 'whisper' && first.sender_id !== '0' && first.recipient_id !== '0') return '/characters/' + first.sender_id + '/whispers#with=' + first.recipient_id; + if (f.channel === 'team' && first.team_id !== '0') return '/chat_log/teams#team=' + first.team_id; + if (f.channel === 'guild' && first.guild_id !== '0') return '/chat_log/guild/' + first.guild_id; + if (f.channel === 'zone' && first.zone_id) return '/chat_log#world=' + first.zone_id + ':' + first.instance_id; + return ''; + } + + function show(id) { + return api.get('/api/chat/flags/' + encodeURIComponent(id)).then(function (d) { + if (!d.success) { toast(d.error || 'Could not load the flag', 'danger'); return; } + var f = d.flag; + shown = f; + try { history.replaceState(null, '', '#flag=' + f.id); } catch (e) {} + document.getElementById('flagDetail').classList.remove('d-none'); + document.getElementById('flagTitle').innerHTML = 'Chat flag ' + esc(f.id) + ' ' + statusBadge(f.status) + ' ' + channelBadge(f.channel); + document.getElementById('flagSubtitle').innerHTML = 'Flagged by ' + esc(f.created_by) + ', ' + esc(fmt.unix(f.created_at)) + + '. Messages from ' + esc(fmt.unix(f.first_time)) + (f.last_time !== f.first_time ? ' to ' + esc(fmt.unix(f.last_time)) : '') + '.'; + var thread = conversationLink(f, d.messages); + var links = ''; + if (f.character_id !== '0') links += '' + esc(f.character_name || 'Character') + ''; + if (f.account_id) { + links += 'Account: mute, warn, ban'; + if (DASH.can('player_reports_view')) links += 'Reports about them'; + links += 'Their other flags'; + } + if (f.player_report_id && DASH.can('player_reports_view')) links += 'Report ' + esc(f.player_report_id) + ''; + if (thread) links += 'Conversation'; + document.getElementById('flagLinks').innerHTML = links; + + document.getElementById('flagMessages').innerHTML = d.messages.length ? d.messages.map(function (m) { + return '' + esc(fmt.unix(m.time)) + '' + + '' + (m.sender_id !== '0' ? fmt.character(m.sender_id, m.sender_name) : esc(m.sender_name)) + + (m.recipient_id !== '0' ? ' → ' + fmt.character(m.recipient_id, m.recipient_name) : '') + '' + + '' + ChatText(m) + ''; + }).join('') : 'No copy of the chat.'; + + document.getElementById('flagEvents').innerHTML = d.events.map(function (e) { + return '
  • ' + esc(fmt.unix(e.time)) + ' ' + esc(e.actor) + ' ' + + esc((ACTIONS[e.action] || e.action).toLowerCase()) + (e.detail ? '
    ' + esc(e.detail) + '
    ' : '') + '
  • '; + }).join('') || '
  • Nothing yet.
  • '; + + var form = document.getElementById('flagReview'); + form.classList.toggle('d-none', !d.canReview || f.redacted); + var radio = document.querySelector('input[name="reviewStatus"][value="' + f.status + '"]'); + if (radio) radio.checked = true; + document.getElementById('reviewNote').value = f.note; + document.getElementById('reviewReport').value = f.player_report_id || ''; + document.getElementById('reviewComment').value = ''; + document.getElementById('flagDetail').scrollIntoView({ behavior: 'smooth', block: 'start' }); + }).catch(function () {}); + } + + function ChatText(m) { + if (m.redacted) return 'Hidden: reading ' + esc(m.channel) + ' chat needs ' + esc(NEEDS[m.channel] || 'another permission') + ''; + return (m.blocked ? fmt.badge('Stopped by the filter', 'danger') + ' ' : m.filtered ? fmt.badge('Filter words', 'warning') + ' ' : '') + esc(m.message); + } + + document.getElementById('flagReview').addEventListener('submit', function (e) { + e.preventDefault(); + if (!shown) return; + var status = document.querySelector('input[name="reviewStatus"]:checked'); + var body = { + status: status ? status.value : shown.status, + note: document.getElementById('reviewNote').value, + player_report_id: document.getElementById('reviewReport').value.trim() || '0', + comment: document.getElementById('reviewComment').value + }; + var id = shown.id; + api.action('/api/chat/flags/' + id, body, 'Saved').then(function () { + table.ajax.reload(null, false); + show(id); + }).catch(function () {}); + }); + + $('#flagsTable').on('click', '[data-flag]', function () { show(this.getAttribute('data-flag')); }); + document.getElementById('statusFilter').addEventListener('change', function (e) { + if (e.target.name !== 'flagStatus') return; + state.status = e.target.value; + table.ajax.reload(); + }); + document.getElementById('flagCharacter').addEventListener('change', function () { state.character = this.value.trim(); table.ajax.reload(); }); + document.getElementById('flagAccount').addEventListener('change', function () { state.account = this.value.trim(); table.ajax.reload(); }); + + if (hashValue('flag')) show(hashValue('flag')); +})(); diff --git a/dDashboardServer/static/js/dashboard.js b/dDashboardServer/static/js/dashboard.js index f41d3929b..26812be3a 100644 --- a/dDashboardServer/static/js/dashboard.js +++ b/dDashboardServer/static/js/dashboard.js @@ -130,7 +130,8 @@ 'pet_names': counts.pendingPetNames || 0, 'guilds': counts.pendingGuildNames || 0, 'bug_reports': counts.unresolvedBugReports || 0, - 'reports': counts.openEconomyFlags || 0 + 'reports': counts.openEconomyFlags || 0, + 'chat_flags': counts.openChatFlags || 0 }; Object.keys(map).forEach(function (key) { var el = document.querySelector('[data-badge="' + key + '"]'); diff --git a/dDashboardServer/templates/chat_flags.jinja2 b/dDashboardServer/templates/chat_flags.jinja2 new file mode 100644 index 000000000..8f0bc1cab --- /dev/null +++ b/dDashboardServer/templates/chat_flags.jinja2 @@ -0,0 +1,76 @@ +{% extends "base.jinja2" %} + +{% block title %}Chat Flags - DarkflameServer{% endblock %} + +{% block content %} +
    +
    +
    +

    Chat Flags

    +

    Chat staff flagged for review, newest first. Flag messages from the Chat Log or a guild, team or whisper history. + Each flag keeps a copy of the flagged messages and the chat around them. Mute, warn or ban from the account page, then mark the flag actioned; dismiss it when nothing needs doing.

    +
    +
    + + + + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + +
    #FlaggedChannelAboutMessagesNoteStatus
    +
    +
    + +
    +
    +
    + +
    +
    +
    +
    +
    Messages (flagged ones highlighted; the chat around them as it was)
    +
    +
    +
    +
    +
    Review
    +
    + + + +
    + + + + + + + +
    +
    History
    +
      +
      +
      +
      +
      +
      +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/dDashboardServer/templates/header.jinja2 b/dDashboardServer/templates/header.jinja2 index a85dc3b7d..2073996d7 100644 --- a/dDashboardServer/templates/header.jinja2 +++ b/dDashboardServer/templates/header.jinja2 @@ -16,8 +16,8 @@ {% if can.properties_view %}UGC{% endif %} - {% set inboxPages = ["moderation", "player_reports", "bug_reports"] %} - {% if can.moderate_names or can.moderate_pet_names or can.guilds_manage or can.moderate_properties or can.player_reports_view or can.bug_reports_view %} + {% set inboxPages = ["moderation", "player_reports", "bug_reports", "chat_flags"] %} + {% if can.moderate_names or can.moderate_pet_names or can.guilds_manage or can.moderate_properties or can.player_reports_view or can.bug_reports_view or can.chat_flag %} {% endif %} - {% set moderationPages = ["players", "chat_log", "chat_filter", "pet_names", "guilds", "mail", "send_mail"] %} + {% set moderationPages = ["players", "chat_log", "chat_teams", "chat_filter", "pet_names", "guilds", "mail", "send_mail"] %} {% if can.players_view or can.chat_view or can.chat_filter_manage or can.moderate_pet_names or can.guilds_manage or can.characters_mail or can.mail_send %}