b4e919502f
- Messages: the message list now scrolls inside a fixed-height pane instead of growing the page; new messages autoscroll only when already at the bottom, and scrolling near the top loads older history (40 at a time) with the position preserved. - Add a message search box in the conversations sidebar backed by a new GET /api/v1/messages/search endpoint (searches the caller's conversations, returns snippets); clicking a result opens that conversation. - Manage-members dialog no longer grows horizontally: results are stacked in a fixed-width, scrollable list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
435 lines
15 KiB
JavaScript
435 lines
15 KiB
JavaScript
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 = '';
|
||
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
|
||
? `<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) {
|
||
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 = '<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'); }
|
||
});
|
||
|
||
// ---- 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 = `<span>${esc(r.conversationTitle)}</span>
|
||
<span class="muted" style="font-size:0.82rem">${esc(r.snippet)}</span>`;
|
||
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 = '<li class="muted" style="padding:10px">No matching messages.</li>';
|
||
}
|
||
} 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 = `<span>${esc(u.name)} ${u.isCreator ? '<span class="badge accent">creator</span>' : ''}
|
||
<br><span class="muted">${esc(u.email)}</span></span>`;
|
||
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();
|
||
})();
|