// ───── 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 `
${esc(resolveTags(m.text, thread))}
`; const mine = meId != null && m.senderId === meId; const who = mine ? meUser : m.sender; return `
${thread.isGroup && !mine ? `${esc(name(m.sender))}` : ''}
${esc(m.text)}
${timeAgo(m.at)}
`; }).join(''); if (atBottom || messages.length <= 20) body.scrollTop = body.scrollHeight; } async function openThread(id) { openId = id; listEl.classList.add('has-open'); convEl.classList.add('open'); const d = await api('thread/' + id); if (!d.ok) { convEl.innerHTML = `
Could not open that conversation.
`; return; } lastMessageId = d.messages.length ? d.messages[d.messages.length - 1].id : 0; convEl.innerHTML = `
${esc(threadTitle(d.thread))}
`; renderMessages(d.thread, d.messages); document.getElementById('msgBack').addEventListener('click', closeConv); const input = document.getElementById('msgInput'); const send = async () => { const text = input.value.trim(); if (!text) return; input.value = ''; input.style.height = 'auto'; const r = await api('thread/' + id + '/message', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }) }); // Don't bump lastMessageId here - pollOnce() below reads it to ask "what's newer than this?", so setting it to the // message we just sent would make that message look already-seen and it would never get rendered. if (r.ok) { pollOnce(d.thread); loadThreads(); } }; input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }); input.addEventListener('input', () => { input.style.height = 'auto'; input.style.height = Math.min(120, input.scrollHeight) + 'px'; }); document.getElementById('msgSend').addEventListener('click', send); loadThreads(); restartPoll(d.thread); } function closeConv() { openId = null; listEl.classList.remove('has-open'); convEl.classList.remove('open'); convEl.innerHTML = `
Pick a conversation, or start a new one.
`; clearTimeout(pollTimer); } async function pollOnce(thread) { if (!openId) return; const d = await api('thread/' + openId + '/since/' + lastMessageId); if (d.ok && d.messages.length) { lastMessageId = d.messages[d.messages.length - 1].id; const r = await api('thread/' + openId); if (r.ok) renderMessages(r.thread, r.messages); loadThreads(); } } function restartPoll(thread) { clearTimeout(pollTimer); const tick = () => { pollOnce(thread).finally(() => { if (openId) pollTimer = setTimeout(tick, 4000); }); }; pollTimer = setTimeout(tick, 4000); } // ---------------- new message ---------------- const panel = document.getElementById('msgNewPanel'); const toInput = document.getElementById('msgNewTo'); const suggestEl = document.getElementById('msgSuggest'); const chipsEl = document.getElementById('msgChips'); const nameInput = document.getElementById('msgNewName'); const nameLabel = document.getElementById('msgNewNameLabel'); const errEl = document.getElementById('msgNewErr'); function renderChips() { chipsEl.innerHTML = picked.map(u => `${esc(name(u))}`).join(''); chipsEl.querySelectorAll('button').forEach(b => b.addEventListener('click', () => { picked = picked.filter(u => u.id != b.dataset.id); renderChips(); toggleGroupName(); })); } function toggleGroupName() { const show = picked.length > 1; nameInput.hidden = !show; nameLabel.hidden = !show; } function pick(u) { if (picked.some(p => p.id === u.id)) return; picked.push(u); renderChips(); toggleGroupName(); toInput.value = ''; suggestEl.classList.remove('open'); suggestEl.innerHTML = ''; } let suggestTimer = null; toInput.addEventListener('input', () => { clearTimeout(suggestTimer); const q = toInput.value.trim(); if (!q) { suggestEl.classList.remove('open'); return; } suggestTimer = setTimeout(async () => { const d = await api('lookup?username=' + encodeURIComponent(q)); const rows = []; if (d.ok && d.user) rows.push(d.user); suggestEl.innerHTML = rows.length ? rows.map(u => `
${esc(name(u))} @${esc(u.username)}
`).join('') : `
No player found
`; suggestEl.classList.add('open'); suggestEl.querySelectorAll('.msg-suggest-row[data-id]').forEach(row => row.addEventListener('click', () => { const u = rows.find(r => String(r.id) === row.dataset.id); if (u) pick(u); })); }, 250); }); async function loadFriendsIntoSuggest() { const d = await api('friends'); if (!d.ok || !d.friends.length) return; suggestEl.innerHTML = d.friends.map(u => `
${esc(name(u))} @${esc(u.username)}
`).join(''); suggestEl.classList.add('open'); suggestEl.querySelectorAll('.msg-suggest-row[data-id]').forEach(row => row.addEventListener('click', () => { const u = d.friends.find(r => String(r.id) === row.dataset.id); if (u) pick(u); })); } document.getElementById('msgNewBtn').addEventListener('click', () => { picked = []; renderChips(); toggleGroupName(); toInput.value = ''; nameInput.value = ''; document.getElementById('msgNewText').value = ''; errEl.textContent = ''; panel.classList.add('open'); loadFriendsIntoSuggest(); }); document.getElementById('msgNewCancel').addEventListener('click', () => panel.classList.remove('open')); panel.addEventListener('click', e => { if (e.target === panel) panel.classList.remove('open'); }); document.getElementById('msgNewSend').addEventListener('click', async () => { if (!picked.length) { errEl.textContent = 'Add at least one person.'; return; } const btn = document.getElementById('msgNewSend'); btn.disabled = true; errEl.textContent = ''; const body = { Ids: picked.map(u => u.id), Name: nameInput.value.trim() || null, Message: document.getElementById('msgNewText').value.trim() || null }; const d = await api('thread', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); btn.disabled = false; if (!d.ok) { errEl.textContent = d.error || 'Could not start that conversation.'; return; } panel.classList.remove('open'); await loadThreads(); openThread(d.thread.id); }); // ---------------- boot ---------------- fetch('/api/site/me', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : null).then(d => { if (!d || !d.ok) return; meId = d.user.id; meUser = d.user; const open = document.getElementById('msgBody'); if (open && openId) openThread(openId); // repaint "mine" bubbles once we know who "me" is }).catch(() => {}); loadThreads(); listTimer = setInterval(() => { if (!document.hidden) loadThreads(); }, 15000); window.addEventListener('beforeunload', () => { clearInterval(listTimer); clearTimeout(pollTimer); }); })();