feat(dashboard): chat flags queue

Staff flag chat for review (chat_flag): one message or several from one
conversation, with who it is about and a note. The flag keeps a copy of the
messages and 10 before and after. The Chat Flags page (Inbox, badge for open
flags) filters by status, character and account; a flag links to the character,
the account (mute, warn, ban), their player reports, their other flags and the
conversation, and shows its history. Reviewers (chat_flag_review) mark it
actioned or dismissed, change the note, link a player report or comment; all of
it is in the flag's history and the audit log. The sidebar adds Team Chat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 18:22:43 -05:00
parent f3a49991d4
commit 6c43753fb2
10 changed files with 470 additions and 6 deletions

View File

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

View File

@@ -474,7 +474,8 @@ namespace {
{"pendingGuildNames", snapshot.pendingGuildNames},
{"pendingProperties", snapshot.pendingProperties},
{"unresolvedBugReports", snapshot.unresolvedBugReports},
{"openEconomyFlags", snapshot.openEconomyFlags}
{"openEconomyFlags", snapshot.openEconomyFlags},
{"openChatFlags", snapshot.openChatFlags}
});
});

View File

@@ -38,6 +38,7 @@ set(DASHBOARDROUTES_SOURCES
"VanityRoutes.cpp"
"ChatRoutes.cpp"
"ChatHistory.cpp"
"ChatFlagRoutes.cpp"
"Strikes.cpp"
"Inspector.cpp"
"CDClientBrowser.cpp"

View File

@@ -0,0 +1,230 @@
#include "ChatFlagRoutes.h"
#include <algorithm>
#include <ctime>
#include <set>
#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<int64_t>(std::time(nullptr)); }
ChatHistory::Access AccessOf(const HTTPContext& context) {
return { Can(context, "chat_private"), Can(context, "chat_dms") };
}
std::optional<IChatLog::ChatMessage> 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<uint64_t> ids;
for (const auto& value : (*body)["message_ids"]) {
const auto id = value.is_number_unsigned() ? std::optional(value.get<uint64_t>()) : value.is_string() ? GeneralUtils::TryParse<uint64_t>(value.get<std::string>()) : 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<IChatLog::ChatMessage> 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<LWOOBJID>(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<uint32_t>()
: GeneralUtils::TryParse<uint32_t>(body->value("account", std::string{})).value_or(0);
q.offset = request->start;
q.limit = std::clamp<uint32_t>(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<uint64_t>(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<uint64_t>(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<uint64_t>()) : value.is_string() ? GeneralUtils::TryParse<uint64_t>(value.get<std::string>()) : 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);
});
}

View File

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

View File

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

View File

@@ -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=<id> 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) : '<span class="text-body-secondary">-</span>') +
(f.account_id ? '<div class="small"><a href="/accounts/' + esc(f.account_id) + '">Account ' + esc(f.account_id) + '</a></div>' : '');
}
function excerpt(f) {
return f.redacted ? '<span class="text-body-secondary fst-italic">Hidden: needs ' + esc(NEEDS[f.channel] || 'another permission') + '</span>' : 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)) + '<div class="small text-body-secondary">by ' + esc(row.created_by) + '</div>'; } },
{ 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 ? '<div class="small text-body-secondary">' + esc(row.updated_by) + ', ' + esc(fmt.unix(row.updated_at)) + '</div>' : '');
} },
{ data: null, orderable: false, render: function (d) { return '<button type="button" class="btn btn-sm btn-outline-primary" data-flag="' + esc(d.id) + '">Review</button>'; } }
], {
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 += '<a class="btn btn-sm btn-outline-secondary" href="/characters/' + esc(f.character_id) + '">' + esc(f.character_name || 'Character') + '</a>';
if (f.account_id) {
links += '<a class="btn btn-sm btn-outline-danger" href="/accounts/' + esc(f.account_id) + '" title="Mute, warn, strike or ban from the account page">Account: mute, warn, ban</a>';
if (DASH.can('player_reports_view')) links += '<a class="btn btn-sm btn-outline-secondary" href="/player_reports?status=&account=' + esc(f.account_id) + '">Reports about them</a>';
links += '<a class="btn btn-sm btn-outline-secondary" href="/chat_flags#account=' + esc(f.account_id) + '">Their other flags</a>';
}
if (f.player_report_id && DASH.can('player_reports_view')) links += '<a class="btn btn-sm btn-outline-secondary" href="/player_reports?status=">Report ' + esc(f.player_report_id) + '</a>';
if (thread) links += '<a class="btn btn-sm btn-outline-secondary" href="' + esc(thread) + '">Conversation</a>';
document.getElementById('flagLinks').innerHTML = links;
document.getElementById('flagMessages').innerHTML = d.messages.length ? d.messages.map(function (m) {
return '<tr class="' + (m.flagged ? 'table-warning' : '') + '"><td class="text-nowrap small text-body-secondary">' + esc(fmt.unix(m.time)) + '</td>' +
'<td class="text-nowrap">' + (m.sender_id !== '0' ? fmt.character(m.sender_id, m.sender_name) : esc(m.sender_name)) +
(m.recipient_id !== '0' ? ' &rarr; ' + fmt.character(m.recipient_id, m.recipient_name) : '') + '</td>' +
'<td>' + ChatText(m) + '</td></tr>';
}).join('') : '<tr><td class="text-body-secondary">No copy of the chat.</td></tr>';
document.getElementById('flagEvents').innerHTML = d.events.map(function (e) {
return '<li class="mb-2"><span class="text-body-secondary">' + esc(fmt.unix(e.time)) + '</span> <strong>' + esc(e.actor) + '</strong> ' +
esc((ACTIONS[e.action] || e.action).toLowerCase()) + (e.detail ? '<div>' + esc(e.detail) + '</div>' : '') + '</li>';
}).join('') || '<li class="text-body-secondary">Nothing yet.</li>';
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 '<span class="text-body-secondary fst-italic">Hidden: reading ' + esc(m.channel) + ' chat needs ' + esc(NEEDS[m.channel] || 'another permission') + '</span>';
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'));
})();

