mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
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>
348 lines
13 KiB
JavaScript
348 lines
13 KiB
JavaScript
/**
|
|
* Live updates over a single WebSocket.
|
|
*
|
|
* The server pushes three topics:
|
|
* - dashboard_update : server status and totals
|
|
* - table_changed : {table, id?} - refetch that table through the normal API
|
|
* - moderation_counts : moderation queue sizes for the sidebar badges (GM 3+)
|
|
*
|
|
* Pages call Live.watchTable(name, dataTable), Live.on(name, callback) or Live.refreshPage(names, id) to react to
|
|
* changes; what a page registers lasts until nav.js swaps in another page (Live.resetPage). Game servers report their
|
|
* writes as they happen (world -> master -> dashboard), and the dashboard also diffs a database snapshot for writers
|
|
* outside the game. After a reconnect every watcher is told to resync, since changes may have been missed while the
|
|
* socket was down.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
var MAX_BACKOFF_MS = 30000;
|
|
var RELOAD_THROTTLE_MS = 1500; // busy servers report changes constantly; reload tables at most this often
|
|
var PAGE_REFRESH_MIN_MS = 3000;
|
|
|
|
var ws = null;
|
|
var attempts = 0;
|
|
var tableWatchers = {}; // table name -> [callback]
|
|
var statusWatchers = [];
|
|
var actionWaiters = {}; // requestId -> callback for actions started from this tab
|
|
var topicWatchers = {}; // extra topics a page asked for (e.g. player_positions) -> [callback]
|
|
var wasConnected = false;
|
|
var pageGeneration = 0; // goes up on every page change; callbacks of a page that's gone do nothing
|
|
// The restart countdown lives across page changes, so it keeps the browser's own timer (nav.js stops the page's)
|
|
var coreSetInterval = window.setInterval.bind(window);
|
|
|
|
function setIndicator(text, cls) {
|
|
var el = document.getElementById('live-indicator');
|
|
if (!el) return;
|
|
el.textContent = text;
|
|
el.className = cls;
|
|
}
|
|
|
|
function subscribe(topic) {
|
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ event: 'subscribe', subscription: topic }));
|
|
}
|
|
|
|
function notifyTable(table, data) {
|
|
(tableWatchers[table] || []).forEach(function (cb) {
|
|
try { cb(data); } catch (e) { console.error(e); }
|
|
});
|
|
}
|
|
|
|
function handleMessage(data) {
|
|
switch (data.event) {
|
|
case 'dashboard_update':
|
|
statusWatchers.forEach(function (cb) { cb(data); });
|
|
break;
|
|
case 'table_changed':
|
|
notifyTable(data.table, data);
|
|
break;
|
|
case 'moderation_counts':
|
|
updateBadges(data);
|
|
break;
|
|
case 'action_result':
|
|
resolveAction(data);
|
|
break;
|
|
case 'session_ended':
|
|
// Signed out everywhere, banned or no longer allowed on the dashboard
|
|
api.get('/api/auth/me').then(function (me) { if (!me.valid) window.location.href = '/login'; }).catch(function () {});
|
|
break;
|
|
default:
|
|
(topicWatchers[data.event] || []).forEach(function (cb) {
|
|
try { cb(data); } catch (e) { console.error(e); }
|
|
});
|
|
}
|
|
if (data.event === 'dashboard_update') showRestart(data.restart);
|
|
}
|
|
|
|
function connect() {
|
|
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
try {
|
|
ws = new WebSocket(protocol + '//' + window.location.host + '/ws');
|
|
} catch (e) {
|
|
scheduleReconnect();
|
|
return;
|
|
}
|
|
|
|
ws.onopen = function () {
|
|
attempts = 0;
|
|
setIndicator('live', 'text-success');
|
|
if (wasConnected) {
|
|
// Anything could have changed while we were away
|
|
Object.keys(tableWatchers).forEach(function (table) { notifyTable(table, { table: table, resync: true }); });
|
|
}
|
|
wasConnected = true;
|
|
subscribe('dashboard_update');
|
|
if (DASH.gmLevel >= 1) subscribe('table_changed');
|
|
if (DASH.can('moderate_names')) subscribe('moderation_counts');
|
|
subscribe('action_result');
|
|
Object.keys(topicWatchers).forEach(subscribe);
|
|
};
|
|
ws.onmessage = function (event) {
|
|
try { handleMessage(JSON.parse(event.data)); } catch (e) { /* ignore malformed frames */ }
|
|
};
|
|
ws.onclose = function () {
|
|
ws = null;
|
|
setIndicator('reconnecting…', 'text-warning');
|
|
scheduleReconnect();
|
|
};
|
|
ws.onerror = function () { /* onclose follows */ };
|
|
}
|
|
|
|
function scheduleReconnect() {
|
|
attempts++;
|
|
var delay = Math.min(MAX_BACKOFF_MS, 1000 * Math.pow(2, attempts - 1));
|
|
// Our session may have expired; check before hammering the socket
|
|
if (attempts > 3) {
|
|
api.get('/api/auth/me').then(function (me) { if (!me.valid) window.location.href = '/login'; }).catch(function () {});
|
|
}
|
|
setTimeout(connect, delay);
|
|
}
|
|
|
|
function resolveAction(result) {
|
|
var cb = actionWaiters[result.requestId];
|
|
if (!cb) return;
|
|
delete actionWaiters[result.requestId];
|
|
cb(result);
|
|
}
|
|
|
|
function updateBadges(counts) {
|
|
var map = {
|
|
'moderation': (counts.pendingNames || 0) + (DASH.can('moderate_pet_names') ? counts.pendingPetNames || 0 : 0) + (DASH.can('guilds_manage') ? counts.pendingGuildNames || 0 : 0) + (DASH.can('moderate_properties') ? counts.pendingProperties || 0 : 0),
|
|
'pet_names': counts.pendingPetNames || 0,
|
|
'guilds': counts.pendingGuildNames || 0,
|
|
'bug_reports': counts.unresolvedBugReports || 0,
|
|
'reports': counts.openEconomyFlags || 0,
|
|
'chat_flags': counts.openChatFlags || 0
|
|
};
|
|
Object.keys(map).forEach(function (key) {
|
|
var el = document.querySelector('[data-badge="' + key + '"]');
|
|
if (!el) return;
|
|
el.textContent = map[key];
|
|
el.classList.toggle('d-none', !map[key]);
|
|
});
|
|
}
|
|
|
|
// Run fn at most once per interval, always including the last call (leading and trailing)
|
|
function throttle(fn, interval) {
|
|
var last = 0, timer = null;
|
|
return function () {
|
|
var wait = last + interval - Date.now();
|
|
if (wait <= 0 && !timer) {
|
|
last = Date.now();
|
|
fn();
|
|
} else if (!timer) {
|
|
timer = setTimeout(function () { timer = null; last = Date.now(); fn(); }, Math.max(wait, 0));
|
|
}
|
|
};
|
|
}
|
|
|
|
// Whether the user is in the middle of something a page reload would throw away
|
|
function isBusy() {
|
|
var active = document.activeElement;
|
|
if (active && active !== document.body && active.matches('input:not([type=checkbox]):not([type=radio]), textarea, select, [contenteditable]')) return true;
|
|
if (document.querySelector('.modal.show, .dropdown-menu.show, .offcanvas.show')) return true;
|
|
var selection = window.getSelection && window.getSelection();
|
|
return !!(selection && String(selection).length);
|
|
}
|
|
|
|
var SCROLL_KEY = 'live-scroll:' + window.location.pathname + window.location.search;
|
|
try {
|
|
var savedScroll = sessionStorage.getItem(SCROLL_KEY);
|
|
if (savedScroll !== null) {
|
|
sessionStorage.removeItem(SCROLL_KEY);
|
|
window.addEventListener('load', function () { window.scrollTo(0, parseInt(savedScroll, 10) || 0); });
|
|
}
|
|
} catch (e) { /* storage unavailable */ }
|
|
|
|
function reloadKeepingScroll() {
|
|
try { sessionStorage.setItem(SCROLL_KEY, String(window.scrollY)); } catch (e) { /* storage unavailable */ }
|
|
window.location.reload();
|
|
}
|
|
|
|
function showStaleBanner() {
|
|
if (document.getElementById('live-stale')) return;
|
|
var banner = document.createElement('div');
|
|
banner.id = 'live-stale';
|
|
banner.className = 'alert alert-info d-flex align-items-center gap-3 position-fixed bottom-0 start-50 translate-middle-x mb-3 shadow';
|
|
banner.setAttribute('role', 'status');
|
|
banner.style.zIndex = 1090;
|
|
var text = document.createElement('span');
|
|
text.textContent = 'This changed while you were editing.';
|
|
var button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'btn btn-sm btn-primary';
|
|
button.textContent = 'Refresh';
|
|
button.addEventListener('click', function () {
|
|
banner.remove();
|
|
if (window.Nav) Nav.refresh({ force: true }); else reloadKeepingScroll();
|
|
});
|
|
banner.append(text, button);
|
|
document.body.appendChild(banner);
|
|
}
|
|
|
|
// A countdown on every page while a server restart is scheduled
|
|
var restartTimer = null;
|
|
function showRestart(restart) {
|
|
var banner = document.getElementById('restart-banner');
|
|
clearInterval(restartTimer);
|
|
if (!restart || !restart.at) {
|
|
if (banner) banner.remove();
|
|
return;
|
|
}
|
|
if (!banner) {
|
|
banner = document.createElement('div');
|
|
banner.id = 'restart-banner';
|
|
banner.className = 'alert alert-warning d-flex align-items-center gap-2 mb-3';
|
|
banner.setAttribute('role', 'status');
|
|
var main = document.querySelector('main') || document.body;
|
|
main.insertBefore(banner, main.firstChild);
|
|
}
|
|
function tick() {
|
|
var left = Math.max(0, Math.round(restart.at - Date.now() / 1000));
|
|
var text = left >= 60 ? Math.floor(left / 60) + ' min ' + (left % 60) + ' s' : left + ' s';
|
|
banner.textContent = 'Server restart in ' + text + (restart.reason ? ': ' + restart.reason : '') + (restart.by ? ' (scheduled by ' + restart.by + ')' : '');
|
|
}
|
|
tick();
|
|
restartTimer = coreSetInterval(tick, 1000);
|
|
}
|
|
|
|
window.Live = {
|
|
// Receive a socket topic this page needs (subscribed now and after every reconnect)
|
|
onTopic: function (topic, cb) {
|
|
var first = !topicWatchers[topic];
|
|
(topicWatchers[topic] = topicWatchers[topic] || []).push(cb);
|
|
if (first) subscribe(topic);
|
|
},
|
|
// Call cb({table, id}) whenever the named table changes
|
|
on: function (table, cb) {
|
|
(tableWatchers[table] = tableWatchers[table] || []).push(cb);
|
|
},
|
|
// Reload a DataTable (keeping its page) whenever the named table changes
|
|
watchTable: function (table, dataTable) {
|
|
var generation = pageGeneration;
|
|
Live.on(table, throttle(function () { if (generation === pageGeneration) dataTable.ajax.reload(null, false); }, RELOAD_THROTTLE_MS));
|
|
},
|
|
/**
|
|
* For server-rendered pages about one row: update the page in place when that row changes in any of the given
|
|
* tables (or any row, with anyRow). Nav.refresh patches only what the server shows differently, so what is being
|
|
* typed stays; where it can't (the page would be rebuilt) and the user is busy, it offers a refresh instead.
|
|
* Without nav.js the page reloads, or offers a refresh while the user is busy.
|
|
*/
|
|
refreshPage: function (tables, id, options) {
|
|
options = options || {};
|
|
var generation = pageGeneration;
|
|
var refresh = throttle(function () {
|
|
if (generation !== pageGeneration) return;
|
|
if (window.Nav) Nav.refresh();
|
|
else if (isBusy()) showStaleBanner();
|
|
else reloadKeepingScroll();
|
|
}, PAGE_REFRESH_MIN_MS);
|
|
[].concat(tables).forEach(function (table) {
|
|
Live.on(table, function (e) {
|
|
if (e.resync || options.anyRow || String(e.id) === String(id)) refresh();
|
|
});
|
|
});
|
|
},
|
|
throttle: throttle,
|
|
isBusy: isBusy,
|
|
showStaleBanner: showStaleBanner,
|
|
onStatus: function (cb) { statusWatchers.push(cb); },
|
|
// Forget what the page registered (nav.js, when it swaps in another page). Topics only it asked for are dropped.
|
|
resetPage: function () {
|
|
pageGeneration++;
|
|
tableWatchers = {};
|
|
statusWatchers = [];
|
|
Object.keys(topicWatchers).forEach(function (topic) {
|
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ event: 'unsubscribe', subscription: topic }));
|
|
});
|
|
topicWatchers = {};
|
|
var stale = document.getElementById('live-stale');
|
|
if (stale) stale.remove();
|
|
},
|
|
// The sidebar's waiting counts again (nav.js, when the menu was rebuilt)
|
|
refreshBadges: function () {
|
|
if (DASH.can('moderate_names')) api.get('/api/moderation/counts').then(updateBadges).catch(function () {});
|
|
},
|
|
/**
|
|
* Wait for background work (a scan, a search) and fetch its result, including its data, which only the account
|
|
* that started it can read. Resolves with {success, message, data}. Keeps checking with a growing interval
|
|
* in case the socket is down.
|
|
*/
|
|
waitForResult: function (requestId) {
|
|
return new Promise(function (resolve) {
|
|
var done = false, delay = 1000;
|
|
function fetchResult() {
|
|
if (done) return;
|
|
api.get('/api/actions/' + requestId).then(function (r) {
|
|
if (r.status === 'done') { done = true; delete actionWaiters[requestId]; resolve(r); } else schedule();
|
|
}).catch(schedule);
|
|
}
|
|
function schedule() {
|
|
if (done) return;
|
|
setTimeout(fetchResult, delay);
|
|
delay = Math.min(delay * 1.5, 10000);
|
|
}
|
|
actionWaiters[requestId] = fetchResult;
|
|
schedule();
|
|
});
|
|
},
|
|
/**
|
|
* Wait for the result of an asynchronous player action (kick, rescue, ...). Falls back to polling
|
|
* /api/actions/:id if the socket is down. Resolves with {success, message, affected}.
|
|
*/
|
|
waitForAction: function (requestId) {
|
|
return new Promise(function (resolve) {
|
|
var done = false;
|
|
function finish(result) { if (!done) { done = true; resolve(result); } }
|
|
actionWaiters[requestId] = finish;
|
|
var polls = 0;
|
|
(function poll() {
|
|
if (done || polls++ > 15) return;
|
|
setTimeout(function () {
|
|
if (done) return;
|
|
api.get('/api/actions/' + requestId).then(function (r) {
|
|
if (r.status === 'done') { delete actionWaiters[requestId]; finish(r); } else poll();
|
|
}).catch(poll);
|
|
}, 1000);
|
|
})();
|
|
});
|
|
}
|
|
};
|
|
|
|
// Logout clears the HttpOnly cookie server-side
|
|
document.addEventListener('click', function (e) {
|
|
if (!e.target.closest('#logoutBtn')) return;
|
|
e.preventDefault();
|
|
api.post('/api/auth/logout').finally(function () { window.location.href = '/login'; });
|
|
});
|
|
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
var initial = document.querySelector('[data-initial]');
|
|
if (initial) initial.textContent = (document.body.dataset.username || '?').charAt(0).toUpperCase();
|
|
connect();
|
|
if (DASH.can('moderate_names')) api.get('/api/moderation/counts').then(updateBadges).catch(function () {});
|
|
// Staff who manage restarts also see who scheduled it; everyone else gets when and why from the status
|
|
if (DASH.can('server_restart')) api.get('/api/server/restart').then(function (r) { showRestart(r && r.at ? r : null); }).catch(function () {});
|
|
else api.get('/api/status').then(function (s) { showRestart(s.restart); }).catch(function () {});
|
|
});
|
|
})();
|