Files
BountyBoard/web/static/js/chat-widget.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

155 lines
5.7 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.
// Floating messages bubble (bottom-right, every page except /messages):
// unread badge, mini panel with conversation list ↔ thread view and a quick
// plain-text composer. Reuses the conversations API + live WS channel.
import { api } from '/static/js/api.js';
import { subscribe, onPollFallback } from '/static/js/ws.js';
if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages') {
let meId = '';
let open = false;
let current = null; // conversation object when in thread view
const userCache = new Map();
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
const fab = document.createElement('button');
fab.className = 'chat-fab';
fab.setAttribute('aria-label', 'Messages');
fab.innerHTML = '✉<span class="bell-badge" id="cw-unread" hidden></span>';
document.body.appendChild(fab);
const panel = document.createElement('div');
panel.className = 'chat-panel';
panel.hidden = true;
panel.innerHTML = `
<header>
<span style="display:flex;align-items:center;gap:8px">
<button class="btn small ghost" id="cw-back" hidden aria-label="Back to conversations">←</button>
<strong id="cw-title">Messages</strong>
</span>
<span>
<a class="btn small ghost" id="cw-full" href="/messages" aria-label="Open full messages">⤢</a>
<button class="btn small ghost" id="cw-close" aria-label="Close">×</button>
</span>
</header>
<div class="cw-body" id="cw-body"></div>
<footer id="cw-footer" hidden>
<input type="text" id="cw-input" placeholder="Write a message…" aria-label="Message">
<button class="btn small primary" id="cw-send">Send</button>
</footer>`;
document.body.appendChild(panel);
const body = panel.querySelector('#cw-body');
const unreadBadge = fab.querySelector('#cw-unread');
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 refreshUnread() {
try {
const res = await api('GET', '/api/v1/conversations');
const total = res.conversations.reduce((a, c) => a + (c.unread || 0), 0);
unreadBadge.textContent = total > 9 ? '9+' : String(total);
unreadBadge.hidden = total === 0;
return res.conversations;
} catch (e) { return []; }
}
async function showList() {
current = null;
panel.querySelector('#cw-back').hidden = true;
panel.querySelector('#cw-footer').hidden = true;
panel.querySelector('#cw-title').textContent = 'Messages';
const convs = await refreshUnread();
body.replaceChildren(...convs.map((c) => {
const b = document.createElement('button');
b.className = 'cw-conv';
b.innerHTML = `<span>${esc(c.title)} <span class="muted">· ${esc(c.kind)}</span></span>
${c.unread ? `<span class="badge accent">${c.unread}</span>` : ''}`;
b.addEventListener('click', () => showThread(c));
return b;
}));
if (!convs.length) {
body.innerHTML = '<p class="muted" style="padding:10px">No conversations yet — start one from the Messages page.</p>';
}
}
async function renderMsg(m) {
const div = document.createElement('div');
div.className = 'cw-msg' + (m.senderId === meId ? ' own' : '');
div.innerHTML = `<p class="cw-who">${esc(await userName(m.senderId))}</p><div>${m.body || ''}</div>` +
((m.attachments || []).length ? `<p class="muted" style="margin:4px 0 0;font-size:0.75rem">📎 ${m.attachments.length} attachment(s)</p>` : '');
return div;
}
async function showThread(c) {
current = c;
panel.querySelector('#cw-back').hidden = false;
panel.querySelector('#cw-footer').hidden = false;
panel.querySelector('#cw-title').textContent = c.title;
body.replaceChildren();
const res = await api('GET', `/api/v1/conversations/${c.id}/messages?limit=30`);
for (const m of res.messages) body.appendChild(await renderMsg(m));
body.scrollTop = body.scrollHeight;
api('POST', `/api/v1/conversations/${c.id}/read`, {}).catch(() => {});
refreshUnread();
panel.querySelector('#cw-input').focus();
}
async function send() {
const input = panel.querySelector('#cw-input');
const text = input.value.trim();
if (!text || !current) return;
input.value = '';
try {
await api('POST', `/api/v1/conversations/${current.id}/messages`, {
body: '<p>' + esc(text) + '</p>',
});
} catch (e) { input.value = text; }
}
panel.querySelector('#cw-send').addEventListener('click', send);
panel.querySelector('#cw-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); send(); }
});
fab.addEventListener('click', async () => {
open = !open;
panel.hidden = !open;
if (open) {
if (!meId) {
try { meId = (await api('GET', '/api/v1/auth/me')).user.id; } catch (e) { /* ignore */ }
}
showList();
}
});
panel.querySelector('#cw-close').addEventListener('click', () => {
open = false;
panel.hidden = true;
});
panel.querySelector('#cw-back').addEventListener('click', showList);
subscribe('chat', async (event, data) => {
if (event !== 'message' || !data) return;
if (open && current && data.conversationId === current.id) {
body.appendChild(await renderMsg(data));
body.scrollTop = body.scrollHeight;
api('POST', `/api/v1/conversations/${current.id}/read`, {}).catch(() => {});
} else if (open && !current) {
showList();
} else {
refreshUnread();
}
});
onPollFallback(refreshUnread);
refreshUnread();
}