View File

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

View File

@@ -0,0 +1,76 @@
{% extends "base.jinja2" %}
{% block title %}Chat Flags - DarkflameServer{% endblock %}
{% block content %}
<div id="chatFlagsPage">
<div class="d-flex justify-content-between align-items-start mb-3 flex-wrap gap-2">
<div>
<h2 class="mb-1">Chat Flags</h2>
<p class="text-body-secondary mb-0">Chat staff flagged for review, newest first. Flag messages from the <a href="/chat_log">Chat Log</a> 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.</p>
</div>
<div class="btn-group flex-wrap" role="group" id="statusFilter" aria-label="Status">
<input type="radio" class="btn-check" name="flagStatus" id="flagStatusOpen" value="open" checked><label class="btn btn-outline-primary" for="flagStatusOpen">Open</label>
<input type="radio" class="btn-check" name="flagStatus" id="flagStatusActioned" value="actioned"><label class="btn btn-outline-primary" for="flagStatusActioned">Actioned</label>
<input type="radio" class="btn-check" name="flagStatus" id="flagStatusDismissed" value="dismissed"><label class="btn btn-outline-primary" for="flagStatusDismissed">Dismissed</label>
<input type="radio" class="btn-check" name="flagStatus" id="flagStatusAll" value=""><label class="btn btn-outline-primary" for="flagStatusAll">All</label>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<div class="row g-2 align-items-end">
<div class="col-sm-6 col-lg-4"><label class="form-label small mb-1" for="flagCharacter">About character</label>
<input class="form-control form-control-sm" id="flagCharacter" placeholder="Name or ID"></div>
<div class="col-sm-6 col-lg-3"><label class="form-label small mb-1" for="flagAccount">About account ID</label>
<input class="form-control form-control-sm" id="flagAccount" inputmode="numeric" placeholder="Any"></div>
</div>
</div>
<div class="card-body">
<table id="flagsTable" class="table table-sm table-hover align-middle w-100 table-stack">
<thead><tr><th>#</th><th>Flagged</th><th>Channel</th><th>About</th><th>Messages</th><th>Note</th><th>Status</th><th></th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="card d-none" id="flagDetail">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div><h5 class="mb-0" id="flagTitle"></h5><div class="small text-body-secondary" id="flagSubtitle"></div></div>
<div class="d-flex flex-wrap gap-2" id="flagLinks"></div>
</div>
<div class="card-body">
<div class="row g-4">
<div class="col-lg-8">
<h6>Messages <span class="small text-body-secondary fw-normal">(flagged ones highlighted; the chat around them as it was)</span></h6>
<table class="table table-sm align-middle"><tbody id="flagMessages"></tbody></table>
</div>
<div class="col-lg-4">
<form id="flagReview" class="mb-4 d-none">
<h6>Review</h6>
<div class="btn-group w-100 mb-2" role="group" aria-label="Status">
<input type="radio" class="btn-check" name="reviewStatus" id="reviewOpen" value="open"><label class="btn btn-sm btn-outline-warning" for="reviewOpen">Open</label>
<input type="radio" class="btn-check" name="reviewStatus" id="reviewActioned" value="actioned"><label class="btn btn-sm btn-outline-success" for="reviewActioned">Actioned</label>
<input type="radio" class="btn-check" name="reviewStatus" id="reviewDismissed" value="dismissed"><label class="btn btn-sm btn-outline-secondary" for="reviewDismissed">Dismissed</label>
</div>
<label class="form-label small mb-1" for="reviewNote">Note</label>
<textarea class="form-control form-control-sm mb-2" id="reviewNote" rows="3" maxlength="2000"></textarea>
<label class="form-label small mb-1" for="reviewReport">Player report ID <span class="text-body-secondary">(optional)</span></label>
<input class="form-control form-control-sm mb-2" id="reviewReport" inputmode="numeric" placeholder="None">
<label class="form-label small mb-1" for="reviewComment">Comment for the history <span class="text-body-secondary">(optional, e.g. "muted 3 days")</span></label>
<input class="form-control form-control-sm mb-2" id="reviewComment" maxlength="2000">
<button class="btn btn-sm btn-primary">Save</button>
</form>
<h6>History</h6>
<ul class="list-unstyled small mb-0" id="flagEvents"></ul>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/chat-flags.js"></script>
{% endblock %}

