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 = '';
document.getElementById('chat-title').textContent = c.title;
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);
}
async function loadMessages(prepend) {
const res = await api('GET',
`/api/v1/conversations/${current.id}/messages?cursor=${prepend ? oldestCursor : ''}`);
const msgs = res.messages;
if (msgs.length) oldestCursor = msgs[0].id;
const nodes = await Promise.all(msgs.map(renderMessage));
if (prepend) msgHost.prepend(...nodes);
else {
msgHost.append(...nodes);
msgHost.scrollTop = msgHost.scrollHeight;
}
}
// 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)}`).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) {
msgHost.appendChild(await renderMessage(data));
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();
dlg.showModal();
});
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;
document.getElementById('nc-search').addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(async () => {
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');
host.replaceChildren(...res.users.filter((u) => u.id !== meId).map((u) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'btn small';
b.textContent = `${u.name} <${u.email}>`;
b.addEventListener('click', () => { selected.set(u.id, u); renderSelected(); });
return b;
}));
}, 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'); }
});
(async () => {
try {
const me = await api('GET', '/api/v1/auth/me');
meId = me.user.id;
} catch (e) { /* page guard handles */ }
loadConversations();
})();