// ───── MESSAGES PAGE (/messages) ─────
// DMs and group chats, backed by SiteChatController -> the same ChatDB the game client uses. A message sent here shows up
// in-game (ChatDB.Push notifies the game's hub) and a message sent in-game shows up here on the next poll.
(function () {
const threadsEl = document.getElementById('msgThreads');
if (!threadsEl) return;
const convEl = document.getElementById('msgConv');
const placeholderEl = document.getElementById('msgPlaceholder');
const listEl = document.getElementById('msgList');
let openId = null;
let lastMessageId = 0;
let pollTimer = null;
let listTimer = null;
let picked = []; // [{id, username, display_name, profile_image}]
let meId = null; // fetched fresh, not read from app.js's module-scoped CAMPUS_ME
let meUser = null; // same shape as a thread member (id/username/display_name/profile_image), for the avatar on my own bubbles
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
// profile_image comes back as either a ready /img/ URL or a bare filename (same shape /api/site/profile/{u} uses - see app.js's myPfpUrl).
function avatar(u) {
const raw = u && u.profile_image;
if (raw) return /^https?:|^\//.test(raw) ? raw : (((window.CAMPUS_DATA && window.CAMPUS_DATA.imgBase) || '/img/') + raw);
return `https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent((u && u.username) || 'player')}`;
}
function name(u) { return (u && (u.display_name || u.username)) || 'Player'; }
function timeAgo(iso) {
if (!iso) return '';
const ms = Date.now() - new Date(iso).getTime();
const m = Math.floor(ms / 60000);
if (m < 1) return 'now';
if (m < 60) return m + 'm';
const h = Math.floor(m / 60);
if (h < 24) return h + 'h';
const d = Math.floor(h / 24);
if (d < 7) return d + 'd';
return new Date(iso).toLocaleDateString();
}
async function api(path, opts) {
const r = await fetch('/api/site/chat/' + path, Object.assign({ credentials: 'same-origin' }, opts));
if (r.status === 401) { location.href = '/login'; throw new Error('signed out'); }
const d = await r.json().catch(() => ({ ok: false }));
return d;
}
function threadTitle(t) {
if (t.isGroup) return t.name || t.members.map(name).join(', ');
return t.members.length ? name(t.members[0]) : 'Player';
}
// ---------------- thread list ----------------
async function loadThreads(keepScroll) {
const d = await api('thread');
if (!d.ok) { threadsEl.innerHTML = '
Could not load your messages.
'; return; }
if (!d.threads.length) { threadsEl.innerHTML = '
No conversations yet. Click "New message" to start one.
'; return; }
threadsEl.innerHTML = d.threads.map(t => {
const m = t.members[0];
const second = t.isGroup && t.members[1] ? `` : '';
return ``;
}).join('');
threadsEl.querySelectorAll('.msg-thread').forEach(b => b.addEventListener('click', () => openThread(Number(b.dataset.id))));
}
// ---------------- conversation ----------------
// System notices carry raw "Player <@U123>" tags (the format the game client itself resolves, e.g. "Player <@U6> left") -
// replace them with a name using whoever is in this thread, so the website doesn't leak the internal tag to a reader.
function resolveTags(text, thread) {
const who = idStr => { const id = Number(idStr); if (id === meId) return 'You'; const u = thread.members.find(m => m.id === id); return u ? name(u) : 'A player'; };
return text.replace(/Player <@U(\d+)>/g, (whole, idStr) => who(idStr)) // "Player <@U6> left" -> "You left" / "Alice left"
.replace(/<@U(\d+)>/g, (whole, idStr) => who(idStr)); // any tag not preceded by "Player " on its own
}
function renderMessages(thread, messages) {
const body = document.getElementById('msgBody');
if (!body) return;
const atBottom = body.scrollHeight - body.scrollTop - body.clientHeight < 60;
body.innerHTML = messages.map(m => {
if (m.system) return `