View File

@@ -16,8 +16,8 @@
{% if can.properties_view %}<a href="/ugc" class="list-group-item list-group-item-action{% if current_page == "ugc" %} active{% endif %}">UGC</a>{% endif %}
</div>
{% 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 %}
<div class="nav-group">
<button class="nav-group-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#nav-inbox" aria-expanded="{% if current_page in inboxPages %}true{% else %}false{% endif %}" aria-controls="nav-inbox">
Inbox<span class="nav-group-dot" title="Something in here needs attention"></span>
@@ -26,13 +26,14 @@
<div class="list-group list-group-flush">
{% if can.moderate_names or can.moderate_pet_names or can.guilds_manage or can.moderate_properties %}<a href="/moderation" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center{% if current_page == "moderation" %} active{% endif %}">Review Queue <span class="badge rounded-pill text-bg-warning d-none" data-badge="moderation"></span></a>{% endif %}
{% if can.player_reports_view %}<a href="/player_reports" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center{% if current_page == "player_reports" %} active{% endif %}">Player Reports <span class="badge rounded-pill text-bg-danger d-none" data-badge="player_reports"></span></a>{% endif %}
{% if can.chat_flag %}<a href="/chat_flags" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center{% if current_page == "chat_flags" %} active{% endif %}">Chat Flags <span class="badge rounded-pill text-bg-warning d-none" data-badge="chat_flags" title="Open chat flags"></span></a>{% endif %}
{% if can.bug_reports_view %}<a href="/bug_reports" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center{% if current_page == "bug_reports" %} active{% endif %}">Bug Reports <span class="badge rounded-pill text-bg-danger d-none" data-badge="bug_reports"></span></a>{% endif %}
</div>
</div>
</div>
{% 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 %}
<div class="nav-group">
<button class="nav-group-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#nav-moderation" aria-expanded="{% if current_page in moderationPages %}true{% else %}false{% endif %}" aria-controls="nav-moderation">
@@ -42,6 +43,7 @@
<div class="list-group list-group-flush">
{% if can.players_view %}<a href="/players" class="list-group-item list-group-item-action{% if current_page == "players" %} active{% endif %}">Online Players</a>{% endif %}
{% if can.chat_view %}<a href="/chat_log" class="list-group-item list-group-item-action{% if current_page == "chat_log" %} active{% endif %}">Chat Log</a>{% endif %}
{% if can.chat_private %}<a href="/chat_log/teams" class="list-group-item list-group-item-action{% if current_page == "chat_teams" %} active{% endif %}">Team Chat</a>{% endif %}
{% if can.chat_filter_manage %}<a href="/chat_filter" class="list-group-item list-group-item-action{% if current_page == "chat_filter" %} active{% endif %}">Chat Filter</a>{% endif %}
{% if can.moderate_pet_names %}<a href="/pet_names" class="list-group-item list-group-item-action{% if current_page == "pet_names" %} active{% endif %}">Pet Names</a>{% endif %}
{% if can.guilds_manage %}<a href="/guilds" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center{% if current_page == "guilds" %} active{% endif %}">Guilds <span class="badge rounded-pill text-bg-warning d-none" data-badge="guilds" title="Guild names waiting for review"></span></a>{% endif %}