Chat
+ root.innerHTML = `
`;
- chq('#chnew').onclick = chNewChat; let t; chq('#chq').oninput = () => { chDrawList(); clearTimeout(t); t = setTimeout(chSearchMessages, 350); };
+ chq('#chnew').onclick = chNewChat; chq('#chset').onclick = chSettings; let t; chq('#chq').oninput = () => { chDrawList(); clearTimeout(t); t = setTimeout(chSearchMessages, 350); };
await chRefresh(true);
}
async function chRefresh(first) {
@@ -63,17 +65,30 @@ function chDrawList() {
box.oncontextmenu = e => { const r = e.target.closest('[data-id]'); if (r) { e.preventDefault(); chChatMenu(r.dataset.id); } };
}
+/** @Name in a message stands out (and means that person gets an alert even if they muted the group). */
+const chMention = html => { const names = Object.values(ch.names || {}).filter(Boolean).sort((a, b) => b.length - a.length); let out = html; for (const n of names) out = out.replace(new RegExp('(^|\\s)@' + esc(n).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![\\w])', 'gi'), (x, pre) => pre + '
@' + esc(n) + ''); return out; };
+const chDur = s => { s = Math.round(+s || 0); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
+function chPoll(m) {
+ const P = m.poll, votes = P.votes || {}, count = id => Object.values(votes).filter(v => v.includes(id)).length, total = Object.keys(votes).length, mine = (ME && votes[ME.id]) || [];
+ return `
๐ ${esc(P.q)}${P.multi ? 'Choose any' : 'Choose one'}${P.closed ? ' ยท closed' : ''}
+ ${P.options.map(o => { const n = count(o.id); return ``; }).join('')}
+ ${total} ${total === 1 ? 'person has' : 'people have'} voted
`;
+}
function chBubble(m, c, prev) {
const att = (m.att || []).map(a => { const u = '/api/chat/file/' + a.id;
return /^image\//.test(a.type || '') ? `

`
+ : a.voice ? `
${(a.wave && a.wave.length ? a.wave : Array(28).fill(30)).map(v => ``).join('')}${chDur(a.dur)}
`
: /^audio\//.test(a.type || '') ? `
` : /^video\//.test(a.type || '') ? `
`
: `
${icon('clip')}${esc(a.name || 'file')}${a.size ? ' ยท ' + sgSize(a.size) : ''}`; }).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() ? `
${new Date(m.ts).toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' })}
` : '';
- return `${day}
${c.type === 'group' && !m.out ? `
${esc(m.name)}` : ''}
- ${m.quote ? `
${esc(m.quote.name)}${esc(m.quote.text || 'Message')}
` : ''}${att}
- ${m.deleted ? '
This message was deleted' : m.text ? `
${sgLink(esc(m.text)).replace(/\n/g, '
')}
` : ''}
-
${m.edited ? 'edited ยท ' : ''}${new Date(m.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
+ if (m.sys) return `${day}
${esc(m.text)}
`;
+ const tick = m.out && m.status ? `
${m.status === 'sent' ? 'โ' : 'โโ'}` : '';
+ return `${day}
${m.fwd ? `
โช Forwarded from ${esc(m.fwd.name)}` : ''}${c.type === 'group' && !m.out ? `
${esc(m.name)}` : ''}
+ ${m.poll ? chPoll(m) : ''}${m.quote ? `
${esc(m.quote.name)}${esc(m.quote.text || 'Message')}
` : ''}${att}
+ ${m.deleted ? '
This message was deleted' : m.text ? `
${chMention(sgLink(esc(m.text)).replace(/\n/g, '
'))}
` : ''}
+ ${m.link && !m.deleted ? `
${m.link.image ? `
` : ''}${esc(m.link.site || new URL(m.link.url).hostname)}${esc(m.link.title)}${m.link.desc ? `${esc(m.link.desc)}` : ''}` : ''}
+
${m.exp ? 'โฑ ' : ''}${m.edited ? 'edited ยท ' : ''}${new Date(m.ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}${tick ? ' ' + tick : ''}
${Object.keys(reacts).length ? `
${Object.entries(reacts).map(([e, n]) => e + (n > 1 ? ' ' + n : '')).join(' ')}` : ''}
`;
}
async function chDrawThread(first) {
@@ -84,25 +99,36 @@ async function chDrawThread(first) {
const c = r.conv, old = box.querySelector('.sgmsgs'), stick = !old || old.scrollHeight - old.scrollTop - old.clientHeight < 80;
if (!box.querySelector('.sgbar')) {
box.innerHTML = `
${icon('back')}${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}
-
-
`;
+
+
`;
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(', ') : '';
+ ch.curConv = c; ch.names = Object.fromEntries(c.members.map(x => [x.id, x.name])); ch.pins = r.pinned || []; chq('#chtitle').textContent = c.name; chPaintSub(); chDrawPin();
const list = r.msgs, el = chq('#chmsgs');
el.innerHTML = (r.more ? '
Older messages are not shown here
' : '') + list.map((m, i) => chBubble(m, c, list[i - 1])).join('') || '
No messages yet. Say hello.
';
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); };
+ el.onclick = e => {
+ const b = e.target.closest('.sgb'), m0 = b && list.find(m => m.id === b.dataset.id), opt = e.target.closest('.sgopt'), vc = e.target.closest('.sgvoice'), pq = e.target.closest('.sgpq');
+ if (opt && m0) return chVote(m0, opt.dataset.o); if (vc) return chPlayVoice(vc); if (pq && m0) return chPollWho(m0);
+ if (!b || e.target.closest('a, audio, video')) return; chMessageMenu(m0, 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('', '', `
${['๐', 'โค๏ธ', '๐', '๐ฎ', '๐ข', '๐'].map(e => ``).join('')}
${m.text ? `` : ''}
+ ${m.text && allowed('tasks') ? `` : ''}${m.text && allowed('recipes') ? `` : ''}${m.text && allowed('calendar') ? `` : ''}
+
${mine && m.text ? `` : ''}${mine ? `` : ''}
`);
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 tk = el.querySelector('#chtk'); if (tk) tk.onclick = async () => { closeSheet(); try { await jfetch('/api/tasks', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ list: 'local:inbox', fields: { title: m.text.slice(0, 200), notes: 'From Chat (' + m.name + ')', due: '', done: false } }) }); toast('Added to your tasks', { label: 'Open', run: () => { location.hash = '#/tasks'; } }); } catch (e) { toast(e.message); } };
+ const sh = el.querySelector('#chsh'); if (sh) sh.onclick = async () => { closeSheet(); try { await jfetch('/api/recipes/shopping/add', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: m.text.slice(0, 200) }) }); toast('Added to the shopping list', { label: 'Open', run: () => { location.hash = '#/recipes/shop'; } }); } catch (e) { toast(e.message); } };
+ const ev = el.querySelector('#chev'); if (ev) ev.onclick = () => { closeSheet(); chMakeEvent(m); };
+ el.querySelector('#chfw').onclick = () => { closeSheet(); chForward(m, c.id); };
+ el.querySelector('#chpn').onclick = async () => { closeSheet(); try { await chPost('/pin', { conv: c.id, msg: m.id, on: !ch.pins.some(p => p.id === m.id) }); } catch (e) { toast(e.message); } };
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); } }); };
@@ -119,15 +145,18 @@ function 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.oninput = () => { grow(); chSetDraft(ch.cur, ta.value); if (ta.value.trim() && Date.now() - ch.typedAt > 3000) { ch.typedAt = Date.now(); chPost('/typing', { conv: ch.cur }).catch(() => {}); } };
+ chMentionBox(ta);
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(); };
+ chq('#chat').onclick = () => actionSheet('Attach', '', [{ label: 'Photo or file', icon: 'clip', run: () => chq('#chfile').click() }, { label: 'Poll', icon: 'check', run: chNewPoll }]);
+ chq('#chem').onclick = chEmojiPanel; chq('#chmic').onclick = chRecStart;
+ const sw = () => { const has = !!ta.value.trim() || ch.tray.length; chq('#chmic').hidden = has; chq('#chsend').hidden = !has; }; ta.addEventListener('input', sw); sw(); ch.swSend = sw;
+ chq('#chfile').onchange = e => { ch.tray.push(...e.target.files); e.target.value = ''; chDrawTray(); ch.swSend(); };
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();
+ ta.value = ''; grow(); chSetDraft(to, ''); ch.tray = []; ch.reply = null; chDrawTray(); chDrawReply(); ta.focus(); ch.swSend();
const list = chq('#chmsgs'), tmp = document.createElement('div'); tmp.className = 'sgb out'; tmp.style.opacity = '.65'; tmp.innerHTML = `${esc(text) || 'Attachment'}
Sendingโฆ
`;
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
@@ -159,6 +188,7 @@ 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); } } });
+ acts.push({ label: 'Disappearing messages' + (c.ttl ? ' ยท on' : ''), icon: 'clock', run: () => chTtlSheet(c) });
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) });
@@ -174,3 +204,139 @@ async function chMembers(c) {
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] }));
}
+
+/** The line under the chat's name: who is typing, or who is here, or (for a group) who is in it, and the disappearing-messages timer. */
+function chPaintSub() {
+ const el = chq('#chsub'), c = ch.curConv; if (!el || !c) return;
+ const ty = ch.typing && ch.typing.conv === c.id && ch.typing.until > Date.now() ? ch.typing.name + ' is typingโฆ' : '';
+ const ago = t => { const m = Math.round((Date.now() - t) / 60000); return m < 1 ? 'just now' : m < 60 ? m + ' min ago' : m < 1440 ? Math.round(m / 60) + ' h ago' : new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric' }); };
+ const base = c.type === 'group' ? c.members.map(x => x.name).join(', ') : c.presence ? (c.presence.online ? 'โ Online' : c.presence.seen ? 'Active ' + ago(c.presence.seen) : '') : '';
+ el.textContent = ty || (base + (c.ttl ? (base ? ' ยท ' : '') + 'โฑ ' + (c.ttl === 3600 ? '1 hour' : c.ttl === 86400 ? '1 day' : '1 week') : ''));
+ el.style.color = ty ? 'var(--accent)' : '';
+}
+function chDrawPin() {
+ const b = chq('#chpin'); if (!b) return; if (!ch.pins.length) { b.hidden = true; return; }
+ const last = ch.pins[ch.pins.length - 1]; b.hidden = false; b.innerHTML = `
๐${ch.pins.length > 1 ? ch.pins.length + ' pinned messages' : 'Pinned message'}${esc(last.text || (last.att && last.att[0] ? last.att[0].name : 'Attachment'))}`;
+ b.onclick = () => { const el = openSheet('Pinned', '', `
${ch.pins.slice().reverse().map(m => `
`).join('')}
`);
+ el.querySelectorAll('[data-go]').forEach(x => x.onclick = () => { closeSheet(); const t = chq(`.sgb[data-id="${CSS.escape(x.dataset.go)}"]`); if (t) { t.scrollIntoView({ block: 'center' }); t.classList.add('flash'); setTimeout(() => t.classList.remove('flash'), 1400); } });
+ el.querySelectorAll('[data-un]').forEach(x => x.onclick = async () => { closeSheet(); try { await chPost('/pin', { conv: ch.cur, msg: x.dataset.un, on: false }); } catch (e) { toast(e.message); } }); };
+}
+function chTtlSheet(c) {
+ actionSheet('Disappearing messages', 'New messages in this chat go after the time you pick', [[0, 'Off'], [3600, '1 hour'], [86400, '1 day'], [604800, '1 week']].map(([v, l]) => ({ label: l + ((c.ttl || 0) === v ? ' โ' : ''), icon: 'clock', run: async () => { try { await chPost('/conv/ttl', { id: c.id, ttl: v }); } catch (e) { toast(e.message); } } })));
+}
+function chForward(m, from) {
+ const el = openSheet('Forward toโฆ', '', `
${ch.convs.map(c => `
`).join('') || '
No chats yet.
'}
`);
+ el.querySelectorAll('[data-t]').forEach(b => b.onclick = async () => { closeSheet(); try { await chPost('/forward', { from, to: b.dataset.t, msg: m.id }); toast('Forwarded'); } catch (e) { toast(e.message); } });
+}
+/** Type @ in a group and the people in it are offered. */
+function chMentionBox(ta) {
+ let box; const close = () => { if (box) { box.remove(); box = null; } };
+ const show = () => {
+ const c = ch.curConv; if (!c || c.type !== 'group') return close(); const before = ta.value.slice(0, ta.selectionStart), m = /(^|\s)@(\w*)$/.exec(before); if (!m) return close();
+ const rows = c.members.filter(x => ME && x.id !== ME.id && x.name.toLowerCase().startsWith(m[2].toLowerCase())).slice(0, 6); if (!rows.length) return close();
+ if (!box) { box = document.createElement('div'); box.className = 'ctsug'; box.style.cssText += ';bottom:100%;top:auto'; ta.closest('form').style.position = 'relative'; ta.closest('form').appendChild(box); }
+ box.innerHTML = rows.map((r, i) => `
`).join('');
+ box.querySelectorAll('button').forEach(b => b.onmousedown = e => { e.preventDefault(); const r = rows[+b.dataset.i], pos = ta.selectionStart - m[2].length; ta.value = ta.value.slice(0, pos) + r.name + ' ' + ta.value.slice(ta.selectionStart); ta.selectionStart = ta.selectionEnd = pos + r.name.length + 1; close(); ta.dispatchEvent(new Event('input')); });
+ };
+ ta.addEventListener('input', show); ta.addEventListener('blur', () => setTimeout(close, 120));
+}
+
+async function chVote(m, opt) {
+ const P = m.poll, mine = (ME && (P.votes || {})[ME.id]) || []; let opts;
+ if (P.multi) opts = mine.includes(opt) ? mine.filter(x => x !== opt) : [...mine, opt]; else opts = mine.includes(opt) ? [] : [opt];
+ try { await chPost('/vote', { conv: ch.cur, msg: m.id, opts }); } catch (e) { toast(e.message); }
+}
+function chPollWho(m) {
+ const P = m.poll, votes = P.votes || {};
+ const el = openSheet(P.q, P.multi ? 'Choose any' : 'Choose one', `
${P.options.map(o => { const who = Object.entries(votes).filter(([, v]) => v.includes(o.id)).map(([u]) => ch.names[u] || 'Someone'); return `
${esc(o.text)}${who.length ? esc(who.join(', ')) : 'No votes yet'}
`; }).join('')}
+ ${m.out && !P.closed ? '
' : ''}`);
+ const c = el.querySelector('#chpc'); if (c) c.onclick = async () => { closeSheet(); try { await chPost('/poll/close', { conv: ch.cur, msg: m.id }); } catch (e) { toast(e.message); } };
+}
+function chNewPoll() {
+ const el = openSheet('New poll', 'Ask the chat to vote', `
`);
+ el.querySelector('#chpx').onclick = closeSheet; setTimeout(() => el.querySelector('#chpq').focus(), 60);
+ el.querySelector('#chpadd').onclick = () => { const box = el.querySelector('#chpo'); if (box.children.length >= 8) return; const i = document.createElement('input'); i.type = 'text'; i.className = 'chpopt'; i.maxLength = 100; i.placeholder = 'Choice ' + (box.children.length + 1); i.style.marginTop = '6px'; box.appendChild(i); i.focus(); };
+ el.querySelector('#chpf').onsubmit = async e => { e.preventDefault(); try { await chPost('/poll', { conv: ch.cur, question: el.querySelector('#chpq').value, options: [...el.querySelectorAll('.chpopt')].map(x => x.value), multi: el.querySelector('#chpm').checked }); closeSheet(); await chRefresh(); const l = chq('#chmsgs'); if (l) l.scrollTop = l.scrollHeight; } catch (x) { el.querySelector('#chpe').textContent = x.message; } };
+}
+// ---- voice messages: play (one at a time) ...
+function chPlayVoice(box) {
+ const a = ch.audio || (ch.audio = new Audio()), same = ch.playing === box;
+ if (ch.playing && ch.playing !== box) chStopVoice();
+ if (same && !a.paused) { a.pause(); box.querySelector('.sgvp').innerHTML = icon('play'); return; }
+ if (!same) { a.src = box.dataset.src; ch.playing = box; a.onended = chStopVoice; a.ontimeupdate = () => { const d = a.duration || +box.dataset.dur || 1, bars = box.querySelectorAll('.sgbars i'), k = Math.round(a.currentTime / d * bars.length); bars.forEach((b, i) => b.classList.toggle('on', i < k)); box.querySelector('.sgvt').textContent = chDur(a.currentTime); }; }
+ a.play().then(() => { box.querySelector('.sgvp').innerHTML = icon('pause'); }).catch(() => toast('Could not play that'));
+}
+function chStopVoice() { const a = ch.audio, b = ch.playing; if (a) a.pause(); if (b) { b.querySelector('.sgvp').innerHTML = icon('play'); b.querySelectorAll('.sgbars i').forEach(x => x.classList.remove('on')); b.querySelector('.sgvt').textContent = chDur(b.dataset.dur); } ch.playing = null; }
+// ... and record
+async function chRecStart() {
+ if (ch.rec) return; if (!navigator.mediaDevices || !window.MediaRecorder) return toast('This browser cannot record audio');
+ let stream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch { return toast('Allow the microphone to record a voice message'); }
+ const type = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus'].find(t => MediaRecorder.isTypeSupported(t)) || '', mr = new MediaRecorder(stream, type ? { mimeType: type } : undefined), chunks = [], wave = [];
+ let ac, an, tick; try { ac = new (window.AudioContext || window.webkitAudioContext)(); an = ac.createAnalyser(); an.fftSize = 256; ac.createMediaStreamSource(stream).connect(an); const buf = new Uint8Array(an.fftSize); tick = setInterval(() => { an.getByteTimeDomainData(buf); let mx = 0; for (const v of buf) mx = Math.max(mx, Math.abs(v - 128)); wave.push(Math.min(100, Math.round(mx / 128 * 140))); }, 200); } catch { /* no waveform: the message still works */ }
+ mr.ondataavailable = e => { if (e.data.size) chunks.push(e.data); }; mr.start(); ch.rec = { mr, chunks, wave, stream, ac, tick, t0: Date.now(), type: mr.mimeType || type || 'audio/webm' };
+ chDrawRec(); ch.rec.timer = setInterval(chDrawRec, 500);
+}
+function chRecEnd() { const r = ch.rec; if (!r) return; clearInterval(r.timer); clearInterval(r.tick); try { r.mr.state !== 'inactive' && r.mr.stop(); } catch {} r.stream.getTracks().forEach(t => t.stop()); try { r.ac && r.ac.close(); } catch {} ch.rec = null; const f = chq('#chform'); if (f) f.classList.remove('rec'); const b = chq('#chrecbar'); if (b) b.remove(); }
+function chDrawRec() {
+ const r = ch.rec, f = chq('#chform'); if (!r || !f) return; f.classList.add('rec'); let b = chq('#chrecbar');
+ if (!b) { b = document.createElement('div'); b.id = 'chrecbar'; b.className = 'chrecbar'; b.innerHTML = `
0:00`; f.appendChild(b);
+ b.querySelector('#chrx2').onclick = chRecEnd; b.querySelector('#chrs').onclick = chRecSend; }
+ b.querySelector('#chrt').textContent = chDur((Date.now() - r.t0) / 1000);
+}
+async function chRecSend() {
+ const r = ch.rec; if (!r) return; const to = ch.cur, secs = Math.round((Date.now() - r.t0) / 1000); if (secs < 1) return chRecEnd();
+ const done = new Promise(ok => { r.mr.onstop = ok; }); try { r.mr.stop(); } catch {} await done; const blob = new Blob(r.chunks, { type: r.type }), wave = r.wave.slice(); chRecEnd();
+ const bars = []; const n = 32; for (let i = 0; i < n; i++) { const seg = wave.slice(Math.floor(i * wave.length / n), Math.max(Math.floor(i * wave.length / n) + 1, Math.floor((i + 1) * wave.length / n))); bars.push(seg.length ? Math.max(...seg) : 20); }
+ const ext = /mp4/.test(r.type) ? 'm4a' : /ogg/.test(r.type) ? 'ogg' : 'webm';
+ try { const up = await chApi(`/upload?conv=${encodeURIComponent(to)}&name=${encodeURIComponent('Voice message.' + ext)}&voice=1&dur=${secs}&wave=${bars.join(',')}`, { method: 'POST', body: blob, headers: { 'content-type': r.type.split(';')[0] } }); await chPost('/send', { conv: to, files: [up.id] }); await chRefresh(); const l = chq('#chmsgs'); if (l) l.scrollTop = l.scrollHeight; }
+ catch (e) { toast(e.message || 'Could not send the voice message'); }
+}
+// ---- emoji
+const CH_EMOJI = '๐ ๐ ๐ ๐ ๐ ๐
๐ ๐คฃ ๐ ๐ ๐ ๐ ๐ฅฐ ๐ ๐ ๐ ๐ค ๐ค ๐ ๐ด ๐ข ๐ญ ๐ฎ ๐ฑ ๐ก ๐ฅณ ๐ ๐ ๐ ๐ ๐ ๐ ๐ช ๐ โค๏ธ ๐งก ๐ ๐ ๐ ๐ ๐ฅ โญ โจ ๐ ๐ ๐ ๐ ๐ ๐ โ ๐ช ๐ ๐ โ๏ธ โ๏ธ ๐ง๏ธ โ๏ธ ๐ ๐ถ ๐ฑ ๐ธ โ
โ โ ๐ฏ'.split(' ');
+function chEmojiPanel() {
+ const f = chq('#chform'), old = chq('#chemo'); if (old) return old.remove(); const box = document.createElement('div'); box.id = 'chemo'; box.className = 'chemo'; box.innerHTML = CH_EMOJI.map(e => `
`).join(''); f.appendChild(box);
+ box.onclick = e => { const b = e.target.closest('[data-e]'); if (!b) return; const ta = chq('#chtext'), pos = ta.selectionStart || ta.value.length; ta.value = ta.value.slice(0, pos) + b.dataset.e + ta.value.slice(ta.selectionEnd || pos); ta.selectionStart = ta.selectionEnd = pos + b.dataset.e.length; ta.dispatchEvent(new Event('input')); ta.focus(); };
+}
+
+// ---- from a message into the rest of the hub
+function chMakeEvent(m) {
+ const today = new Date(), d = today.getFullYear() + '-' + String(today.getMonth() + 1).padStart(2, '0') + '-' + String(today.getDate()).padStart(2, '0');
+ const el = openSheet('Add to the calendar', 'From a message in Chat', `
`);
+ el.querySelector('#chex').onclick = closeSheet;
+ el.querySelector('#chef').onsubmit = async e => { e.preventDefault(); try {
+ const cals = await jfetch('/api/cal/list'), c = cals.find(x => x.local && !x.shared); if (!c) throw new Error('There is no calendar to add to'); const day = el.querySelector('#ched2').value, tm = el.querySelector('#chetm').value, allDay = !tm, end = tm ? new Date(new Date(day + 'T' + tm).getTime() + 3600e3) : null;
+ const endStr = allDay ? day : day + 'T' + String(end.getHours()).padStart(2, '0') + ':' + String(end.getMinutes()).padStart(2, '0');
+ await jfetch('/api/cal/event', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ cal: c.id, fields: { title: el.querySelector('#chet').value, location: '', notes: 'From Chat (' + m.name + ')', allDay, start: allDay ? day : day + 'T' + tm, end: endStr, tz: Intl.DateTimeFormat().resolvedOptions().timeZone, repeat: 'none' } }) });
+ closeSheet(); toast('Added to the calendar', { label: 'Open', run: () => { location.hash = '#/calendar'; } }); } catch (x) { el.querySelector('#chee').textContent = x.message; } };
+}
+/** From anywhere in the hub: "Share to Chat". Picks a chat and sends the text (with a link back to the thing). */
+async function shareToChat(text) {
+ if (!allowed('chat')) return toast('Chat is not turned on for you');
+ let convs = []; try { convs = (await chApi('/convs')).convs.filter(c => !c.archived); } catch (e) { return toast(e.message); }
+ const el = openSheet('Share to Chat', text.length > 90 ? text.slice(0, 90) + 'โฆ' : text, `
${convs.map(c => `
`).join('') || '
Start a chat first.
'}
`);
+ el.querySelectorAll('[data-t]').forEach(b => b.onclick = async () => { closeSheet(); try { await chPost('/send', { conv: b.dataset.t, text }); toast('Shared', { label: 'Open', run: () => { location.hash = '#/chat/' + encodeURIComponent(b.dataset.t); } }); } catch (e) { toast(e.message); } });
+}
+// ---- settings: quiet hours, and the family announcement (admin)
+async function chSettings() {
+ let pr, an = null; try { [pr, an] = await Promise.all([chApi('/prefs'), chApi('/announcement')]); } catch (e) { return toast(e.message); }
+ const q = pr.quiet, admin = ME && ME.role === 'admin';
+ const el = openSheet('Chat settings', '', `
`);
+ el.querySelector('#chsx').onclick = closeSheet;
+ el.querySelector('#chsf').onsubmit = async e => { e.preventDefault(); try { await chPost('/prefs', { quiet: { on: el.querySelector('#chqo').checked, from: el.querySelector('#chqf').value, to: el.querySelector('#chqt').value } }); if (admin) await chPost('/announcement', { text: el.querySelector('#chan').value }); closeSheet(); toast('Saved'); } catch (x) { el.querySelector('#chse').textContent = x.message; } };
+}
+/** Home: the family announcement, if there is one (each person can dismiss it). */
+async function chAnnounceCard(el) {
+ if (!el || !allowed('chat')) return; let a = null; try { a = await chApi('/announcement'); } catch { return; }
+ let gone = ''; try { gone = localStorage.getItem('ch_ann_gone:' + (ME ? ME.id : '')) || ''; } catch {}
+ if (!a || a.id === gone) { el.innerHTML = ''; return; }
+ el.innerHTML = `
${icon('chat')}Family announcement ยท ${esc(a.by)}${esc(a.text)}
`;
+ el.querySelector('#ancx').onclick = () => { try { localStorage.setItem('ch_ann_gone:' + (ME ? ME.id : ''), a.id); } catch {} el.innerHTML = ''; };
+}
diff --git a/public/player.js b/public/player.js
index 5544ce8..d146bb5 100644
--- a/public/player.js
+++ b/public/player.js
@@ -111,6 +111,7 @@ function trackSheet(t, extra) {
...(typeof mmPlex === 'function' && mmPlex(t.key) ? [
{ label: mfIs(t) ? 'Remove from Favorites' : 'Add to Favorites', icon: 'heart', run: () => mfToggle(t) },
{ label: 'Add to a Family playlistโฆ', icon: 'plus', run: () => addToFamily([t]) },
+ ...(allowed('chat') && t.albumKey ? [{ label: 'Share to Chat', icon: 'send', run: () => shareToChat('โช ' + t.title + ' โ ' + t.artist + ' ' + location.origin + '/#/music/album/' + t.albumKey) }] : []),
...(t.artistKey ? [{ label: 'More like this', icon: 'shuffle', run: () => { location.hash = '#/music/mix/similar/' + t.artistKey; } }] : []),
] : []),
...(extra || []),
diff --git a/public/recipes-view.js b/public/recipes-view.js
index 5aab6bb..14d7da2 100644
--- a/public/recipes-view.js
+++ b/public/recipes-view.js
@@ -59,7 +59,7 @@ async function rcOne(slug) {
${esc(r.name)}
${r.description ? `
${esc(r.description)}
` : ''}
${facts.length ? `
${facts.map(f => `${esc(f[0])} ${esc(f[1])}`).join(' ยท ')}
` : ''}
${r.orgUrl ? `
Original page
` : ''}
-
+
Shopping list${allowed('chat') ? '
' : ''}
Ingredients
${r.ingredients.map((i, n) => i.title && !i.text ? `
${esc(i.title)}
` : `
`).join('') || '
No ingredients.
'}
Steps
${r.steps.map((s, n) => `- ${s.title ? `${esc(s.title)} ` : ''}${esc(s.text).replace(/\n/g, '
')} `).join('') || '- No steps.
'}
@@ -76,6 +76,7 @@ async function rcOne(slug) {
document.querySelectorAll('.rcsteps li[data-s]').forEach(li => li.onclick = () => flip('s', +li.dataset.s));
$('#rcreset').onclick = () => { done = { i: [], s: [] }; store.del(sk); paint(); };
paint();
+ if ($('#rcshare')) $('#rcshare').onclick = () => shareToChat(r.name + ' ' + location.origin + '/#/recipes/r/' + encodeURIComponent(slug));
$('#rcadd').onclick = async e => {
const b = e.currentTarget; b.disabled = true;
try { const o = await rcApi('/shopping/add-recipe', rcOpt('POST', { slug })); toast('Added to your shopping list'); b.disabled = false; return o; }
diff --git a/public/style.css b/public/style.css
index 2a3eb0c..1ad25bf 100644
--- a/public/style.css
+++ b/public/style.css
@@ -998,3 +998,26 @@ body.vizopen { overflow: hidden; }
.chart { display: flex; align-items: flex-end; gap: 3px; height: 120px; padding: 6px 0; } .chart span { flex: 1; height: 100%; display: flex; align-items: flex-end; } .chart i { display: block; width: 100%; background: var(--accent); border-radius: 3px 3px 0 0; min-height: 2px; }
.statbar { height: 6px; border-radius: 3px; background: var(--panel2); margin-top: 6px; overflow: hidden; } .statbar i { display: block; height: 100%; background: var(--accent); }
[data-fv][aria-pressed="true"] svg, #npfav.on svg, #cnfav.on svg { fill: currentColor; }
+
+/* Chat: pins, notices, mentions, forwarded */
+.sgpin { display: flex; gap: 10px; align-items: center; width: 100%; padding: 8px 14px; border: 0; border-bottom: 1px solid var(--line); background: var(--panel2); color: inherit; font: inherit; text-align: left; cursor: pointer; } .sgpin[hidden] { display: none; }
+.sgpin b, .sgpin small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sgpin small { color: var(--accent); }
+.sgsys { font-size: 12px; color: var(--muted); font-style: italic; }
+.mention { color: var(--accent); } .sgb.out .mention { color: inherit; text-decoration: underline; }
+.sgfwd { display: block; font-size: 12px; opacity: .7; font-style: italic; margin-bottom: 2px; }
+.sgb.flash { animation: chflash 1.2s; } @keyframes chflash { 0%, 60% { box-shadow: 0 0 0 3px var(--accent); } 100% { box-shadow: 0 0 0 0 transparent; } }
+
+/* Chat: voice messages, polls, link previews, emoji */
+.sgvoice { display: flex; align-items: center; gap: 8px; min-width: 200px; cursor: pointer; } .sgvoice .sgvp { flex: none; background: rgba(255,255,255,.18); border-radius: 50%; width: 36px; height: 36px; }
+.sgbars { flex: 1; display: flex; align-items: center; gap: 2px; height: 30px; } .sgbars i { flex: 1; min-width: 2px; background: currentColor; opacity: .35; border-radius: 2px; } .sgbars i.on { opacity: 1; } .sgvt { font-size: 12px; opacity: .8; min-width: 32px; text-align: right; }
+.sgpoll { min-width: 220px; } .sgpoll b { display: block; margin-bottom: 2px; cursor: pointer; } .sgpoll > small { display: block; opacity: .75; margin: 2px 0 6px; }
+.sgopt { position: relative; display: flex; align-items: center; gap: 8px; width: 100%; margin: 4px 0; padding: 8px 10px; border: 1px solid rgba(127,127,127,.35); border-radius: 10px; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; overflow: hidden; }
+.sgopt i { position: absolute; inset: 0 auto 0 0; background: rgba(127,127,127,.25); z-index: 0; } .sgopt span, .sgopt small { position: relative; z-index: 1; } .sgopt span { flex: 1; } .sgopt.on { border-color: currentColor; font-weight: 600; } .sgopt:disabled { cursor: default; opacity: .8; }
+.sgprev { display: flex; gap: 10px; margin: 6px 0 2px; padding: 8px; border-radius: 10px; background: rgba(127,127,127,.18); color: inherit; text-decoration: none; overflow: hidden; } .sgprev img { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; flex: none; }
+.sgprev span { min-width: 0; } .sgprev small, .sgprev b, .sgprev em { display: block; overflow: hidden; text-overflow: ellipsis; } .sgprev small { opacity: .7; } .sgprev b { white-space: nowrap; } .sgprev em { font-style: normal; opacity: .8; font-size: 13px; max-height: 2.6em; }
+.chemo { position: absolute; bottom: 100%; left: 0; right: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); gap: 2px; max-height: 190px; overflow: auto; padding: 8px; background: var(--panel); border: 1px solid var(--line); border-radius: 12px 12px 0 0; z-index: 25; }
+.chemo button { border: 0; background: none; font-size: 24px; padding: 4px; cursor: pointer; border-radius: 8px; } .chemo button:hover { background: var(--panel2); }
+.sgc { position: relative; } .sgc.rec > :not(.chrecbar) { visibility: hidden; } .chrecbar { position: absolute; inset: 0; display: flex; align-items: center; gap: 10px; padding: 0 10px; background: var(--panel); } .recdot { width: 10px; height: 10px; border-radius: 50%; background: var(--danger); animation: chblink 1s infinite; } @keyframes chblink { 50% { opacity: .2; } }
+
+.annc { display: flex; align-items: center; gap: 12px; margin: 0 0 14px; padding: 12px 14px; border-radius: 14px; background: color-mix(in srgb, var(--accent) 14%, var(--panel)); border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--line)); }
+.annc .ic { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; color: var(--accent-t); flex: none; } .annc small { display: block; color: var(--muted); } .annc b { display: block; font-size: 17px; overflow-wrap: anywhere; }
diff --git a/public/sw.js b/public/sw.js
index 72fb5d4..1ae275c 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
-const CACHE = 'hub-v59';
+const CACHE = 'hub-v62';
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
diff --git a/tools/chat-test.js b/tools/chat-test.js
index 2adccf9..a1097e9 100644
--- a/tools/chat-test.js
+++ b/tools/chat-test.js
@@ -44,6 +44,50 @@ const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user']
await post('/conv/mute', { id: dm.id, on: true }, 'b'); await post('/send', { conv: dm.id, text: 'psst' }, 'a'); let bl = await j(await call('/convs', {}, 'b')); assert.equal(bl.convs.find(x => x.id === dm.id).muted, true); assert.equal(bl.unread, 0, 'a muted chat does not add to the badge'); assert.equal(bl.convs.find(x => x.id === dm.id).unread, 1, 'but still counts in itself');
await post('/conv/archive', { id: dm.id, on: true }, 'b'); assert.equal((await j(await call('/convs', {}, 'b'))).convs.find(x => x.id === dm.id).archived, true);
const s = await j(await call('/search?q=hello', {}, 'b')); assert.equal(s.results.length, 1); assert.equal(s.results[0].convName, 'Ann'); assert.equal((await j(await call('/search?q=h', {}, 'b'))).results.length, 0, 'too short');
+
+ // ---- phase 2: ticks, typing, presence, disappearing messages, mentions, pins, forwarding
+ const g2 = (await j(await post('/new', { type: 'group', name: 'Crew', members: [P.b.id, P.c.id] }, 'a'))).conv; const st = async (who, conv) => (await j(await call('/conv/' + encodeURIComponent(conv), {}, who)));
+ let sm = (await j(await post('/send', { conv: g2.id, text: 'Hi @Bob and @Cy!' }, 'a'))).msg; assert.deepEqual(sm.mentions.sort(), [P.b.id, P.c.id].sort(), 'mentions are found by name'); assert.equal(sm.status, 'sent', 'nobody has the app open: just sent');
+ await st('b', g2.id); let a1 = (await st('a', g2.id)).msgs.find(x => x.id === sm.id); assert.equal(a1.status, 'sent', 'delivered needs everyone in a group'); await st('c', g2.id); a1 = (await st('a', g2.id)).msgs.find(x => x.id === sm.id); assert.equal(a1.status, 'delivered', 'everyone has fetched it');
+ await post('/read', { id: g2.id }, 'b'); assert.equal((await st('a', g2.id)).msgs.find(x => x.id === sm.id).status, 'delivered', 'read by one is not read by all'); await post('/read', { id: g2.id }, 'c'); assert.equal((await st('a', g2.id)).msgs.find(x => x.id === sm.id).status, 'read');
+ assert.equal((await st('b', g2.id)).msgs.find(x => x.id === sm.id).status, undefined, 'ticks are only for what you sent');
+ // typing and presence (Bob has the live stream open)
+ const t0 = events.length; await post('/typing', { conv: dm.id }, 'a'); await new Promise(ok => setTimeout(ok, 120)); const ty = events.slice(t0).find(e => e.t === 'typing'); assert.ok(ty && ty.name === 'Ann' && ty.conv === dm.id, 'Bob sees that Ann is typing');
+ let pv = (await j(await call('/convs', {}, 'a'))).convs.find(x => x.id === dm.id); assert.equal(pv.presence.online, true, 'Bob has the app open'); assert.ok(pv.presence.seen > 0);
+ // disappearing messages
+ assert.equal((await post('/conv/ttl', { id: g2.id, ttl: 5 }, 'a')).status, 400); assert.equal((await post('/conv/ttl', { id: g2.id, ttl: 3600 }, 'b')).status, 200);
+ let gv = await st('a', g2.id); assert.equal(gv.conv.ttl, 3600); const sys = gv.msgs.at(-1); assert.equal(sys.sys, true); assert.match(sys.text, /Bob set messages to disappear after 1 hour/); assert.equal((await j(await call('/convs', {}, 'a'))).convs.find(x => x.id === g2.id).preview, 'Bob set messages to disappear after 1 hour', 'a notice has no name in front');
+ const upg = await j(await call('/upload?conv=' + g2.id + '&name=t.gif', { method: 'POST', body: png, headers: { 'content-type': 'image/gif' } }, 'a')); const tm = (await j(await post('/send', { conv: g2.id, text: 'secret', files: [upg.id] }, 'a'))).msg; assert.ok(tm.exp > Date.now(), 'it has a time to go');
+ assert.equal((await call('/file/' + upg.id, {}, 'b')).status, 200); const realNow = Date.now; Date.now = () => realNow() + 2 * 3600e3; chat.purge(); Date.now = realNow;
+ gv = await st('a', g2.id); assert.ok(!gv.msgs.some(x => x.id === tm.id), 'it is gone when its time is up'); assert.ok(gv.msgs.some(x => x.id === sm.id), 'older messages from before the timer stay'); assert.equal((await call('/file/' + upg.id, {}, 'b')).status, 404, 'and so is its file');
+ // pins
+ assert.equal((await post('/pin', { conv: g2.id, msg: sm.id, on: true }, 'c')).status, 200); gv = await st('b', g2.id); assert.deepEqual(gv.pinned.map(x => x.id), [sm.id]); await post('/pin', { conv: g2.id, msg: sm.id, on: false }, 'b'); assert.deepEqual((await st('a', g2.id)).pinned, []);
+ assert.equal((await post('/pin', { conv: g2.id, msg: (await st('a', g2.id)).msgs.find(x => x.sys).id, on: true }, 'a')).status, 404, 'a notice cannot be pinned');
+ // forwarding copies the message and its file into another chat
+ const fp = await j(await call('/upload?conv=' + g2.id + '&name=f.gif', { method: 'POST', body: png, headers: { 'content-type': 'image/gif' } }, 'a')); const fm = (await j(await post('/send', { conv: g2.id, text: 'look at this', files: [fp.id] }, 'a'))).msg;
+ const fw = (await j(await post('/forward', { from: g2.id, to: dm.id, msg: fm.id }, 'a'))).msg; assert.equal(fw.text, 'look at this'); assert.equal(fw.fwd.name, 'Ann'); assert.notEqual(fw.att[0].id, fp.id, 'the file is copied, not shared'); assert.equal((await call('/file/' + fw.att[0].id, {}, 'b')).status, 200);
+ assert.equal((await post('/forward', { from: g2.id, to: dm.id, msg: fm.id }, 'c')).status, 404, 'only into chats you are in');
+
+
+ // ---- phase 3: voice notes, polls, link previews
+ const vp = await j(await call('/upload?conv=' + g2.id + '&name=Voice.m4a&voice=1&wave=10,50,200,-5,abc,80&dur=12', { method: 'POST', body: 'audio-bytes', headers: { 'content-type': 'audio/mp4' } }, 'a')); assert.equal(vp.voice, true);
+ const vm = (await j(await post('/send', { conv: g2.id, files: [vp.id] }, 'a'))).msg; assert.deepEqual(vm.att[0].wave, [10, 50, 100, 0, 80], 'the waveform is cleaned (0 to 100)'); assert.equal(vm.att[0].dur, 12); assert.equal(vm.att[0].voice, true);
+ assert.equal((await j(await call('/convs', {}, 'b'))).convs.find(x => x.id === g2.id).preview, 'Ann: ๐ค Voice message', 'a voice note with no words is listed as one');
+ const notV = await j(await call('/upload?conv=' + g2.id + '&name=x.gif&voice=1', { method: 'POST', body: png, headers: { 'content-type': 'image/gif' } }, 'a')); assert.equal(notV.voice, undefined, 'only audio can be a voice note');
+ assert.equal((await post('/poll', { conv: g2.id, question: 'Pizza?', options: ['Yes'] }, 'a')).status, 400, 'a poll needs two choices'); const pm = (await j(await post('/poll', { conv: g2.id, question: 'Pizza or tacos?', options: ['Pizza', 'Tacos', ' ', 'Salad'] }, 'a'))).msg; assert.deepEqual(pm.poll.options.map(o => o.text), ['Pizza', 'Tacos', 'Salad']); assert.equal(pm.poll.multi, false);
+ assert.equal((await post('/vote', { conv: g2.id, msg: pm.id, opts: ['0', '1', '9'] }, 'b')).status, 200); let pv2 = (await st('a', g2.id)).msgs.find(x => x.id === pm.id); assert.deepEqual(pv2.poll.votes, { [P.b.id]: ['0'] }, 'a single-choice poll keeps one vote');
+ await post('/vote', { conv: g2.id, msg: pm.id, opts: ['1'] }, 'c'); await post('/vote', { conv: g2.id, msg: pm.id, opts: ['2'] }, 'b'); pv2 = (await st('a', g2.id)).msgs.find(x => x.id === pm.id); assert.deepEqual(pv2.poll.votes, { [P.b.id]: ['2'], [P.c.id]: ['1'] }, 'changing a vote replaces it'); await post('/vote', { conv: g2.id, msg: pm.id, opts: [] }, 'c'); assert.deepEqual((await st('a', g2.id)).msgs.find(x => x.id === pm.id).poll.votes, { [P.b.id]: ['2'] }, 'an empty vote takes it back');
+ const mp = (await j(await post('/poll', { conv: g2.id, question: 'Which days?', options: ['Sat', 'Sun'], multi: true }, 'a'))).msg; await post('/vote', { conv: g2.id, msg: mp.id, opts: ['0', '1'] }, 'b'); assert.deepEqual((await st('a', g2.id)).msgs.find(x => x.id === mp.id).poll.votes[P.b.id], ['0', '1'], 'a multiple-choice poll keeps both');
+ assert.equal((await post('/poll/close', { conv: g2.id, msg: pm.id }, 'b')).status, 403, 'only the asker closes it'); assert.equal((await post('/poll/close', { conv: g2.id, msg: pm.id }, 'a')).status, 200); assert.equal((await post('/vote', { conv: g2.id, msg: pm.id, opts: ['0'] }, 'b')).status, 400, 'a closed poll takes no votes');
+ assert.equal((await j(await call('/convs', {}, 'b'))).convs.find(x => x.id === g2.id).preview, 'Ann: ๐ Which days?', 'a poll is listed by its question');
+ const pp = chat.parsePreview('
Fallback', 'https://cook.example.com/stew'); assert.deepEqual(pp, { url: 'https://cook.example.com/stew', site: 'Cook Site', title: 'Pot & Pan Recipes', desc: 'Best stew', image: 'https://cook.example.com/img/a.jpg' }, 'a preview reads the page\'s own words');
+ assert.equal(chat.parsePreview('
Plain', 'https://x.example.com/').title, 'Plain'); assert.equal(chat.parsePreview('
', 'https://x.example.com/').image, '', 'only https pictures'); assert.equal(chat.parsePreview('', 'https://x.example.com/'), null);
+
+
+ // ---- family extras: announcement and quiet hours
+ assert.equal(await j(await call('/announcement', {}, 'b')), null); assert.equal((await post('/announcement', { text: 'Dinner at 6' }, 'b')).status, 403, 'only an admin posts it'); const an = await j(await post('/announcement', { text: ' Dinner at 6 ' }, 'a')); assert.equal(an.text, 'Dinner at 6'); assert.equal((await j(await call('/announcement', {}, 'b'))).by, 'Ann'); await post('/announcement', { text: '' }, 'a'); assert.equal(await j(await call('/announcement', {}, 'b')), null, 'an empty one clears it');
+ assert.deepEqual((await j(await call('/prefs', {}, 'b'))).quiet, { on: false, from: '21:00', to: '07:00' }); const qp = await j(await post('/prefs', { quiet: { on: true, from: '20:30', to: 'nope' } }, 'b')); assert.deepEqual(qp.quiet, { on: true, from: '20:30', to: '07:00' }, 'a bad time is ignored'); assert.equal((await j(await call('/prefs', {}, 'c'))).quiet.on, false, 'each person has their own');
+
ctl.abort(); console.log('chat ok');
} catch (e) { console.error(e); process.exitCode = 1; }
hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 80);