Files
etalon 70cb664246 feat: @-mention autocomplete; consultant bounty-board access; clickable atomization tasks
- @-mentions: typing "@" in task comments, the messages composer and the chat
  widget now opens a user picker (new shared mention.js) that inserts "@Name ".
  Backed by the existing /api/v1/users search; menus de-dupe per field.
- Consultants can now open the bounty board (read-only): nav link + page/API
  access extended to consultants, board visibility resolves their assigned
  customers, and cards show "Open" instead of claim actions for non-developers.
- Atomization board task titles (roots and subtasks) link to /tasks/{id} so
  consultants can reach the detail view (comments, assignment, timeline).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:54:44 +02:00

438 lines
15 KiB
JavaScript
Raw Permalink 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';
import { attachMentions } from '/static/js/mention.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 = '';
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 (composer.dataset.mentionActive === '1') return; // mention picker owns Enter
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
form.requestSubmit();
}
});
attachMentions(composer);
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();
})();