feat: messaging with conversations, rich text, attachments, typing, unread (phase 9)
- conversations: dm (unique pair, find-or-create), group (titled), project (customer-bound); participant-only access - messages: server-sanitized rich text, attachments referencing uploads from the generic POST /api/v1/files endpoint (rate limited, MIME sniffed) - unread counts via aggregation; mark-read pushes readBy receipts - chat files served only to conversation participants (or uploader) - @mentions in chat notify the mentioned participant - typing indicator: client WS event relayed to other participants - WS targeted delivery (SendTo) + 15s polling fallback - two-pane messages UI: conversation list with unread badges, composer (contenteditable + formatting buttons), drag & drop upload, inline image previews with lightbox, new-conversation dialog with user search Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -155,6 +155,42 @@ table.list th { color: var(--muted); font-size: 0.875rem; }
|
||||
.toast.err { border-left-color: var(--err); }
|
||||
.toast.ok { border-left-color: var(--ok); }
|
||||
|
||||
/* chat */
|
||||
.chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 16px; min-height: 70vh; }
|
||||
.chat-sidebar { overflow: auto; }
|
||||
.conv-list { list-style: none; margin: 8px 0 0; padding: 0; }
|
||||
.conv-list li { border-bottom: 1px solid var(--border); }
|
||||
.conv-list li.active .conv-item { background: var(--surface2); }
|
||||
.conv-item {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||
width: 100%; padding: 10px 8px; font: inherit; text-align: left;
|
||||
background: none; border: none; color: var(--text); cursor: pointer;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.conv-item:hover { background: var(--surface2); }
|
||||
.chat-main { display: flex; flex-direction: column; }
|
||||
.chat-messages { flex: 1; overflow: auto; padding: 8px 0; min-height: 320px; }
|
||||
.chat-msg {
|
||||
max-width: 75%; margin: 8px 0; padding: 8px 12px;
|
||||
background: var(--surface2); border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.chat-msg.own { margin-left: auto; background: color-mix(in srgb, var(--accent) 12%, var(--surface2)); }
|
||||
.chat-msg p, .chat-msg ul, .chat-msg ol { margin: 4px 0; }
|
||||
.chat-img { max-width: 240px; max-height: 180px; cursor: zoom-in; border-radius: var(--radius); }
|
||||
.chat-toolbar { display: flex; gap: 4px; margin-bottom: 8px; }
|
||||
.chat-composer {
|
||||
min-height: 60px; max-height: 200px; overflow: auto;
|
||||
background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 8px;
|
||||
}
|
||||
.chat-composer:empty::before { content: attr(data-placeholder); color: var(--muted); }
|
||||
.chat-attachments { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.lightbox { border: none; background: transparent; max-width: 92vw; max-height: 92vh; }
|
||||
.lightbox img { max-width: 90vw; max-height: 88vh; cursor: zoom-out; }
|
||||
.lightbox::backdrop { background: rgba(0,0,0,0.8); }
|
||||
@media (max-width: 800px) { .chat-layout { grid-template-columns: 1fr; } }
|
||||
|
||||
/* kanban */
|
||||
.kanban { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
|
||||
.kanban-col {
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
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 = `
|
||||
<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();
|
||||
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();
|
||||
})();
|
||||
Reference in New Issue
Block a user