Family Chat (2.0): a built-in messenger for the family, phase 1
build-container / build (push) Successful in 7m9s

- Chat tab: direct chats and groups, text, attachments, replies, reactions, edit and delete, unread counts, search, drafts, mute and archive
- Live delivery over server-sent events; phone push for people who are not in the app (muted chats stay quiet)
- chat.js keeps conversations and files in data/chat; push.notify sends one person a notification
- Roadmap updated: phase 1 done, combined inbox with Signal still to do

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-26 11:14:05 -04:00
1 parent 74592c25a5
commit 8283d10b41
14 files changed
+407 -27

No files matched your search

+176
View File
@@ -0,0 +1,176 @@
'use strict';
// ---- Chat: the family messenger (see chat.js on the server). It looks like the Signal screen on purpose and shares its styles (sg*) and helpers (sgTime, sgLink...).
const ch = { convs: [], cur: null, root: null, es: null, unread: 0, reply: null, tray: [], names: {}, people: null, showArchived: false, sendQ: null, found: null };
const chq = q => (ch.root || document).querySelector(q);
const chApi = (p, o) => jfetch('/api/chat' + p, o);
const chPost = (p, body) => chApi(p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const chOnView = () => location.hash.startsWith('#/chat');
const chMounted = () => !!ch.root && document.contains(ch.root);
const chDraftKey = id => 'ch_draft:' + (ME ? ME.id : '') + ':' + id;
const chGetDraft = id => { try { return localStorage.getItem(chDraftKey(id)) || ''; } catch { return ''; } };
const chSetDraft = (id, t) => { try { t ? localStorage.setItem(chDraftKey(id), t) : localStorage.removeItem(chDraftKey(id)); } catch {} };
// ---- live updates, kept running whatever screen is open (so the unread count on the menu is always right)
async function chStart() {
if (!allowed('chat') || ch.es) return;
try { const st = await chApi('/status'); ch.unread = st.unread || 0; renderNav(activeTab); } catch { return; }
ch.es = new EventSource('/api/chat/events');
ch.es.onmessage = e => { let ev; try { ev = JSON.parse(e.data); } catch { return; } chEvent(ev); };
}
let chEvT = null;
function chEvent(ev) {
if (typeof ev.unread === 'number' && ev.unread !== ch.unread) { ch.unread = ev.unread; renderNav(activeTab); }
if (ev.t === 'msg' && !ev.out) {
const looking = chMounted() && ch.cur === ev.conv && document.visibilityState === 'visible' && document.hasFocus();
if (!looking) { toast((ev.name || 'Chat') + ': ' + (ev.preview || '')); if (typeof sgAlerts === 'function' && sgAlerts().sound && typeof sgChime === 'function') sgChime(); }
}
if (!chMounted()) return;
clearTimeout(chEvT); chEvT = setTimeout(chRefresh, 150);
}
function viewChat(args = []) { ch.cur = args[0] ? decodeURIComponent(args[0]) : null; return chMount($('#view')); }
async function chMount(root) {
ch.root = root; root.innerHTML = '<div class="wrap sgwrap"><h1>Chat</h1><p class="empty">Loading…</p></div>';
root.innerHTML = `<div class="wrap sgwrap"><div class="sg ${ch.cur ? 'open' : ''}"><div class="sgl"><div class="sghead"><h1>Chat</h1><button class="btn ghost" id="chnew">${icon('edit')}New</button></div>
<input type="search" id="chq" placeholder="Search chats and messages" autocomplete="off"><div class="list sglist" id="chlist"></div></div><div class="sgm" id="chm"></div></div></div>`;
chq('#chnew').onclick = chNewChat; let t; chq('#chq').oninput = () => { chDrawList(); clearTimeout(t); t = setTimeout(chSearchMessages, 350); };
await chRefresh(true);
}
async function chRefresh(first) {
try {
const r = await chApi('/convs'); ch.convs = r.convs; ch.unread = r.unread; renderNav(activeTab);
if (!chq('#chlist')) return; chDrawList();
if (ch.cur) await chDrawThread(first); else chq('#chm').innerHTML = '<p class="empty">Choose a chat, or start a new one.</p>';
} catch (e) { const l = chq('#chlist'); if (l) l.innerHTML = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; }
}
async function chSearchMessages() {
const q = (chq('#chq') || {}).value ? chq('#chq').value.trim() : ''; if (q.length < 2) { ch.found = null; return chDrawList(); }
try { const r = await chApi('/search?q=' + encodeURIComponent(q)); if ((chq('#chq') || {}).value.trim() === q) { ch.found = r.results; chDrawList(); } } catch { /* the list still works */ }
}
function chRow(c) {
return `<a class="row sgrow ${c.id === ch.cur ? 'on' : ''}" data-id="${esc(c.id)}" href="#/chat/${encodeURIComponent(c.id)}"><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span>
<span class="grow"><b>${esc(c.name)}${c.muted ? ' 🔕' : ''}</b><small>${esc(c.preview || '')}</small></span><span class="sgside"><small>${c.preview || c.ts ? sgTime(c.ts) : ''}</small>${c.unread ? `<i class="nbadge" style="position:static${c.muted ? ';opacity:.5' : ''}">${c.unread}</i>` : ''}</span></a>`;
}
function chDrawList() {
const q = (chq('#chq') || {}).value ? chq('#chq').value.toLowerCase().trim() : '', box = chq('#chlist'); if (!box) return;
const match = c => !q || c.name.toLowerCase().includes(q) || (c.preview || '').toLowerCase().includes(q), live = ch.convs.filter(c => !c.archived && match(c)), arch = ch.convs.filter(c => c.archived && match(c));
const found = q.length >= 2 && ch.found ? ch.found : [];
box.innerHTML = live.map(chRow).join('') + (arch.length ? `<button class="row" id="charch"><span class="grow"><small>${ch.showArchived || q ? '▾' : '▸'} Archived (${arch.length})</small></span></button>${ch.showArchived || q ? arch.map(chRow).join('') : ''}` : '')
+ (found.length ? `<p class="sub" style="margin:12px 12px 4px">In messages</p>${found.map(f => `<a class="row sgrow" href="#/chat/${encodeURIComponent(f.conv)}"><span class="grow"><b>${esc(f.convName)}</b><small>${esc(f.msg.name)}: ${esc(f.msg.text)}</small></span><span class="sgside"><small>${sgTime(f.msg.ts)}</small></span></a>`).join('')}` : '')
|| '';
if (!box.innerHTML) box.innerHTML = `<p class="empty">${q ? 'Nothing matches' : 'No chats yet. Tap New to start one.'}</p>`;
const a = box.querySelector('#charch'); if (a) a.onclick = () => { ch.showArchived = !ch.showArchived; chDrawList(); };
box.oncontextmenu = e => { const r = e.target.closest('[data-id]'); if (r) { e.preventDefault(); chChatMenu(r.dataset.id); } };
}
function chBubble(m, c, prev) {
const att = (m.att || []).map(a => { const u = '/api/chat/file/' + a.id;
return /^image\//.test(a.type || '') ? `<a href="${u}" target="_blank" rel="noopener"><img class="sgimg" loading="lazy" alt="${esc(a.name || 'photo')}" src="${u}"></a>`
: /^audio\//.test(a.type || '') ? `<audio controls preload="none" src="${u}"></audio>` : /^video\//.test(a.type || '') ? `<video controls preload="metadata" playsinline src="${u}"></video>`
: `<a class="sgfile" href="${u}?dl=1" target="_blank" rel="noopener">${icon('clip')}<span>${esc(a.name || 'file')}${a.size ? ' · ' + sgSize(a.size) : ''}</span></a>`; }).join('');
const reacts = {}; Object.values(m.react || {}).forEach(e => { reacts[e] = (reacts[e] || 0) + 1; });
const day = !prev || new Date(prev.ts).toDateString() !== new Date(m.ts).toDateString() ? `<div class="sgday">${new Date(m.ts).toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })}</div>` : '';
return `${day}<div class="sgb ${m.out ? 'out' : 'in'}" data-id="${esc(m.id)}">${c.type === 'group' && !m.out ? `<small class="who" style="color:${sgColor(m.from)}">${esc(m.name)}</small>` : ''}
${m.quote ? `<div class="sgq"><b>${esc(m.quote.name)}</b><span>${esc(m.quote.text || 'Message')}</span></div>` : ''}${att}
${m.deleted ? '<i class="sgdel">This message was deleted</i>' : m.text ? `<div class="sgt">${sgLink(esc(m.text)).replace(/\n/g, '<br>')}</div>` : ''}
<span class="sgmeta">${m.edited ? 'edited · ' : ''}${new Date(m.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</span>
${Object.keys(reacts).length ? `<span class="sgre">${Object.entries(reacts).map(([e, n]) => e + (n > 1 ? ' ' + n : '')).join(' ')}</span>` : ''}</div>`;
}
async function chDrawThread(first) {
const box = chq('#chm'); if (!box) return;
const id = ch.cur; let r;
try { r = await chApi('/conv/' + encodeURIComponent(id)); } catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
if (ch.cur !== id || !chq('#chm')) return; ch.names = r.names || {};
const c = r.conv, old = box.querySelector('.sgmsgs'), stick = !old || old.scrollHeight - old.scrollTop - old.clientHeight < 80;
if (!box.querySelector('.sgbar')) {
box.innerHTML = `<div class="sgbar"><a class="ibtn sgback" href="#/chat" aria-label="Back to chats">${icon('back')}</a><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span><span class="grow"><b id="chtitle"></b><small id="chsub"></small></span><button class="ibtn" id="chmore" aria-label="Chat options">${icon('more')}</button></div>
<div class="sgmsgs" id="chmsgs"></div><div id="chrep" class="sgrep" hidden></div><div id="chtray" class="sgtray" hidden></div>
<form class="sgc" id="chform"><button type="button" class="ibtn" id="chat" aria-label="Attach a file">${icon('clip')}</button><input type="file" id="chfile" multiple hidden>
<textarea id="chtext" rows="1" placeholder="Message" enterkeyhint="send"></textarea><button class="ibtn sgsend" aria-label="Send">${icon('send')}</button></form>`;
chWireComposer(); chq('#chmore').onclick = () => chChatMenu(ch.cur); const ta = chq('#chtext'); ta.value = chGetDraft(id); ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 140) + 'px';
}
chq('#chtitle').textContent = c.name; chq('#chsub').textContent = c.type === 'group' ? c.members.map(x => x.name).join(', ') : '';
const list = r.msgs, el = chq('#chmsgs');
el.innerHTML = (r.more ? '<p class="sub" style="text-align:center">Older messages are not shown here</p>' : '') + list.map((m, i) => chBubble(m, c, list[i - 1])).join('') || '<p class="empty">No messages yet. Say hello.</p>';
if (first || stick) el.scrollTop = el.scrollHeight;
el.onclick = e => { const b = e.target.closest('.sgb'); if (!b || e.target.closest('a, audio, video')) return; chMessageMenu(list.find(m => m.id === b.dataset.id), c); };
if (c.unread) chPost('/read', { id }).then(() => { const x = ch.convs.find(q => q.id === id); if (x) x.unread = 0; chDrawList(); }).catch(() => {});
}
function chMessageMenu(m, c) {
if (!m || m.deleted) return;
const mine = m.out, el = openSheet('', '', `<div class="sgemo">${['👍', '❤️', '😂', '😮', '😢', '🙏'].map(e => `<button class="ibtn" data-e="${e}" aria-label="React ${e}">${e}</button>`).join('')}</div>
<div class="list" style="background:var(--panel2);border:0;margin-top:8px"><button class="row" id="chr">${icon('back')}<span class="grow"><b>Reply</b></span></button>${m.text ? `<button class="row" id="chcp">${icon('copy')}<span class="grow"><b>Copy text</b></span></button>` : ''}
${mine && m.text ? `<button class="row" id="ched">${icon('edit')}<span class="grow"><b>Edit</b></span></button>` : ''}${mine ? `<button class="row" id="chdl">${icon('trash')}<span class="grow"><b style="color:var(--danger)">Delete for everyone</b></span></button>` : ''}</div>`);
el.querySelectorAll('[data-e]').forEach(b => b.onclick = async () => { closeSheet(); try { await chPost('/react', { conv: c.id, msg: m.id, emoji: (m.react && ME && m.react[ME.id]) === b.dataset.e ? '' : b.dataset.e }); } catch (e) { toast(e.message); } });
el.querySelector('#chr').onclick = () => { closeSheet(); ch.reply = m; chDrawReply(); chq('#chtext').focus(); };
const cp = el.querySelector('#chcp'); if (cp) cp.onclick = async () => { closeSheet(); try { await navigator.clipboard.writeText(m.text); toast('Copied'); } catch { toast('Could not copy'); } };
const ed = el.querySelector('#ched'); if (ed) ed.onclick = () => { closeSheet(); textSheet('Edit message', 'Message', 'Save', async t => { try { await chPost('/edit', { conv: c.id, msg: m.id, text: t }); } catch (e) { toast(e.message); } }, m.text); };
const dl = el.querySelector('#chdl'); if (dl) dl.onclick = () => { closeSheet(); confirmSheet('Delete this message?', 'It disappears for everyone in the chat.', 'Delete', async () => { try { await chPost('/delete', { conv: c.id, msg: m.id }); } catch (e) { toast(e.message); } }); };
}
function chDrawReply() {
const b = chq('#chrep'); if (!b) return; if (!ch.reply) { b.hidden = true; return; }
b.hidden = false; b.innerHTML = `<span class="grow"><b>${esc(ch.reply.out ? 'You' : ch.reply.name)}</b><small>${esc(ch.reply.text || 'Attachment')}</small></span><button class="ibtn" id="chrx" aria-label="Cancel reply">${icon('x')}</button>`;
chq('#chrx').onclick = () => { ch.reply = null; chDrawReply(); };
}
function chDrawTray() {
const t = chq('#chtray'); if (!t) return; t.hidden = !ch.tray.length;
t.innerHTML = ch.tray.map((f, i) => `<span class="sgchip">${/^image\//.test(f.type) ? `<img alt="" src="${URL.createObjectURL(f)}">` : icon('clip')}<small>${esc(f.name)}</small><button type="button" class="ibtn" data-x="${i}" aria-label="Remove">${icon('x')}</button></span>`).join('');
t.querySelectorAll('[data-x]').forEach(b => b.onclick = () => { ch.tray.splice(+b.dataset.x, 1); chDrawTray(); });
}
function chWireComposer() {
const ta = chq('#chtext'), grow = () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 140) + 'px'; };
ta.oninput = () => { grow(); chSetDraft(ch.cur, ta.value); };
ta.onkeydown = e => { if (e.key === 'Enter' && !e.shiftKey && matchMedia('(pointer: fine)').matches) { e.preventDefault(); chq('#chform').requestSubmit(); } };
chq('#chat').onclick = () => chq('#chfile').click();
chq('#chfile').onchange = e => { ch.tray.push(...e.target.files); e.target.value = ''; chDrawTray(); };
ta.addEventListener('paste', e => { const fs = [...(e.clipboardData && e.clipboardData.files || [])]; if (fs.length) { ch.tray.push(...fs); chDrawTray(); } });
// The message appears at once, dimmed as "Sending…"; if it fails, the words and files go back into the box so nothing is lost.
chq('#chform').onsubmit = e => {
e.preventDefault(); const text = ta.value, files = [...ch.tray], reply = ch.reply, to = ch.cur; if (!text.trim() && !files.length) return;
ta.value = ''; grow(); chSetDraft(to, ''); ch.tray = []; ch.reply = null; chDrawTray(); chDrawReply(); ta.focus();
const list = chq('#chmsgs'), tmp = document.createElement('div'); tmp.className = 'sgb out'; tmp.style.opacity = '.65'; tmp.innerHTML = `${esc(text) || 'Attachment'}<div class="sgmeta"><small>Sending…</small></div>`;
if (list) { list.appendChild(tmp); list.scrollTop = list.scrollHeight; }
ch.sendQ = (ch.sendQ || Promise.resolve()).then(async () => { // one at a time, so quick messages arrive in the order you wrote them
try {
const ids = []; for (const f of files) { const up = await chApi('/upload?conv=' + encodeURIComponent(to) + '&name=' + encodeURIComponent(f.name), { method: 'POST', body: f, headers: { 'content-type': f.type || 'application/octet-stream' } }); ids.push(up.id); }
await chPost('/send', { conv: to, text, files: ids, quote: reply ? reply.id : undefined }); tmp.remove(); await chRefresh(); const el = chq('#chmsgs'); if (el) el.scrollTop = el.scrollHeight;
} catch (err) { tmp.remove(); toast(err.message || 'Could not send'); if (ch.cur === to && !ta.value) { ta.value = text; grow(); ch.tray = files; ch.reply = reply; chDrawTray(); chDrawReply(); } }
});
};
}
// ---- starting chats, groups and managing them
async function chPeople() { if (!ch.people) ch.people = await chApi('/people'); return ch.people; }
async function chNewChat() {
let people = []; try { people = await chPeople(); } catch (e) { return toast(e.message); }
const el = openSheet('New chat', 'Pick someone in the family', `<div class="list">${people.map(p => `<button class="row" data-p="${esc(p.id)}"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><span class="grow"><b>${esc(p.name)}</b></span></button>`).join('') || '<p class="empty">There is nobody else with Chat yet. An admin can turn it on for people in Settings.</p>'}</div>
${people.length > 1 ? '<div class="bar"><button class="btn ghost" id="chg" style="width:100%;justify-content:center">New group…</button></div>' : ''}`);
el.querySelectorAll('[data-p]').forEach(b => b.onclick = async () => { try { const r = await chPost('/new', { type: 'direct', with: b.dataset.p }); closeSheet(); location.hash = '#/chat/' + encodeURIComponent(r.conv.id); } catch (e) { toast(e.message); } });
const g = el.querySelector('#chg'); if (g) g.onclick = () => { closeSheet(); chNewGroup(people); };
}
function chNewGroup(people) {
const el = openSheet('New group', '', `<form id="chgf"><label for="chgn">Group name</label><input type="text" id="chgn" maxlength="60" required autocomplete="off" placeholder="Family"><label>Who is in it?</label>
<div class="list">${people.map(p => `<label class="row" style="cursor:pointer"><input type="checkbox" data-p="${esc(p.id)}" checked><span class="grow"><b>${esc(p.name)}</b></span></label>`).join('')}</div><div class="err" id="chge"></div>
<div class="bar"><button class="btn ghost" type="button" id="chgx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Create</button></div></form>`);
el.querySelector('#chgx').onclick = closeSheet; setTimeout(() => el.querySelector('#chgn').focus(), 60);
el.querySelector('#chgf').onsubmit = async e => { e.preventDefault(); try { const r = await chPost('/new', { type: 'group', name: el.querySelector('#chgn').value, members: [...el.querySelectorAll('[data-p]:checked')].map(c => c.dataset.p) }); closeSheet(); location.hash = '#/chat/' + encodeURIComponent(r.conv.id); } catch (x) { el.querySelector('#chge').textContent = x.message; } };
}
function chChatMenu(id) {
const c = ch.convs.find(x => x.id === id); if (!c) return; const acts = [];
acts.push({ label: c.muted ? 'Unmute' : 'Mute', icon: 'bell', run: async () => { try { await chPost('/conv/mute', { id, on: !c.muted }); toast(c.muted ? 'Unmuted' : 'Muted. No alerts from this chat.'); chRefresh(); } catch (e) { toast(e.message); } } });
acts.push({ label: c.archived ? 'Move out of the archive' : 'Archive', icon: 'files', run: async () => { try { await chPost('/conv/archive', { id, on: !c.archived }); if (!c.archived && ch.cur === id) location.hash = '#/chat'; chRefresh(); } catch (e) { toast(e.message); } } });
if (c.type === 'group') {
acts.push({ label: 'Rename the group', icon: 'edit', run: () => textSheet('Rename group', 'Group name', 'Rename', async n => { try { await chPost('/conv/update', { id, name: n }); chRefresh(); } catch (e) { toast(e.message); } }, c.name) });
acts.push({ label: 'People in the group', icon: 'people', run: () => chMembers(c) });
acts.push({ label: 'Leave the group', icon: 'trash', danger: true, run: () => confirmSheet('Leave this group?', c.name, 'Leave', async () => { try { await chPost('/conv/update', { id, leave: true }); location.hash = '#/chat'; chRefresh(); } catch (e) { toast(e.message); } }) });
}
actionSheet(c.name, c.type === 'group' ? c.members.length + ' people' : 'Chat', acts);
}
async function chMembers(c) {
let people = []; try { people = await chPeople(); } catch (e) { return toast(e.message); }
const owner = ME && (ME.role === 'admin' || c.by === ME.id), others = people.filter(p => !c.members.some(m => m.id === p.id));
const el = openSheet(c.name, 'People in the group', `<div class="list">${c.members.map(m => `<div class="row"><span class="sgav" style="background:${sgColor(m.id)}">${esc(sgInitial(m.name))}</span><span class="grow"><b>${esc(m.name)}${ME && m.id === ME.id ? ' (you)' : ''}${m.id === c.by ? ' · made it' : ''}</b></span>${owner && !(ME && m.id === ME.id) ? `<button class="btn danger" data-r="${esc(m.id)}" style="min-height:34px">Remove</button>` : ''}</div>`).join('')}</div>
${others.length ? `<h2>Add someone</h2><div class="list">${others.map(p => `<button class="row" data-a="${esc(p.id)}"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><span class="grow"><b>${esc(p.name)}</b></span>${icon('plus')}</button>`).join('')}</div>` : ''}`);
const act = async body => { try { await chPost('/conv/update', { id: c.id, ...body }); closeSheet(); chRefresh(); } catch (e) { toast(e.message); } };
el.querySelectorAll('[data-r]').forEach(b => b.onclick = () => act({ remove: [b.dataset.r] })); el.querySelectorAll('[data-a]').forEach(b => b.onclick = () => act({ add: [b.dataset.a] }));
}