feat(dashboard): guild, team and whisper chat histories

Guild chat per guild (/chat_log/guild/<id>, from the guild's card), team chat by
team (/chat_log/teams) and a character's whispers by conversation
(/characters/<id>/whispers, chat_dms; opening one is audited), paged on the
server. The Chat Log filters by account and time range, shows the conversation
around a message, links to its history and marks flagged messages; messages can
be picked (shift-click for a range) to flag. The character page links to all of
them.

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

View File

@@ -0,0 +1,136 @@
/**
* Picking chat messages and flagging them for review, shared by the Chat Log and the chat histories (guild, team and
* whisper conversations). Each message row has a checkbox (ChatFlagging.checkbox); shift-click picks every message
* between the last one clicked and this one. The bar (ChatFlagging.bar) shows how many are picked and opens the flag
* dialog: who the flag is about (one of the senders) and a note. The server keeps a copy of the messages and the chat
* around them. Messages already flagged link to their flag (ChatFlagging.flagLink).
*/
window.ChatFlagging = (function () {
'use strict';
var CHANNEL_NEEDS = { whisper: 'chat_dms', team: 'chat_private', guild: 'chat_private' };
function flagLink(row) {
if (!row.flag_id) return '';
return ' <a class="badge text-bg-warning text-decoration-none" href="/chat_flags#flag=' + esc(row.flag_id) + '" title="In chat flag ' + esc(row.flag_id) + '">Flagged</a>';
}
// The message text, or why it can't be shown
function text(row) {
if (row.redacted) return '<span class="text-body-secondary fst-italic">Hidden: reading ' + esc(row.channel) + ' chat needs ' + esc(CHANNEL_NEEDS[row.channel] || 'another permission') + '</span>';
var badges = '';
if (row.blocked) badges += fmt.badge('Stopped by the filter', 'danger') + ' ';
else if (row.filtered) badges += fmt.badge('Filter words', 'warning') + ' ';
return badges + '<span class="' + (row.blocked ? 'text-body-secondary' : '') + '">' + esc(row.message) + '</span>';
}
function checkbox(row) {
if (!DASH.can('chat_flag') || row.redacted || !row.id) return '';
return '<input class="form-check-input" type="checkbox" data-flag-pick="' + esc(row.id) + '" aria-label="Pick this message to flag">';
}
/**
* Picking in `container` (an element, or a selector), with the bar in `barEl`. onFlagged(id) runs after a flag
* is made.
*/
function create(container, barEl, onFlagged) {
var root = typeof container === 'string' ? document.querySelector(container) : container;
var bar = typeof barEl === 'string' ? document.querySelector(barEl) : barEl;
var rows = {}; // id -> message, for every message shown
var picked = {}; // id -> true
var lastClicked = null;
function count() { return Object.keys(picked).length; }
function renderBar() {
if (!bar) return;
var n = count();
bar.classList.toggle('d-none', !DASH.can('chat_flag'));
bar.innerHTML = '<span class="small text-body-secondary">' + (n ? n + ' message' + (n === 1 ? '' : 's') + ' picked' :
'Tick messages to flag them for review (shift-click picks a range).') + '</span>' +
(n ? ' <button type="button" class="btn btn-sm btn-warning" data-flag-open>Flag&hellip;</button> <button type="button" class="btn btn-sm btn-outline-secondary" data-flag-clear>Clear</button>' : '');
}
function sync() {
root.querySelectorAll('[data-flag-pick]').forEach(function (box) { box.checked = !!picked[box.getAttribute('data-flag-pick')]; });
renderBar();
}
function clear() { picked = {}; lastClicked = null; sync(); }
root.addEventListener('click', function (e) {
var box = e.target.closest('[data-flag-pick]');
if (!box) return;
var id = box.getAttribute('data-flag-pick');
var on = box.checked;
if (e.shiftKey && lastClicked !== null) {
var boxes = Array.prototype.slice.call(root.querySelectorAll('[data-flag-pick]'));
var ids = boxes.map(function (b) { return b.getAttribute('data-flag-pick'); });
var a = ids.indexOf(lastClicked), b = ids.indexOf(id);
if (a !== -1 && b !== -1) {
for (var i = Math.min(a, b); i <= Math.max(a, b); i++) {
if (on) picked[ids[i]] = true; else delete picked[ids[i]];
}
}
} else if (on) picked[id] = true;
else delete picked[id];
lastClicked = id;
sync();
});
if (bar) bar.addEventListener('click', function (e) {
if (e.target.closest('[data-flag-clear]')) return clear();
if (e.target.closest('[data-flag-open]')) openDialog();
});
function openDialog() {
var ids = Object.keys(picked);
var messages = ids.map(function (id) { return rows[id]; }).filter(Boolean).sort(function (a, b) { return a.id - b.id; });
var senders = [];
messages.forEach(function (m) {
if (m.sender_id !== '0' && !senders.some(function (s) { return s.id === m.sender_id; })) senders.push({ id: m.sender_id, name: m.sender_name });
});
var modalEl = document.createElement('div');
modalEl.className = 'modal fade';
modalEl.tabIndex = -1;
modalEl.innerHTML = '<div class="modal-dialog modal-lg"><form class="modal-content">' +
'<div class="modal-header"><h5 class="modal-title">Flag ' + ids.length + ' message' + (ids.length === 1 ? '' : 's') + '</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div>' +
'<div class="modal-body">' +
'<div class="border rounded p-2 mb-3 small" style="max-height: 14rem; overflow-y: auto">' + messages.map(function (m) {
return '<div><span class="text-body-secondary">' + esc(fmt.unix(m.time)) + '</span> <strong>' + esc(m.sender_name) + '</strong>' +
(m.recipient_name ? ' &rarr; ' + esc(m.recipient_name) : '') + ': ' + esc(m.message) + '</div>';
}).join('') + '</div>' +
(senders.length ? '<div class="mb-3"><label class="form-label" for="flagAbout">About</label><select class="form-select" id="flagAbout">' +
senders.map(function (s) { return '<option value="' + esc(s.id) + '">' + esc(s.name) + '</option>'; }).join('') + '</select></div>' : '') +
'<div><label class="form-label" for="flagNote">Note</label><textarea class="form-control" id="flagNote" rows="3" maxlength="2000" placeholder="What is wrong here, for whoever reviews it"></textarea>' +
'<div class="form-text">The flag keeps a copy of these messages and the chat around them, even after the chat log is pruned. The messages must be from one conversation.</div></div>' +
'</div>' +
'<div class="modal-footer"><button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button><button type="submit" class="btn btn-warning">Flag</button></div>' +
'</form></div>';
document.body.appendChild(modalEl);
var modal = new bootstrap.Modal(modalEl);
modalEl.addEventListener('hidden.bs.modal', function () { modal.dispose(); modalEl.remove(); });
modalEl.querySelector('form').addEventListener('submit', function (e) {
e.preventDefault();
var about = modalEl.querySelector('#flagAbout');
api.action('/api/chat/flags', { message_ids: ids, character: about ? about.value : '', note: modalEl.querySelector('#flagNote').value }, 'Flagged for review').then(function (d) {
modal.hide();
clear();
if (onFlagged) onFlagged(d.id);
}).catch(function () {});
});
modal.show();
}
renderBar();
return {
// Remember the messages shown (for the dialog); call before or after rendering them
remember: function (messages) { messages.forEach(function (m) { rows[m.id] = m; }); },
// After the rows were drawn again: tick what's picked
sync: sync,
clear: clear
};
}
return { create: create, checkbox: checkbox, flagLink: flagLink, text: text };
})();

View File

@@ -0,0 +1,141 @@
/**
* The chat histories (chat_history.jinja2): one guild's chat (mode guild), team chat by team (mode teams) and one
* character's whispers by conversation (mode whispers). Conversations load newest page first; Older loads the page
* before. #team=<id> and #with=<character id> open that team or conversation (links from the Chat Log).
*/
(function () {
'use strict';
var page = document.getElementById('chatHistoryPage');
var mode = page.dataset.mode;
var id = page.dataset.id;
var PAGE = 100, LIST_PAGE = 50;
var nf = new Intl.NumberFormat();
var rows = document.getElementById('threadRows');
var flagging = ChatFlagging.create(document.getElementById('threadTable'), document.getElementById('flagBar'), function () { reloadThread(); });
var thread = null; // {url, oldest, messages}
var list = { offset: 0, items: [], selected: null };
function hashValue(name) { var m = location.hash.match(new RegExp('[#&]' + name + '=([^&]*)')); return m ? decodeURIComponent(m[1]) : ''; }
// ---- the conversation ----
function line(m) {
var where = m.zone_id ? '<div class="small text-body-secondary">' + esc(m.zone_name || ('Zone ' + m.zone_id)) + '</div>' : '';
return '<tr class="chat-line"><td style="width: 1.5rem">' + ChatFlagging.checkbox(m) + '</td>' +
'<td class="when">' + esc(fmt.unix(m.time)) + '</td>' +
'<td class="who">' + (m.sender_id !== '0' ? fmt.character(m.sender_id, m.sender_name) : esc(m.sender_name)) + where + '</td>' +
'<td>' + ChatFlagging.text(m) + ChatFlagging.flagLink(m) + '</td>' +
'<td class="text-end">' + (m.account_id ? '<a class="btn btn-sm btn-outline-secondary" href="/accounts/' + esc(m.account_id) + '">Account</a>' : '') + '</td></tr>';
}
function render() {
if (!thread) return;
rows.innerHTML = thread.messages.length ? thread.messages.map(line).join('')
: '<tr><td class="text-body-secondary">No messages (chat is kept for log_chat_days).</td></tr>';
document.getElementById('olderBox').classList.toggle('d-none', !thread.more);
flagging.sync();
}
function load(url, older) {
var query = '?limit=' + PAGE + (older && thread && thread.messages.length ? '&before=' + thread.messages[0].id : '');
return api.get(url + query).then(function (d) {
if (!d.success) { rows.innerHTML = '<tr><td class="text-danger">' + esc(d.error || 'Could not load the chat') + '</td></tr>'; return d; }
flagging.remember(d.messages);
if (older && thread && thread.url === url) thread.messages = d.messages.concat(thread.messages);
else thread = { url: url, messages: d.messages };
thread.more = d.has_more;
render();
if (!older) window.scrollTo({ top: document.getElementById('threadTable').getBoundingClientRect().bottom + window.scrollY - window.innerHeight + 80 });
return d;
});
}
function reloadThread() { if (thread) load(thread.url, false); }
document.getElementById('older').addEventListener('click', function () { if (thread) load(thread.url, true); });
function setLinks(html) { document.getElementById('threadLinks').innerHTML = html; }
// ---- guild ----
if (mode === 'guild') {
load('/api/chat/guild/' + encodeURIComponent(id), false).then(function (d) {
if (!d || !d.success) return;
document.getElementById('threadTitle').textContent = d.guild.exists ? d.guild.name : 'Guild ' + id + ' (disbanded)';
setLinks(d.guild.exists && DASH.can('guilds_manage') ? '<a class="btn btn-sm btn-outline-secondary" href="/guilds#guild=' + esc(id) + '">Members and history</a>' : '');
}).catch(function () {});
return;
}
// ---- teams and whisper conversations: the list on the left ----
var listEl = document.getElementById('threadList');
function itemHtml(item) {
var active = list.selected === item.key;
return '<a class="list-group-item list-group-item-action' + (active ? ' active' : '') + '" data-key="' + esc(item.key) + '">' +
'<div class="d-flex justify-content-between align-items-center gap-2"><span class="fw-semibold text-truncate">' + esc(item.title) + '</span>' +
'<span class="badge rounded-pill text-bg-secondary" title="Messages">' + nf.format(item.messages) + '</span></div>' +
'<div class="meta">' + esc(item.meta) + '</div></a>';
}
function renderList(total) {
listEl.innerHTML = list.items.length ? list.items.map(itemHtml).join('') : '<div class="list-group-item small text-body-secondary">None found.</div>';
document.getElementById('listCount').textContent = nf.format(total);
document.getElementById('listMoreBox').classList.toggle('d-none', list.items.length >= total);
}
function span(first, last) { return fmt.unix(first) + (last !== first ? ' to ' + fmt.unix(last) : ''); }
function loadList(more) {
if (!more) { list.offset = 0; list.items = []; }
var url = mode === 'teams'
? '/api/chat/teams?limit=' + LIST_PAGE + '&offset=' + list.offset + '&character=' + encodeURIComponent(document.getElementById('teamCharacter').value.trim())
: '/api/characters/' + encodeURIComponent(id) + '/whispers?limit=' + LIST_PAGE + '&offset=' + list.offset;
return api.get(url).then(function (d) {
if (!d.success) { listEl.innerHTML = '<div class="list-group-item small text-danger">' + esc(d.error || 'Could not load') + '</div>'; return; }
var items = mode === 'teams'
? d.teams.map(function (t) { return { key: t.team_id, title: t.senders.join(', ') || 'Team', messages: t.messages, meta: span(t.first_time, t.last_time), team: t }; })
: d.partners.map(function (p) { return { key: p.character_id, title: p.name || p.character_id, messages: p.messages, meta: span(p.first_time, p.last_time), partner: p }; });
list.items = list.items.concat(items);
list.offset += items.length;
renderList(d.total);
});
}
function open(key) {
list.selected = key;
listEl.querySelectorAll('[data-key]').forEach(function (el) { el.classList.toggle('active', el.getAttribute('data-key') === key); });
var item = list.items.filter(function (i) { return i.key === key; })[0];
flagging.clear();
try { history.replaceState(null, '', '#' + (mode === 'teams' ? 'team' : 'with') + '=' + key); } catch (e) {}
if (mode === 'teams') {
document.getElementById('threadTitle').textContent = item ? 'Team: ' + item.title : 'Team ' + key;
setLinks('');
return load('/api/chat/team/' + encodeURIComponent(key), false).catch(function () {});
}
var name = item ? item.title : key;
document.getElementById('threadTitle').textContent = 'With ' + name;
setLinks('<a class="btn btn-sm btn-outline-secondary" href="/characters/' + esc(key) + '">' + esc(name) + '</a>' +
'<a class="btn btn-sm btn-outline-secondary" href="/characters/' + esc(key) + '/whispers">Their whispers</a>');
return load('/api/characters/' + encodeURIComponent(id) + '/whispers/' + encodeURIComponent(key), false).catch(function () {});
}
listEl.addEventListener('click', function (e) {
var item = e.target.closest('[data-key]');
if (item) open(item.getAttribute('data-key'));
});
document.getElementById('listMore').addEventListener('click', function () { loadList(true).catch(function () {}); });
var teamCharacter = document.getElementById('teamCharacter');
if (teamCharacter && hashValue('character')) teamCharacter.value = hashValue('character');
if (teamCharacter) teamCharacter.addEventListener('change', function () { loadList(false).catch(function () {}); });
var start = hashValue(mode === 'teams' ? 'team' : 'with');
loadList(false).then(function () {
if (start) open(start);
// Whispers are only opened on purpose: each one opened is audited
else if (list.items.length && mode === 'teams') open(list.items[0].key);
}).catch(function () {});
})();

View File

@@ -1,21 +1,33 @@
/**
* The Chat Log page: the chat table, and a list of the world servers running now. Picking one narrows the chat to that
* server and shows who's in it, how busy its chat is, and what can be done there (say something, shut it down).
* The selection is kept in the address (#world=1200:2) so it can be linked to, e.g. from the home page.
* The selection is kept in the address (#world=1200:2) so it can be linked to, e.g. from the home page; #character=,
* #account= and #search= fill in those filters (links from character and account pages).
* Messages can be picked and flagged for review (chat-flagging.js); Context shows the conversation around a message.
*/
(function () {
'use strict';
var page = document.getElementById('chatLogPage');
var can = { send: !!page.dataset.canSend, players: !!page.dataset.canPlayers, worlds: !!page.dataset.canWorlds };
var CHANNELS = { zone: ['Zone', 'secondary'], whisper: ['Whisper', 'info'], team: ['Team', 'primary'], web: ['Web', 'warning'] };
var CHANNELS = { zone: ['Zone', 'secondary'], whisper: ['Whisper', 'info'], team: ['Team', 'primary'], guild: ['Guild', 'success'], web: ['Web', 'warning'] };
var nf = new Intl.NumberFormat();
var state = {
worlds: [],
selected: null, // {zone, instance} or null for every world
filters: { channel: '', character: '', blocked: false }
filters: { channel: '', character: '', account: '', since: 0, until: 0, blocked: false }
};
var flagging = ChatFlagging.create('#chatTable', '#flagBar', function () { table.ajax.reload(null, false); });
// Where the rest of a message's conversation is: the whisper thread, the team or the guild
function conversationLink(row) {
if (row.redacted) return '';
if (row.channel === 'whisper' && DASH.can('chat_dms') && row.sender_id !== '0' && row.recipient_id !== '0') return '/characters/' + row.sender_id + '/whispers#with=' + row.recipient_id;
if (row.channel === 'team' && DASH.can('chat_private') && row.team_id !== '0') return '/chat_log/teams#team=' + row.team_id;
if (row.channel === 'guild' && DASH.can('chat_private') && row.guild_id !== '0') return '/chat_log/guild/' + row.guild_id;
return '';
}
function worldKey(w) { return w.mapID + ':' + w.instanceID; }
function selectedWorld() {
@@ -26,6 +38,7 @@
// ---- the chat table ----
var table = serverTable('#chatTable', '/api/tables/chat_log', [
{ data: 'id', orderable: false, render: function (d, t, row) { return ChatFlagging.checkbox(row); } },
{ data: 'time', orderable: false, render: function (d) { return esc(fmt.unix(d)); } },
{ data: 'channel', orderable: false, render: function (d) { var c = CHANNELS[d] || [d, 'secondary']; return fmt.badge(c[0], c[1]); } },
{ data: 'zone_id', orderable: false, render: function (d, t, row) {
@@ -34,19 +47,26 @@
} },
{ data: 'sender_name', orderable: false, render: function (d, t, row) { return row.sender_id !== '0' ? fmt.character(row.sender_id, d) : esc(d); } },
{ data: 'recipient_name', orderable: false, render: function (d, t, row) { return row.recipient_id !== '0' ? fmt.character(row.recipient_id, d) : ''; } },
{ data: 'message', orderable: false, render: function (d, t, row) {
return (row.blocked ? fmt.badge('Stopped by the filter', 'danger') + ' ' : '') + '<span class="' + (row.blocked ? 'text-body-secondary' : '') + '">' + esc(d) + '</span>';
} },
// The sender's account: mute, warn or ban from there
{ data: 'message', orderable: false, render: function (d, t, row) { return ChatFlagging.text(row) + ChatFlagging.flagLink(row); } },
// The conversation, and the sender's account: mute, warn or ban from there
{ data: 'account_id', orderable: false, render: function (d, t, row) {
var suggest = row.sender_id !== '0' && row.channel !== 'web' ? AiSuggest.button('chat_message', row.id) : '';
return '<div class="d-flex justify-content-end gap-1">' + (d ? '<a class="btn btn-sm btn-outline-secondary text-nowrap" href="/accounts/' + esc(d) + '">Account</a>' : '') + suggest + '</div>';
var thread = conversationLink(row);
return '<div class="d-flex justify-content-end gap-1">' +
(row.redacted ? '' : '<button type="button" class="btn btn-sm btn-outline-secondary text-nowrap" data-context="' + esc(row.id) + '">Context</button>') +
(thread ? '<a class="btn btn-sm btn-outline-secondary text-nowrap" href="' + esc(thread) + '">Conversation</a>' : '') +
(d ? '<a class="btn btn-sm btn-outline-secondary text-nowrap" href="/accounts/' + esc(d) + '">Account</a>' : '') + suggest + '</div>';
} }
], {
liveTable: 'chat',
dataTable: { language: { searchPlaceholder: 'Message or name' } },
dataTable: {
language: { searchPlaceholder: 'Message or name' },
order: [],
drawCallback: function () { flagging.sync(); }
},
extra: function () {
var body = { channel: state.filters.channel, character: state.filters.character, blocked: state.filters.blocked };
var body = { channel: state.filters.channel, character: state.filters.character, account: state.filters.account,
since: state.filters.since, until: state.filters.until, blocked: state.filters.blocked };
if (state.selected) { body.zone = state.selected.zone; body.instance = state.selected.instance; }
return body;
}
@@ -152,6 +172,47 @@
document.getElementById('chatChannel').addEventListener('change', function () { state.filters.channel = this.value; table.ajax.reload(); });
document.getElementById('chatCharacter').addEventListener('change', function () { state.filters.character = this.value.trim(); table.ajax.reload(); });
document.getElementById('chatBlocked').addEventListener('change', function () { state.filters.blocked = this.checked; table.ajax.reload(); });
document.getElementById('chatAccount').addEventListener('change', function () { state.filters.account = this.value.trim(); table.ajax.reload(); });
// datetime-local is the viewer's local time; the server takes unix seconds
function unixOf(value) { var t = value ? new Date(value).getTime() : NaN; return isNaN(t) ? 0 : Math.floor(t / 1000); }
document.getElementById('chatSince').addEventListener('change', function () { state.filters.since = unixOf(this.value); table.ajax.reload(); });
document.getElementById('chatUntil').addEventListener('change', function () { state.filters.until = unixOf(this.value); table.ajax.reload(); });
$('#chatTable').on('xhr.dt', function (e, settings, json) { if (json && json.data) flagging.remember(json.data); });
// ---- the conversation around a message ----
document.getElementById('chatTable').addEventListener('click', function (e) {
var button = e.target.closest('[data-context]');
if (button) showContext(button.getAttribute('data-context'));
});
function showContext(id) {
api.get('/api/chat/messages/' + encodeURIComponent(id) + '/context?count=15').then(function (d) {
if (!d.success) { toast(d.error || 'Could not load the conversation', 'danger'); return; }
var modalEl = document.createElement('div');
modalEl.className = 'modal fade';
modalEl.tabIndex = -1;
var thread = conversationLink(d.message);
modalEl.innerHTML = '<div class="modal-dialog modal-xl modal-dialog-scrollable"><div class="modal-content">' +
'<div class="modal-header"><h5 class="modal-title">Conversation around a ' + esc((CHANNELS[d.message.channel] || [d.message.channel])[0].toLowerCase()) + ' message</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div>' +
'<div class="modal-body"><div class="d-flex flex-wrap gap-2 align-items-center mb-2" data-context-bar></div>' +
'<table class="table table-sm align-middle mb-0" data-context-table><tbody>' + d.messages.map(function (m) {
return '<tr class="' + (String(m.id) === String(id) ? 'table-warning' : '') + '"><td style="width: 1.5rem">' + ChatFlagging.checkbox(m) + '</td>' +
'<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>' + ChatFlagging.text(m) + ChatFlagging.flagLink(m) + '</td></tr>';
}).join('') + '</tbody></table></div>' +
'<div class="modal-footer">' + (thread ? '<a class="btn btn-outline-secondary" href="' + esc(thread) + '">Whole conversation</a>' : '') +
'<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button></div></div></div>';
document.body.appendChild(modalEl);
var modal = new bootstrap.Modal(modalEl);
var picker = ChatFlagging.create(modalEl.querySelector('[data-context-table]'), modalEl.querySelector('[data-context-bar]'), function () { modal.hide(); table.ajax.reload(null, false); });
picker.remember(d.messages);
modalEl.addEventListener('hidden.bs.modal', function () { modal.dispose(); modalEl.remove(); });
modalEl.addEventListener('click', function (e) { if (e.target.closest('a[href]')) modal.hide(); });
modal.show();
}).catch(function () {});
}
document.getElementById('worldActions').addEventListener('click', function (e) {
if (!e.target.closest('#worldShutdown') || !state.selected) return;
var world = selectedWorld();
@@ -173,6 +234,10 @@
}
setInterval(loadWorlds, 15000);
var hashValue = function (name) { var m = location.hash.match(new RegExp('[#&]' + name + '=([^&]*)')); return m ? decodeURIComponent(m[1]) : ''; };
if (hashValue('character')) { state.filters.character = hashValue('character'); document.getElementById('chatCharacter').value = state.filters.character; }
if (hashValue('account')) { state.filters.account = hashValue('account'); document.getElementById('chatAccount').value = state.filters.account; }
if (state.filters.character || state.filters.account) table.ajax.reload();
var fromHash = (location.hash.match(/world=(\d+:\d+)/) || [])[1];
if (fromHash) { var p = fromHash.split(':'); state.selected = { zone: Number(p[0]), instance: Number(p[1]) }; }
loadWorlds().then(function () { if (state.selected) table.ajax.reload(); });