Files
BountyBoard/web/static/js/messages.js
T
etalon c59aea42ef feat: Y2K dither theme, filter toolbars, chat bubble widget; fix alignment root causes
- theme rework (user request): white paper bg, brown ink, ordered-dither
  halftone textures, hard offset Y2K shadows, dithered masthead bands;
  dark theme matched; token test + DECISIONS updated
- fix: .card + .card stacking margin leaked into grid layouts, shifting
  every card except the first (the 'always the first item' reports)
- fix: CSP style-src 'self' silently dropped every inline style attribute
  (misaligned save button, stat values, editor attach button, bell badge);
  styles now allow inline, scripts remain strict per §12
- fix: [hidden] is now display:none !important so flex containers cannot
  defeat it (chat panel/footers)
- board + metrics filters live in boxed .toolbar rows with baseline-aligned
  controls; metric stat cards use a uniform .stat layout
- new-conversation dialog: fixed 440px width and 180px results list (no
  more resizing while searching), full-width result rows, picked people
  drop out of the list
- floating messages bubble bottom-right on all pages (except /messages):
  unread badge, mini panel with conversation list, thread view, quick
  composer, live WS updates; toasts moved up to clear it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:54:27 +02:00

307 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[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 = `
<button type="button" class="conv-item">
<span>${esc(c.title)} <span class="muted">· ${esc(c.kind)}</span></span>
${c.unread ? `<span class="badge accent">${c.unread}</span>` : ''}
</button>`;
li.querySelector('button').addEventListener('click', () => openConversation(c));
return li;
}));
if (!conversations.length) {
convList.innerHTML = '<li class="muted">No conversations yet.</li>';
}
}
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
? `<img src="/files/${a.fileId}" alt="${esc(a.name)}" class="chat-img" data-lightbox loading="lazy">`
: `<a class="btn small" href="/files/${a.fileId}">📄 ${esc(a.name)}</a>`).join(' ');
div.innerHTML = `
<p class="muted" style="margin:0">${esc(await userName(m.senderId))}
· ${ulidTime(m.id).toLocaleString()}</p>
<div>${m.body || ''}</div>
${atts ? `<div class="mt">${atts}</div>` : ''}`;
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)} <button type="button" class="btn small ghost" aria-label="Remove attachment">×</button>`;
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();
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 = '<p class="nc-empty">No matches — try a name or email.</p>';
}
}
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'); }
});
(async () => {
try {
const me = await api('GET', '/api/v1/auth/me');
meId = me.user.id;
} catch (e) { /* page guard handles */ }
loadConversations();
})();