diff --git a/README.md b/README.md index f8eb23c..b3b89bf 100644 --- a/README.md +++ b/README.md @@ -180,5 +180,5 @@ Files are kept in the data folder under `mystore/` (up to 1 GB each; extensions **iPad and Screen Time:** Settings, Admin, *iPad and Screen Time* explains the simple setup (Screen Time, Content Restrictions, Web Content: **Limit Adult Websites**, and add only the hub's address under *Always Allow*), lists the web addresses the app needs (the hub, plus the document editor for the Office tab) for anyone using **Allowed Websites Only**, shows other addresses devices were actually seen reaching, and has a *Check this device* button to run on the iPad. The hub sends a report-only Content-Security-Policy (nothing is blocked) so devices report any other address a page loads, and pictures from other sites (podcast search covers, Plex Home pictures, a Verse By Verse cover, search answers) are passed through the hub (`/api/img`) so the browser only talks to the hub for them. Extensions and Sites you add may still reach their own addresses. If the document editor is put behind the same address as the hub (a reverse proxy path), even that one drops off the list. -**Chat** (turn it on per person in the admin screen): the family's own messenger, with the same look as the Signal tab. Direct chats and groups between people who have an account here, text with links and emoji, attachments (up to 100 MB each; pictures, audio and video show in the chat), replies, reactions, edit and delete for everyone, unread counts on the menu and the tab title, search (chats and messages), drafts that survive closing the app, mute and archive, live delivery while the app is open and a phone push when it is not (the same notification devices as reminders, and muted chats stay quiet). Messages and files are kept readable on this server in `data/chat`; end-to-end encryption is not built. Planned next: read ticks, typing, disappearing messages, mentions, pins, voice notes, link previews, polls (see `docs/v2-roadmap.md`). +**Chat** (turn it on per person in the admin screen): the family's own messenger, with the same look as the Signal tab. Direct chats and groups between people who have an account here, text with links and emoji, attachments (up to 100 MB each; pictures, audio and video show in the chat), replies, reactions, edit and delete for everyone, unread counts on the menu and the tab title, search (chats and messages), drafts that survive closing the app, mute and archive, live delivery while the app is open and a phone push when it is not (the same notification devices as reminders, and muted chats stay quiet). Messages and files are kept readable on this server in `data/chat`; end-to-end encryption is not built. Also: sent, delivered and read ticks (everyone, in a group), typing and who is online, disappearing messages (a timer per chat), @mentions (a mentioned person is alerted even in a muted group), pinned messages, forwarding, voice messages with a waveform, link previews (the server reads the page, so devices never contact the site), polls (single or multiple choice, with who voted), an emoji picker, turn a message into a task, a shopping item or a calendar event, *Share to Chat* from a recipe or a song, quiet hours for phone alerts, and a family announcement on Home that an admin posts from Chat settings. Not built: a GIF picker, sharing a location (the hub blocks location access), and a parent setting a child's quiet hours (see `docs/v2-roadmap.md`). diff --git a/chat.js b/chat.js index 8a36f5d..2a0cb19 100644 --- a/chat.js +++ b/chat.js @@ -6,6 +6,7 @@ const { pipeline } = require('stream/promises'); const atomicWrite = require('./atomic'); const users = require('./users'); const push = require('./push'); +const cal = require('./cal'); // (its public-address check, for link previews) const { readBody } = require('./body'); const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), ROOT = () => path.join(DATA_DIR, 'chat'); @@ -30,51 +31,97 @@ const msgs = id => { let m = msgsC.get(id); if (!m) { m = read(msFile(id), []); const saveMsgs = id => write(msFile(id), msgs(id)); // each person's own marks: how far they have read, and which chats they muted or archived const stFile = uid => path.join(users.dirOf(uid), 'chat-state.json'); -const stateOf = uid => { const s = read(stFile(uid), {}); return { read: s.read || {}, muted: s.muted || [], archived: s.archived || [] }; }; +const stateOf = uid => { const s = read(stFile(uid), {}); return { read: s.read || {}, delivered: s.delivered || {}, muted: s.muted || [], archived: s.archived || [], quiet: s.quiet || { on: false, from: '21:00', to: '07:00' } }; }; const saveState = (uid, s) => write(stFile(uid), s); +/** Is it quiet time for this person now (in their own time zone, from their reminder settings)? Phone alerts wait; the app still shows messages. */ +function quietNow(uid) { + const q = stateOf(uid).quiet; if (!q.on) return false; let tz = 'UTC'; try { tz = JSON.parse(fs.readFileSync(path.join(users.dirOf(uid), 'push.json'), 'utf8')).tz || 'UTC'; } catch { /* the default */ } + const hm = push.localNow(tz).hm; return q.from <= q.to ? hm >= q.from && hm < q.to : hm >= q.from || hm < q.to; +} const nameOf = uid => { const u = users.get(uid); return u ? u.name : 'Someone'; }; const live = uid => { const u = users.get(uid); return !!u && !u.disabled && users.can(u, 'chat'); }; const people = () => users.all().filter(u => !u.disabled && users.can(u, 'chat')).map(u => ({ id: u.id, name: u.name })); const convOf = (id, me) => { const c = convs().find(x => x.id === id); if (!c || !c.members.includes(me.id)) throw fail(404, 'Conversation not found'); return c; }; -const unreadIn = (c, uid, st) => { const r = st.read[c.id] || 0; return msgs(c.id).filter(m => m.ts > r && m.from !== uid && !m.deleted).length; }; +const unreadIn = (c, uid, st) => { const r = st.read[c.id] || 0; return msgs(c.id).filter(m => m.ts > r && m.from !== uid && !m.deleted && !m.sys).length; }; const totalUnread = uid => { const st = stateOf(uid); return convs().filter(c => c.members.includes(uid) && !st.muted.includes(c.id) && !st.archived.includes(c.id)).reduce((n, c) => n + unreadIn(c, uid, st), 0); }; -const preview = m => m ? (m.deleted ? 'This message was deleted' : m.text ? str(m.text, 120) : m.att && m.att.length ? '๐Ÿ“Ž ' + (m.att[0].name || 'Attachment') : '') : ''; +const preview = m => m ? (m.deleted ? 'This message was deleted' : m.sys ? str(m.text, 120) : m.poll ? '๐Ÿ“Š ' + m.poll.q : m.att && m.att[0] && m.att[0].voice && !m.text ? '๐ŸŽค Voice message' : m.text ? str(m.text, 120) : m.att && m.att.length ? '๐Ÿ“Ž ' + (m.att[0].name || 'Attachment') : '') : ''; // ---- live updates (server-sent events), one stream per open app +const annFile = () => path.join(ROOT(), 'announcement.json'); +const lastSeen = new Map(); // user id -> when they last did anything (kept in memory) const clients = new Map(); // user id -> Set of responses const online = uid => (clients.get(uid) || new Set()).size > 0; function emit(uids, ev, per) { for (const uid of uids) for (const res of clients.get(uid) || []) { try { res.write('data: ' + JSON.stringify(per ? per(uid, ev) : ev) + '\n\n'); } catch { /* closed */ } } } -const view = (c, uid, st) => { const last = msgs(c.id).slice(-1)[0], others = c.members.filter(x => x !== uid); - return { id: c.id, type: c.type, name: c.type === 'group' ? c.name : nameOf(others[0] || uid), members: c.members.map(x => ({ id: x, name: nameOf(x) })), by: c.by, created: c.created, ts: last ? last.ts : c.created, preview: last ? (c.type === 'group' && last.from !== uid ? nameOf(last.from) + ': ' : last.from === uid ? 'You: ' : '') + preview(last) : '', unread: unreadIn(c, uid, st), muted: st.muted.includes(c.id), archived: st.archived.includes(c.id) }; }; -const msgView = (m, uid) => ({ id: m.id, ts: m.ts, from: m.from, name: nameOf(m.from), out: m.from === uid, text: m.text, att: m.att, quote: m.quote, react: m.react, edited: m.edited, deleted: m.deleted }); +const view = (c, uid, st) => { const last = msgs(c.id).slice(-1)[0], others = c.members.filter(x => x !== uid), o = c.type === 'direct' ? others[0] : null; + return { ttl: c.ttl || 0, ...(o ? { presence: { online: online(o), seen: lastSeen.get(o) || 0 } } : {}), id: c.id, type: c.type, name: c.type === 'group' ? c.name : nameOf(others[0] || uid), members: c.members.map(x => ({ id: x, name: nameOf(x) })), by: c.by, created: c.created, ts: last ? last.ts : c.created, preview: last ? (last.sys ? '' : c.type === 'group' && last.from !== uid ? nameOf(last.from) + ': ' : last.from === uid ? 'You: ' : '') + preview(last) : '', unread: unreadIn(c, uid, st), muted: st.muted.includes(c.id), archived: st.archived.includes(c.id) }; }; +/** sent, delivered or read: for what you sent, from what the others have read and received (everyone, in a group). */ +const statusOf = (m, c, uid, sts) => { if (m.from !== uid || m.sys) return undefined; const o = c.members.filter(x => x !== uid); if (!o.length) return 'sent'; if (o.every(x => (sts[x].read[c.id] || 0) >= m.ts)) return 'read'; if (o.every(x => (sts[x].delivered[c.id] || 0) >= m.ts)) return 'delivered'; return 'sent'; }; +const stsOf = c => Object.fromEntries(c.members.map(x => [x, stateOf(x)])); +const msgView = (m, uid, c, sts) => ({ id: m.id, ts: m.ts, from: m.from, name: m.sys ? '' : nameOf(m.from), out: m.from === uid, text: m.text, att: m.att, quote: m.quote, react: m.react, edited: m.edited, deleted: m.deleted, sys: m.sys, fwd: m.fwd, mentions: m.mentions, exp: m.exp, link: m.link, poll: m.poll ? { ...m.poll, votes: m.votes || {} } : undefined, ...(c && sts ? { status: statusOf(m, c, uid, sts) } : {}) }); +/** Someone has received or read the chat up to now: the senders' ticks change. */ +function markDelivered(uid, c, also) { const st = stateOf(uid), last = msgs(c.id).slice(-1)[0]; if (!last) return; let ch = false; if ((st.delivered[c.id] || 0) < last.ts) { st.delivered[c.id] = last.ts; ch = true; } if (also && (st.read[c.id] || 0) < last.ts) { st.read[c.id] = last.ts; ch = true; } if (ch) { saveState(uid, st); emit(c.members.filter(x => x !== uid), { t: 'receipt', conv: c.id }); } } +const TTLS = [0, 3600, 86400, 604800], ttlWords = s => s === 3600 ? '1 hour' : s === 86400 ? '1 day' : s === 604800 ? '1 week' : 'off'; +/** Disappearing messages that are due go, with their files. */ +function purge() { + const now = Date.now(); let filesChanged = false; + for (const c of convs()) { const list = msgs(c.id), due = list.filter(m => m.exp && m.exp <= now); if (!due.length) continue; + for (const m of due) for (const a of m.att || []) { fs.rm(blob(a.id), { force: true }, () => {}); delete files()[a.id]; filesChanged = true; } + const keep = list.filter(m => !(m.exp && m.exp <= now)); list.length = 0; list.push(...keep); c.pins = (c.pins || []).filter(id => keep.some(m => m.id === id)); saveMsgs(c.id); saveConvs(); emit(c.members, { t: 'upd', conv: c.id }); } + if (filesChanged) saveFiles(); +} +setInterval(() => { try { purge(); } catch (e) { console.warn('[chat]', e.message); } }, 60e3).unref(); +const mentionsIn = (text, c) => c.type !== 'group' ? [] : c.members.filter(uid => { const n = nameOf(uid); return n && new RegExp('(^|\\s)@' + n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?![\\w])', 'i').test(text); }); function send(me, b) { const c = convOf(String(b.conv || ''), me), text = str(b.text, MAX_TEXT).trim(), ids = (Array.isArray(b.files) ? b.files : []).map(String).slice(0, MAX_FILES), att = []; - for (const id of ids) { const f = files()[id]; if (!f || f.conv !== c.id || f.by !== me.id || f.attached) throw fail(400, 'An attachment is not available (upload it again)'); att.push({ id, name: f.name, type: f.type, size: f.size }); } + for (const id of ids) { const f = files()[id]; if (!f || f.conv !== c.id || f.by !== me.id || f.attached) throw fail(400, 'An attachment is not available (upload it again)'); att.push({ id, name: f.name, type: f.type, size: f.size, ...(f.voice ? { voice: true, wave: f.wave, dur: f.dur } : {}) }); } if (!text && !att.length) throw fail(400, 'Write something or attach a file'); - const list = msgs(c.id); let quote; - if (b.quote) { const q = list.find(x => x.id === String(b.quote)); if (q) quote = { id: q.id, from: q.from, name: nameOf(q.from), text: q.deleted ? '' : str(q.text || (q.att && q.att[0] ? q.att[0].name : ''), 200) }; } - const last = list[list.length - 1], m = { id: newId(), ts: Math.max(Date.now(), last ? last.ts + 1 : 0), from: me.id, text, att: att.length ? att : undefined, quote, react: {} }; - list.push(m); if (list.length > MAX_MSGS) list.splice(0, list.length - MAX_MSGS); att.forEach(a => { files()[a.id].attached = true; }); saveMsgs(c.id); if (att.length) saveFiles(); - const body = (c.type === 'group' ? me.name + ': ' : '') + (text ? str(text, 140) : '๐Ÿ“Ž ' + att[0].name), title = c.type === 'group' ? c.name : me.name; - emit(c.members, { t: 'msg', conv: c.id, id: m.id }, uid => ({ t: 'msg', conv: c.id, id: m.id, out: uid === me.id, name: title, preview: body, unread: totalUnread(uid) })); - for (const uid of c.members) if (uid !== me.id && !online(uid) && !stateOf(uid).muted.includes(c.id) && live(uid)) push.notify(uid, { title, body, url: '#/chat/' + encodeURIComponent(c.id), tag: 'chat:' + c.id }).catch(() => {}); - return msgView(m, me.id); + let quote; if (b.quote) { const q = msgs(c.id).find(x => x.id === String(b.quote)); if (q) quote = { id: q.id, from: q.from, name: nameOf(q.from), text: q.deleted ? '' : str(q.text || (q.att && q.att[0] ? q.att[0].name : ''), 200) }; } + return addMsg(me, c, { text, att, quote }); +} +/** Put a message in a chat and tell everyone: the live stream, and a phone notification for those who are away. */ +function addMsg(me, c, { text, att = [], quote, fwd, sys, poll }) { + const list = msgs(c.id), last = list[list.length - 1], ts = Math.max(Date.now(), last ? last.ts + 1 : 0), m = { id: newId(), ts, from: sys ? null : me.id, text, att: att.length ? att : undefined, quote, fwd, sys: sys || undefined, poll, react: {}, mentions: sys ? undefined : (mentionsIn(text, c).length ? mentionsIn(text, c) : undefined), exp: c.ttl && !sys ? ts + c.ttl * 1000 : undefined }; + list.push(m); if (list.length > MAX_MSGS) list.splice(0, list.length - MAX_MSGS); att.forEach(a => { if (files()[a.id]) files()[a.id].attached = true; }); saveMsgs(c.id); if (att.length) saveFiles(); + const body = sys ? text : (c.type === 'group' ? me.name + ': ' : '') + (poll ? '๐Ÿ“Š ' + poll.q : text ? str(text, 140) : att[0] && att[0].voice ? '๐ŸŽค Voice message' : '๐Ÿ“Ž ' + att[0].name), title = c.type === 'group' ? c.name : me.name; + emit(c.members, { t: 'msg', conv: c.id, id: m.id }, uid => ({ t: 'msg', conv: c.id, id: m.id, out: uid === me.id, sys: !!sys, mention: !!(m.mentions && m.mentions.includes(uid)), name: title, preview: body, unread: totalUnread(uid) })); + for (const uid of c.members) if (online(uid) && (sys || uid !== me.id)) markDelivered(uid, c); // (what an open app has received counts as delivered) + if (!sys) for (const uid of c.members) if (uid !== me.id && !online(uid) && live(uid) && (!stateOf(uid).muted.includes(c.id) || (m.mentions && m.mentions.includes(uid))) && !quietNow(uid)) push.notify(uid, { title, body: (m.mentions && m.mentions.includes(uid) ? '@ ' : '') + body, url: '#/chat/' + encodeURIComponent(c.id), tag: 'chat:' + c.id }).catch(() => {}); + if (!sys) { const url = /https:\/\/[^\s<>"']+/.exec(text || ''); if (url) previewOf(url[0].replace(/[.,;:!?)\]]+$/, '')).then(p => { if (p && msgs(c.id).includes(m) && !m.deleted) { m.link = p; saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: m.id }); } }).catch(() => {}); } + return msgView(m, me.id, c, stsOf(c)); +} + +// ---- link previews: the server reads the page's own title, description and picture (public https addresses only), so nobody's device contacts the site +const metaOf = (html, names) => { for (const n of names) { const m = new RegExp(']+(?:property|name)=["\']' + n + '["\'][^>]*>', 'i').exec(html), c = m && /content=["']([^"']*)["']/i.exec(m[0]) || (m && /content=([^\s>]+)/i.exec(m[0])); if (c && c[1].trim()) return c[1]; } return ''; }; +const unent = t => String(t).replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|#39);/gi, (x, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'" }[e.toLowerCase()] || (e[1] === 'x' || e[1] === 'X' ? String.fromCodePoint(parseInt(e.slice(2), 16)) : e[0] === '#' ? String.fromCodePoint(+e.slice(1)) : x))).replace(/\s+/g, ' ').trim(); +/** The preview a page describes about itself: { url, site, title, desc, image } or null. */ +function parsePreview(html, base) { + const title = unent(metaOf(html, ['og:title', 'twitter:title']) || (/]*>([\s\S]*?)<\/title>/i.exec(html) || [])[1] || ''), desc = unent(metaOf(html, ['og:description', 'twitter:description', 'description'])), site = unent(metaOf(html, ['og:site_name'])); + let image = metaOf(html, ['og:image', 'og:image:url', 'twitter:image']); try { image = image ? new URL(unent(image), base).href : ''; if (!/^https:/.test(image)) image = ''; } catch { image = ''; } + if (!title && !desc) return null; return { url: base, site: site.slice(0, 80), title: title.slice(0, 200), desc: desc.slice(0, 300), image: image.slice(0, 500) }; +} +const previews = new Map(); +async function previewOf(raw) { + const hit = previews.get(raw); if (hit && Date.now() - hit.t < 3600e3) return hit.p; + let url; try { url = new URL(raw); } catch { return null; } let r; + for (let hop = 0; hop < 4; hop++) { await cal.assertPublic(url); r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0 (compatible; NE-Hub/1.0; link preview)', accept: 'text/html' }, redirect: 'manual', signal: AbortSignal.timeout(6000) }); if ([301, 302, 303, 307, 308].includes(r.status) && r.headers.get('location')) { url = new URL(r.headers.get('location'), url); try { await r.body?.cancel(); } catch {} continue; } break; } + let p = null; if (r.ok && /html/i.test(r.headers.get('content-type') || '')) { const chunks = []; let n = 0; for await (const c of r.body) { n += c.length; chunks.push(c); if (n > 400000) break; } p = parsePreview(Buffer.concat(chunks).toString('utf8'), url.href); } + previews.set(raw, { t: Date.now(), p }); if (previews.size > 200) previews.delete(previews.keys().next().value); return p; } async function handle(req, res) { const u = new URL(req.url, 'http://x'), p = (() => { try { return decodeURIComponent(u.pathname.slice('/api/chat'.length)) || '/'; } catch { return '/'; } })(), me = users.current(), q = u.searchParams; // (a chat id has colons, which a browser writes as %3A) try { - let m; + let m; lastSeen.set(me.id, Date.now()); purge(); if (p === '/status') return json(res, 200, { ok: true, me: { id: me.id, name: me.name }, unread: totalUnread(me.id) }); if (p === '/people') return json(res, 200, people().filter(x => x.id !== me.id)); if (p === '/events') { res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive', 'x-accel-buffering': 'no' }); res.write(': hello\n\n'); const set = clients.get(me.id) || new Set(); set.add(res); clients.set(me.id, set); const ka = setInterval(() => { try { res.write(': \n\n'); } catch { /* closed */ } }, 25000); - res.on('close', () => { clearInterval(ka); set.delete(res); if (!set.size) clients.delete(me.id); }); return; + res.on('close', () => { clearInterval(ka); set.delete(res); if (!set.size) clients.delete(me.id); lastSeen.set(me.id, Date.now()); }); return; } if (p === '/convs' && req.method === 'GET') { const st = stateOf(me.id); return json(res, 200, { convs: convs().filter(c => c.members.includes(me.id)).map(c => view(c, me.id, st)).sort((a, b) => b.ts - a.ts), unread: totalUnread(me.id) }); } if (p === '/new' && req.method === 'POST') { @@ -99,8 +146,8 @@ async function handle(req, res) { saveConvs(); emit([...new Set([...before, ...c.members])], { t: 'conv', conv: c.id }); return json(res, 200, c.members.includes(me.id) ? { conv: view(c, me.id, stateOf(me.id)) } : { left: true }); } if ((m = /^\/conv\/(\w[\w:]*)$/.exec(p)) && req.method === 'GET') { - const c = convOf(m[1], me), all = msgs(c.id), before = +q.get('before') || Infinity, eligible = all.filter(x => x.ts < before), page = eligible.slice(-PAGE); - return json(res, 200, { conv: view(c, me.id, stateOf(me.id)), msgs: page.map(x => msgView(x, me.id)), more: eligible.length > page.length, names: Object.fromEntries(c.members.map(x => [x, nameOf(x)])) }); + const c = convOf(m[1], me), all = msgs(c.id), before = +q.get('before') || Infinity, eligible = all.filter(x => x.ts < before), page = eligible.slice(-PAGE), sts = stsOf(c); markDelivered(me.id, c); + return json(res, 200, { conv: view(c, me.id, stateOf(me.id)), pinned: (c.pins || []).map(id => all.find(x => x.id === id)).filter(x => x && !x.deleted).map(x => msgView(x, me.id, c, sts)), msgs: page.map(x => msgView(x, me.id, c, sts)), more: eligible.length > page.length, names: Object.fromEntries(c.members.map(x => [x, nameOf(x)])) }); } if (p === '/send' && req.method === 'POST') return json(res, 200, { msg: send(me, JSON.parse(await readBody(req, 5e4) || '{}')) }); if (p === '/upload' && req.method === 'POST') { // the body is the file; ?conv= says which chat it is for @@ -108,7 +155,8 @@ async function handle(req, res) { const id = newId(), type = /^[\w.+-]+\/[\w.+-]+$/.test(String(req.headers['content-type'] || '').split(';')[0].trim()) ? String(req.headers['content-type']).split(';')[0].trim().toLowerCase() : 'application/octet-stream', name = path.basename(str(q.get('name'), 160)).replace(/[\\/:*?"<>|]+/g, '_') || 'file'; fs.mkdirSync(path.dirname(blob(id)), { recursive: true, mode: 0o700 }); let n = 0; req.on('data', d => { n += d.length; if (n > MAX_FILE) req.destroy(fail(413, 'That file is too large')); }); try { await pipeline(req, fs.createWriteStream(blob(id) + '.part', { mode: 0o600 })); } catch (e) { fs.rm(blob(id) + '.part', { force: true }, () => {}); throw e.code === 413 ? e : fail(400, 'The upload did not finish'); } - fs.renameSync(blob(id) + '.part', blob(id)); files()[id] = { conv: c.id, by: me.id, name, type, size: n, ts: Date.now() }; saveFiles(); return json(res, 200, { id, name, type, size: n }); + fs.renameSync(blob(id) + '.part', blob(id)); const voice = q.get('voice') === '1' && /^audio\//.test(type), wave = voice ? String(q.get('wave') || '').split(',').map(Number).filter(v => Number.isFinite(v)).slice(0, 64).map(v => Math.max(0, Math.min(100, Math.round(v)))) : undefined, dur = voice ? Math.max(0, Math.min(900, Math.round(+q.get('dur') || 0))) : undefined; + files()[id] = { conv: c.id, by: me.id, name, type, size: n, ts: Date.now(), ...(voice ? { voice: true, wave, dur } : {}) }; saveFiles(); return json(res, 200, { id, name, type, size: n, voice: voice || undefined }); } if ((m = /^\/file\/([0-9a-f]{16})$/.exec(p)) && req.method === 'GET') { const f = files()[m[1]]; if (!f) throw fail(404, 'Not found'); convOf(f.conv, me); let st; try { st = fs.statSync(blob(m[1])); } catch { throw fail(404, 'Not found'); } @@ -129,15 +177,49 @@ async function handle(req, res) { else { x.deleted = true; x.text = ''; for (const a of x.att || []) { fs.rm(blob(a.id), { force: true }, () => {}); delete files()[a.id]; } x.att = undefined; x.react = {}; saveFiles(); } saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true }); } - if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me), st = stateOf(me.id), last = msgs(c.id).slice(-1)[0]; st.read[c.id] = last ? last.ts : Date.now(); saveState(me.id, st); emit([me.id], { t: 'read', conv: c.id, unread: totalUnread(me.id) }); return json(res, 200, { ok: true, unread: totalUnread(me.id) }); } + if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me), st = stateOf(me.id), last = msgs(c.id).slice(-1)[0]; st.read[c.id] = last ? last.ts : Date.now(); st.delivered[c.id] = Math.max(st.delivered[c.id] || 0, st.read[c.id]); saveState(me.id, st); emit([me.id], { t: 'read', conv: c.id, unread: totalUnread(me.id) }); emit(c.members.filter(x => x !== me.id), { t: 'receipt', conv: c.id }); return json(res, 200, { ok: true, unread: totalUnread(me.id) }); } if ((p === '/conv/mute' || p === '/conv/archive') && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me), st = stateOf(me.id), k = p.endsWith('mute') ? 'muted' : 'archived'; st[k] = st[k].filter(x => x !== c.id); if (b.on) st[k].push(c.id); saveState(me.id, st); emit([me.id], { t: 'conv', conv: c.id, unread: totalUnread(me.id) }); return json(res, 200, { ok: true }); } + if (p === '/announcement' && req.method === 'GET') return json(res, 200, read(annFile(), null)); + if (p === '/announcement' && req.method === 'POST') { // the family board on Home: an admin posts or clears it + if (me.role !== 'admin') throw fail(403, 'Only an admin can change the announcement'); const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = str(b.text, 300).trim(); + if (!text) { fs.rmSync(annFile(), { force: true }); emit(people().map(x => x.id), { t: 'announce' }); return json(res, 200, null); } + const a = { id: newId(), text, by: me.name, at: Date.now() }; write(annFile(), a); emit(people().map(x => x.id), { t: 'announce' }); return json(res, 200, a); + } + if (p === '/prefs' && req.method === 'GET') return json(res, 200, { quiet: stateOf(me.id).quiet }); + if (p === '/prefs' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), st = stateOf(me.id), hm = /^([01]\d|2[0-3]):[0-5]\d$/; if (b.quiet) st.quiet = { on: !!b.quiet.on, from: hm.test(b.quiet.from) ? b.quiet.from : st.quiet.from, to: hm.test(b.quiet.to) ? b.quiet.to : st.quiet.to }; saveState(me.id, st); return json(res, 200, { quiet: st.quiet }); } + if (p === '/poll' && req.method === 'POST') { + const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.conv || ''), me), q = str(b.question, 200).trim(), opts = (Array.isArray(b.options) ? b.options : []).map(o => str(o, 100).trim()).filter(Boolean).slice(0, 8); + if (!q) throw fail(400, 'Ask a question'); if (opts.length < 2) throw fail(400, 'Give at least two choices'); + const msg = addMsg(me, c, { text: '', poll: { q, options: opts.map((t, i) => ({ id: String(i), text: t })), multi: !!b.multi } }); return json(res, 200, { msg }); + } + if (p === '/vote' && req.method === 'POST') { + const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x || !x.poll || x.deleted) throw fail(404, 'Poll not found'); if (x.poll.closed) throw fail(400, 'This poll is closed'); + const ids = [...new Set((Array.isArray(b.opts) ? b.opts : []).map(String))].filter(id => x.poll.options.some(o => o.id === id)); if (!x.poll.multi) ids.splice(1); + x.votes = x.votes || {}; if (ids.length) x.votes[me.id] = ids; else delete x.votes[me.id]; saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true }); + } + if (p === '/poll/close' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x || !x.poll) throw fail(404, 'Poll not found'); if (x.from !== me.id) throw fail(403, 'Only the person who asked can close it'); x.poll.closed = true; saveMsgs(c.id); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true }); } + if (p === '/typing' && req.method === 'POST') { const c = convOf(String(JSON.parse(await readBody(req, 1e4) || '{}').conv || ''), me); emit(c.members.filter(x => x !== me.id), { t: 'typing', conv: c.id, from: me.id, name: me.name }); return json(res, 200, { ok: true }); } + if (p === '/conv/ttl' && req.method === 'POST') { // disappearing messages: from now on, new messages go after this long + const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me), ttl = +b.ttl || 0; if (!TTLS.includes(ttl)) throw fail(400, 'Choose off, 1 hour, 1 day or 1 week'); + if ((c.ttl || 0) !== ttl) { c.ttl = ttl; saveConvs(); addMsg(me, c, { sys: true, text: ttl ? `${me.name} set messages to disappear after ${ttlWords(ttl)}` : `${me.name} turned off disappearing messages` }); } + emit(c.members, { t: 'conv', conv: c.id }); return json(res, 200, { ok: true, ttl }); + } + if (p === '/pin' && req.method === 'POST') { + const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x || x.deleted || x.sys) throw fail(404, 'Message not found'); + c.pins = (c.pins || []).filter(id => id !== x.id); if (b.on) { c.pins.push(x.id); if (c.pins.length > 5) c.pins.shift(); } saveConvs(); emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true, pins: c.pins }); + } + if (p === '/forward' && req.method === 'POST') { // a copy of a message (and its files) into another chat + const b = JSON.parse(await readBody(req, 1e4) || '{}'), from = convOf(String(b.from || ''), me), to = convOf(String(b.to || ''), me), x = msgs(from.id).find(y => y.id === String(b.msg)); if (!x || x.deleted || x.sys) throw fail(404, 'Message not found'); + const att = (x.att || []).map(a => { const id = newId(); try { fs.copyFileSync(blob(a.id), blob(id)); } catch { return null; } files()[id] = { conv: to.id, by: me.id, name: a.name, type: a.type, size: a.size, ts: Date.now(), attached: true }; return { id, name: a.name, type: a.type, size: a.size, ...(a.voice ? { voice: true, wave: a.wave, dur: a.dur } : {}) }; }).filter(Boolean); + if (att.length) saveFiles(); return json(res, 200, { msg: addMsg(me, to, { text: x.text || '', att, fwd: { name: nameOf(x.from) } }) }); + } if (p === '/search' && req.method === 'GET') { const needle = str(q.get('q'), 100).trim().toLowerCase(); if (needle.length < 2) return json(res, 200, { results: [] }); const out = [], st = stateOf(me.id); - for (const c of convs().filter(x => x.members.includes(me.id))) { const cv = view(c, me.id, st); for (const x of msgs(c.id)) if (!x.deleted && x.text && x.text.toLowerCase().includes(needle)) out.push({ conv: c.id, convName: cv.name, msg: msgView(x, me.id) }); } + for (const c of convs().filter(x => x.members.includes(me.id))) { const cv = view(c, me.id, st); for (const x of msgs(c.id)) if (!x.deleted && !x.sys && x.text && x.text.toLowerCase().includes(needle)) out.push({ conv: c.id, convName: cv.name, msg: msgView(x, me.id) }); } return json(res, 200, { results: out.sort((a, b) => b.msg.ts - a.msg.ts).slice(0, 40) }); } return json(res, 404, { error: 'Not found' }); } catch (e) { if (res.headersSent) return res.destroy(); return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); } } -module.exports = { handle, totalUnread }; +module.exports = { handle, totalUnread, purge, parsePreview }; diff --git a/docs/v2-roadmap.md b/docs/v2-roadmap.md index ac181fc..0c1a57c 100644 --- a/docs/v2-roadmap.md +++ b/docs/v2-roadmap.md @@ -15,29 +15,29 @@ A messenger inside the hub for the family, so family chats do not need Signal. E - [x] Live delivery in the open app, push notifications in the background - [x] Mute and archive per chat -**Phase 2: feels like Signal (M)** +**Phase 2: feels like Signal (M): done** - [x] Edit and delete for everyone -- [ ] Sent, delivered and read ticks -- [ ] Typing indicators and online presence -- [ ] Disappearing messages (a timer per chat) -- [ ] @mentions in groups, pinned messages, forward a message +- [x] Sent, delivered and read ticks +- [x] Typing indicators and online presence +- [x] Disappearing messages (a timer per chat) +- [x] @mentions in groups, pinned messages, forward a message -**Phase 3: richer messages (M)** -- [ ] Voice notes (record and play, with a waveform) -- [ ] Link previews (fetched safely by the server) -- [ ] Polls +**Phase 3: richer messages (M): done except the GIF picker and shared places** +- [x] Voice notes (record and play, with a waveform) +- [x] Link previews (fetched safely by the server) +- [x] Polls - [ ] GIF or sticker picker (optional) - [ ] Share a place as a link -**Family extras (M)** -- [ ] Turn a message into a task, an event or a shopping item in one tap -- [ ] Share a song, recipe, book or podcast episode from the hub into a chat -- [ ] Pinned family announcement on Home ("dinner at 6") -- [ ] Kid accounts can only message family; parents can set quiet hours +**Family extras (M): mostly done** +- [x] Turn a message into a task, an event or a shopping item in one tap +- [x] Share a song, recipe, book or podcast episode from the hub into a chat +- [x] Pinned family announcement on Home ("dinner at 6") +- [~] Kid accounts can only message family (already true: only hub accounts can chat); quiet hours are set by each person for themselves (a parent setting them for a child is not built); sharing so far covers recipes and songs, not books or podcast episodes **Privacy (decide first)** - [ ] Open question: server-side storage on the family's own machine to start (TLS + encrypted disk), or end-to-end encryption from day one? Starting without E2EE and adding it later is much harder than choosing now. -- [ ] Per-person notification and privacy settings +- [~] Per-person notification settings (mute, quiet hours; the push kind can be turned off in the reminders settings) Not planned: voice and video calls, stories, usernames and phone-number lookup, sealed sender. @@ -66,6 +66,7 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup, - [ ] **Movies & TV 2.0 (L):** mixes, favorites and stats like Music; a shared watchlist and a "movie night" vote - [ ] **Podcast and audiobook stats (S):** listening history like Music has - [ ] **Reading goals (S):** a family reading challenge with streaks; export notes and highlights +- [ ] **Browse and search by genre (M):** in the Store, search books and audiobooks by genre (fiction, mystery, history, children's, Christian and so on) across the sources, with genre buttons, and the same genre filter on your own library and shelf. Needs each source's genre data (Gutenberg subjects, Open Library subjects, Standard Ebooks tags, Smashwords categories, LibriVox genres), normalised into one list. - [ ] **Casting (M):** pick AirPlay or Cast targets for audio ## 6. Reliability (the admin's side) diff --git a/public/app.js b/public/app.js index 3d2b5ef..6a681e2 100644 --- a/public/app.js +++ b/public/app.js @@ -98,6 +98,7 @@ const ICONS = { arrup: '', arrdn: '', bell: '', + clock: '', chef: '', people: '', search: '', @@ -348,6 +349,7 @@ function viewHome() { $('#view').innerHTML = `

${greet}

${today}

+
${allowed('search') ? `${icon('search')}Search tasks, events, files, music` : ''} ${allowed('music') ? '
' : ''} ${evening ? '
' : ''} @@ -359,6 +361,7 @@ function viewHome() { ${evening ? '' : '
'}
${homeApps()}
`; if (typeof stHomeExt === 'function' && allowed('store')) stHomeExt($('#homeext')); + if (typeof chAnnounceCard === 'function') chAnnounceCard($('#annc')); if (typeof homeResume === 'function') homeResume($('#resume')); renderNpCard(); // the live Now Playing card (it follows the song and has the controls) $('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => { diff --git a/public/chat-view.js b/public/chat-view.js index cc3c4dc..f48d8a8 100644 --- a/public/chat-view.js +++ b/public/chat-view.js @@ -1,6 +1,6 @@ '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 ch = { convs: [], cur: null, curConv: null, root: null, es: null, unread: 0, reply: null, tray: [], names: {}, people: null, showArchived: false, sendQ: null, found: null, typing: null, typedAt: 0, pins: [] }; 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) }); @@ -22,8 +22,10 @@ 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 (!looking) { toast((ev.name || 'Chat') + ': ' + (ev.preview || '')); if (typeof sgAlerts === 'function' && sgAlerts().sound && typeof sgChime === 'function') sgChime(); } } + if (ev.t === 'announce' && typeof chAnnounceCard === 'function') chAnnounceCard(document.getElementById('annc')); + if (ev.t === 'typing') { if (chMounted() && ch.cur === ev.conv) { ch.typing = { conv: ev.conv, name: ev.name, until: Date.now() + 4500 }; chPaintSub(); setTimeout(chPaintSub, 4600); } return; } if (!chMounted()) return; clearTimeout(chEvT); chEvT = setTimeout(chRefresh, 150); } @@ -31,9 +33,9 @@ function chEvent(ev) { function viewChat(args = []) { ch.cur = args[0] ? decodeURIComponent(args[0]) : null; return chMount($('#view')); } async function chMount(root) { ch.root = root; root.innerHTML = '

Chat

Loadingโ€ฆ

'; - root.innerHTML = `

Chat

+ root.innerHTML = `

Chat

`; - 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 || '') ? `${esc(a.name || 'photo')}` + : 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', `
+
${[1, 2].map(i => ``).join('')}
+
+
+
`); + 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', '', `
+
+ ${admin ? `

Family announcement

Shown at the top of everyone's Home. Leave it empty and save to take it down.

` : ''}
+
`); + 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
+
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) => `
  1. ${s.title ? `${esc(s.title)} ` : ''}${esc(s.text).replace(/\n/g, '
    ')}
  2. `).join('') || '
  3. No steps.
  4. '}
@@ -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);