import { api, toast } from '/static/js/api.js'; import { subscribe, send as wsSend, onPollFallback } from '/static/js/ws.js'; const errorBox = document.getElementById('error'); const convList = document.getElementById('conv-list'); const msgHost = document.getElementById('chat-messages'); const form = document.getElementById('chat-form'); const composer = document.getElementById('chat-composer'); let meId = ''; let conversations = []; let current = null; let oldestCursor = ''; let pendingAttachments = []; const userCache = new Map(); const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[c])); async function userName(id) { if (!userCache.has(id)) { try { const res = await api('GET', `/api/v1/users/${id}/card`); userCache.set(id, res.card.name); } catch (e) { userCache.set(id, '…'); } } return userCache.get(id); } async function loadConversations() { try { const res = await api('GET', '/api/v1/conversations'); conversations = res.conversations; renderConvList(); const want = new URLSearchParams(location.search).get('c'); if (!current && want) { const c = conversations.find((x) => x.id === want); if (c) openConversation(c); } } catch (e) { errorBox.textContent = e.message; } } function renderConvList() { convList.replaceChildren(...conversations.map((c) => { const li = document.createElement('li'); li.className = current && current.id === c.id ? 'active' : ''; li.innerHTML = ` `; li.querySelector('button').addEventListener('click', () => openConversation(c)); return li; })); if (!conversations.length) { convList.innerHTML = '
  • No conversations yet.
  • '; } } async function openConversation(c) { current = c; oldestCursor = ''; noMoreOlder = false; document.getElementById('chat-title').textContent = c.title; const mbtn = document.getElementById('chat-members'); mbtn.hidden = !(c.kind !== 'dm' && c.creatorId && c.creatorId === meId); form.hidden = false; msgHost.replaceChildren(); renderConvList(); await loadMessages(false); await api('POST', `/api/v1/conversations/${c.id}/read`, {}).catch(() => {}); c.unread = 0; renderConvList(); history.replaceState(null, '', '/messages?c=' + c.id); } const MSG_PAGE = 40; async function loadMessages(prepend) { const res = await api('GET', `/api/v1/conversations/${current.id}/messages?limit=${MSG_PAGE}&cursor=${prepend ? oldestCursor : ''}`); const msgs = res.messages; if (prepend && msgs.length < MSG_PAGE) noMoreOlder = true; if (msgs.length) oldestCursor = msgs[0].id; const nodes = await Promise.all(msgs.map(renderMessage)); if (prepend) { // preserve the scroll position so the view doesn't jump when older // messages are inserted above the current viewport const before = msgHost.scrollHeight; const top = msgHost.scrollTop; msgHost.prepend(...nodes); msgHost.scrollTop = top + (msgHost.scrollHeight - before); } else { msgHost.append(...nodes); msgHost.scrollTop = msgHost.scrollHeight; } } // load older history when the user scrolls near the top let loadingOlder = false; let noMoreOlder = false; msgHost.addEventListener('scroll', async () => { if (msgHost.scrollTop > 60 || loadingOlder || noMoreOlder || !current) return; loadingOlder = true; try { await loadMessages(true); } finally { loadingOlder = false; } }); // ULID ids embed a 48-bit ms timestamp in the first 10 chars. function ulidTime(id) { const A = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; let ms = 0; for (const ch of String(id).slice(0, 10).toUpperCase()) ms = ms * 32 + A.indexOf(ch); return new Date(ms); } async function renderMessage(m) { const div = document.createElement('div'); div.className = 'chat-msg' + (m.senderId === meId ? ' own' : ''); const atts = (m.attachments || []).map((a) => a.isImage ? `${esc(a.name)}` : `📄 ${esc(a.name)}`).join(' '); div.innerHTML = `

    ${esc(await userName(m.senderId))} · ${ulidTime(m.id).toLocaleString()}

    ${m.body || ''}
    ${atts ? `
    ${atts}
    ` : ''}`; div.querySelectorAll('[data-lightbox]').forEach((img) => { img.addEventListener('click', () => { document.getElementById('lightbox-img').src = img.src; document.getElementById('lightbox').showModal(); }); }); return div; } document.getElementById('lightbox').addEventListener('click', () => { document.getElementById('lightbox').close(); }); // ---- composer ---- document.querySelectorAll('[data-cmd]').forEach((btn) => { btn.addEventListener('click', () => { composer.focus(); document.execCommand(btn.dataset.cmd, false, null); }); }); composer.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); } }); let typingTimer = null; composer.addEventListener('input', () => { if (!current) return; clearTimeout(typingTimer); typingTimer = setTimeout(() => { wsSend('chat', 'typing', { conversationId: current.id }); }, 250); }); async function uploadFiles(fileList) { for (const file of fileList) { const fd = new FormData(); fd.append('file', file); try { const res = await api('POST', '/api/v1/files', fd); pendingAttachments.push(res); renderPending(); } catch (e) { toast(`Upload failed: ${e.message}`, 'err'); } } } function renderPending() { const host = document.getElementById('chat-attachments'); host.replaceChildren(...pendingAttachments.map((a, i) => { const chip = document.createElement('span'); chip.className = 'badge'; chip.innerHTML = `${a.isImage ? '🖼' : '📄'} ${esc(a.name)} `; chip.querySelector('button').addEventListener('click', () => { pendingAttachments.splice(i, 1); renderPending(); }); return chip; })); } document.getElementById('chat-file').addEventListener('change', (e) => { uploadFiles(e.target.files); e.target.value = ''; }); composer.addEventListener('dragover', (e) => e.preventDefault()); composer.addEventListener('drop', (e) => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files); }); form.addEventListener('submit', async (e) => { e.preventDefault(); if (!current) return; const body = composer.innerHTML; if (!composer.textContent.trim() && !pendingAttachments.length) return; try { await api('POST', `/api/v1/conversations/${current.id}/messages`, { body, attachments: pendingAttachments.map((a) => a.fileId), }); composer.innerHTML = ''; pendingAttachments = []; renderPending(); } catch (err) { toast(err.message, 'err'); } }); // ---- live events ---- const typingNames = new Map(); subscribe('chat', async (event, data) => { if (event === 'message' && data) { if (current && data.conversationId === current.id) { const nearBottom = msgHost.scrollHeight - msgHost.scrollTop - msgHost.clientHeight < 120; msgHost.appendChild(await renderMessage(data)); if (nearBottom) msgHost.scrollTop = msgHost.scrollHeight; api('POST', `/api/v1/conversations/${current.id}/read`, {}).catch(() => {}); } else { loadConversations(); } } if (event === 'typing' && data && current && data.conversationId === current.id) { const name = await userName(data.userId); typingNames.set(data.userId, Date.now()); document.getElementById('typing-indicator').textContent = `${name} is typing…`; setTimeout(() => { if (Date.now() - (typingNames.get(data.userId) || 0) >= 2900) { document.getElementById('typing-indicator').textContent = ''; } }, 3000); } }); onPollFallback(() => { loadConversations(); if (current) { msgHost.replaceChildren(); oldestCursor = ''; loadMessages(false); } }); // ---- new conversation dialog ---- const dlg = document.getElementById('newconv-dialog'); const selected = new Map(); document.getElementById('conv-new').addEventListener('click', () => { selected.clear(); renderSelected(); document.getElementById('nc-search').value = ''; dlg.showModal(); searchPeople(); // pre-fill the fixed-height list so the dialog never jumps }); document.getElementById('nc-cancel').addEventListener('click', () => dlg.close()); document.getElementById('nc-kind').addEventListener('change', (e) => { document.getElementById('nc-title-wrap').hidden = e.target.value === 'dm'; document.getElementById('nc-customer-wrap').hidden = e.target.value !== 'project'; }); let searchTimer = null; async function searchPeople() { const q = document.getElementById('nc-search').value.trim(); const res = await api('GET', `/api/v1/users?q=${encodeURIComponent(q)}`); const host = document.getElementById('nc-results'); const rows = res.users.filter((u) => u.id !== meId && !selected.has(u.id)).map((u) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'nc-row'; b.setAttribute('role', 'option'); b.textContent = `${u.name} — ${u.email}`; b.addEventListener('click', () => { selected.set(u.id, u); renderSelected(); searchPeople(); // refresh list so picked people disappear }); return b; }); host.replaceChildren(...rows); if (!rows.length) { host.innerHTML = '

    No matches — try a name or email.

    '; } } document.getElementById('nc-search').addEventListener('input', () => { clearTimeout(searchTimer); searchTimer = setTimeout(searchPeople, 250); }); function renderSelected() { const host = document.getElementById('nc-selected'); host.replaceChildren(...[...selected.values()].map((u) => { const chip = document.createElement('span'); chip.className = 'badge'; chip.textContent = u.name + ' ×'; chip.style.cursor = 'pointer'; chip.addEventListener('click', () => { selected.delete(u.id); renderSelected(); }); return chip; })); } document.getElementById('newconv-form').addEventListener('submit', async (e) => { e.preventDefault(); try { const kind = document.getElementById('nc-kind').value; const res = await api('POST', '/api/v1/conversations', { kind, title: document.getElementById('nc-title').value.trim(), customerId: document.getElementById('nc-customer').value.trim(), participantIds: [...selected.keys()], }); dlg.close(); await loadConversations(); const conv = conversations.find((c) => c.id === res.conversation.id); if (conv) openConversation(conv); } catch (err) { toast(err.message, 'err'); } }); // ---- message search ---- const convSearch = document.getElementById('conv-search'); let msgSearchTimer = null; async function runMessageSearch(q) { const list = document.getElementById('conv-list'); const results = document.getElementById('search-results'); if (q.length < 2) { results.hidden = true; results.replaceChildren(); list.hidden = false; return; } try { const res = await api('GET', `/api/v1/messages/search?q=${encodeURIComponent(q)}`); list.hidden = true; results.hidden = false; results.replaceChildren(...res.results.map((r) => { const li = document.createElement('li'); const b = document.createElement('button'); b.type = 'button'; b.className = 'conv-item'; b.style.cssText = 'flex-direction:column;align-items:flex-start;gap:2px'; b.innerHTML = `${esc(r.conversationTitle)} ${esc(r.snippet)}`; b.addEventListener('click', () => { const c = conversations.find((x) => x.id === r.conversationId); if (c) { convSearch.value = ''; results.hidden = true; list.hidden = false; openConversation(c); } }); li.appendChild(b); return li; })); if (!res.results.length) { results.innerHTML = '
  • No matching messages.
  • '; } } catch (e) { /* ignore transient search errors */ } } convSearch.addEventListener('input', () => { clearTimeout(msgSearchTimer); const q = convSearch.value.trim(); msgSearchTimer = setTimeout(() => runMessageSearch(q), 250); }); // ---- manage members (group creator only) ---- const membersDlg = document.getElementById('members-dialog'); let mmMemberIds = new Set(); async function renderMembers() { const res = await api('GET', `/api/v1/conversations/${current.id}/members`); mmMemberIds = new Set(res.members.map((u) => u.id)); const host = document.getElementById('mm-list'); host.replaceChildren(...res.members.map((u) => { const li = document.createElement('li'); li.className = 'conv-item'; li.style.justifyContent = 'space-between'; li.innerHTML = `${esc(u.name)} ${u.isCreator ? 'creator' : ''}
    ${esc(u.email)}
    `; if (res.canManage && !u.isCreator) { const rm = document.createElement('button'); rm.className = 'btn small'; rm.textContent = 'Remove'; rm.addEventListener('click', async () => { try { await api('DELETE', `/api/v1/conversations/${current.id}/members/${u.id}`); await renderMembers(); } catch (e) { toast(e.message, 'err'); } }); li.appendChild(rm); } return li; })); document.getElementById('mm-add-wrap').hidden = !res.canManage; } let mmTimer = null; async function mmSearch() { const q = document.getElementById('mm-search').value.trim(); const res = await api('GET', `/api/v1/users?q=${encodeURIComponent(q)}`); const host = document.getElementById('mm-results'); const rows = res.users.filter((u) => !mmMemberIds.has(u.id)).map((u) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'nc-row'; b.textContent = `${u.name} — ${u.email}`; b.addEventListener('click', async () => { try { await api('POST', `/api/v1/conversations/${current.id}/members`, { userId: u.id }); document.getElementById('mm-search').value = ''; host.replaceChildren(); await renderMembers(); } catch (e) { toast(e.message, 'err'); } }); return b; }); host.replaceChildren(...rows); } document.getElementById('chat-members').addEventListener('click', async () => { if (!current) return; document.getElementById('mm-search').value = ''; document.getElementById('mm-results').replaceChildren(); try { await renderMembers(); membersDlg.showModal(); } catch (e) { toast(e.message, 'err'); } }); document.getElementById('mm-close').addEventListener('click', () => membersDlg.close()); document.getElementById('mm-search').addEventListener('input', () => { clearTimeout(mmTimer); mmTimer = setTimeout(mmSearch, 250); }); (async () => { try { const me = await api('GET', '/api/v1/auth/me'); meId = me.user.id; } catch (e) { /* page guard handles */ } loadConversations(); })();