diff --git a/README.md b/README.md
index ec3fd9b..f8eb23c 100644
--- a/README.md
+++ b/README.md
@@ -180,3 +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`).
+
diff --git a/chat.js b/chat.js
new file mode 100644
index 0000000..8a36f5d
--- /dev/null
+++ b/chat.js
@@ -0,0 +1,143 @@
+// Family Chat: a messenger built into the hub, for the people who have an account here. Direct chats and groups, text, attachments, replies, reactions, edit and delete,
+// unread counts, search, mute and archive, live delivery (server-sent events) and phone push when someone is not looking. Everything is kept on this server, in
+// DATA_DIR/chat: conversations and messages as JSON, attachments as files. (Messages are stored readable on the server; end-to-end encryption is a later decision.)
+const fs = require('fs'), path = require('path'), crypto = require('crypto');
+const { pipeline } = require('stream/promises');
+const atomicWrite = require('./atomic');
+const users = require('./users');
+const push = require('./push');
+const { readBody } = require('./body');
+
+const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), ROOT = () => path.join(DATA_DIR, 'chat');
+const MAX_TEXT = 8000, MAX_FILE = 100 * 1024 * 1024, MAX_FILES = 10, MAX_MEMBERS = 40, MAX_MSGS = 50000, PAGE = 60;
+const EMOJI = /^[\p{Extended_Pictographic}️⃣\p{Emoji_Modifier}]{1,12}$/u;
+const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
+const fail = (code, message) => Object.assign(new Error(message), { code });
+const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').slice(0, n);
+const newId = () => crypto.randomBytes(8).toString('hex');
+const idOk = s => /^[0-9a-f]{16}$/.test(s || '') || /^d:[0-9a-z]+:[0-9a-z]+$/.test(s || '');
+
+// ---- storage (small enough to hold in memory and write whole)
+const cvFile = () => path.join(ROOT(), 'convs.json'), msFile = id => path.join(ROOT(), 'msgs', id.replace(/:/g, '_') + '.json'), flFile = () => path.join(ROOT(), 'files.json'), blob = id => path.join(ROOT(), 'files', id);
+const read = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
+const write = (f, v) => { fs.mkdirSync(path.dirname(f), { recursive: true, mode: 0o700 }); atomicWrite(f, JSON.stringify(v), { mode: 0o600 }); };
+let convsC = null, filesC = null; const msgsC = new Map();
+const convs = () => convsC || (convsC = read(cvFile(), []));
+const saveConvs = () => write(cvFile(), convs());
+const files = () => filesC || (filesC = read(flFile(), {}));
+const saveFiles = () => write(flFile(), files());
+const msgs = id => { let m = msgsC.get(id); if (!m) { m = read(msFile(id), []); msgsC.set(id, m); if (msgsC.size > 60) msgsC.delete(msgsC.keys().next().value); } return m; };
+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 saveState = (uid, s) => write(stFile(uid), s);
+
+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 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') : '') : '';
+
+// ---- live updates (server-sent events), one stream per open app
+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 });
+
+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 }); }
+ 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);
+}
+
+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;
+ 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;
+ }
+ 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') {
+ const b = JSON.parse(await readBody(req, 1e4) || '{}'); let c;
+ if (b.type === 'group') {
+ const name = str(b.name, 60).trim(); if (!name) throw fail(400, 'Give the group a name'); const mem = [...new Set([me.id, ...(Array.isArray(b.members) ? b.members : [])].map(String))].filter(live); if (mem.length < 2) throw fail(400, 'Add at least one other person'); if (mem.length > MAX_MEMBERS) throw fail(400, 'That is a lot of people for one group');
+ c = { id: newId(), type: 'group', name, members: mem, by: me.id, created: Date.now() }; convs().push(c);
+ } else {
+ const other = String(b.with || ''); if (other === me.id || !live(other)) throw fail(400, 'Choose someone from the family'); const id = 'd:' + [me.id, other].sort().join(':'); c = convs().find(x => x.id === id);
+ if (!c) { c = { id, type: 'direct', members: [me.id, other].sort(), by: me.id, created: Date.now() }; convs().push(c); }
+ }
+ saveConvs(); emit(c.members, { t: 'conv', conv: c.id }); return json(res, 200, { conv: view(c, me.id, stateOf(me.id)) });
+ }
+ if (p === '/conv/update' && req.method === 'POST') { // rename, add or remove people, leave (groups)
+ const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me); if (c.type !== 'group') throw fail(400, 'Only groups can be changed');
+ const isOwner = c.by === me.id || me.role === 'admin'; const before = [...c.members];
+ if (b.name !== undefined) { const n = str(b.name, 60).trim(); if (!n) throw fail(400, 'Give the group a name'); c.name = n; }
+ for (const id of Array.isArray(b.add) ? b.add : []) if (live(String(id)) && !c.members.includes(String(id)) && c.members.length < MAX_MEMBERS) c.members.push(String(id));
+ for (const id of Array.isArray(b.remove) ? b.remove : []) { if (!isOwner && String(id) !== me.id) throw fail(403, 'Only the person who made the group, or an admin, can remove others'); c.members = c.members.filter(x => x !== String(id)); }
+ if (b.leave) c.members = c.members.filter(x => x !== me.id);
+ if (!c.members.length) convsC = convs().filter(x => x !== c); else if (c.by && !c.members.includes(c.by)) c.by = c.members[0];
+ 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)])) });
+ }
+ 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
+ const c = convOf(String(q.get('conv') || ''), me); if ((+req.headers['content-length'] || 0) > MAX_FILE) throw fail(413, 'That file is too large (100 MB at most)');
+ 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 });
+ }
+ 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'); }
+ const inline = /^(image\/(png|jpeg|gif|webp|heic|heif|avif)|audio\/|video\/|application\/pdf$)/.test(f.type), h = { 'content-type': inline ? f.type : 'application/octet-stream', 'x-content-type-options': 'nosniff', 'cache-control': 'private, max-age=3600', 'accept-ranges': 'bytes', 'content-security-policy': "default-src 'none'; sandbox" };
+ if (!inline || q.get('dl') === '1') h['content-disposition'] = `attachment; filename="${f.name.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, '')}"; filename*=UTF-8''${encodeURIComponent(f.name)}`;
+ const rg = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
+ if (rg && (rg[1] || rg[2])) { const a = rg[1] ? +rg[1] : Math.max(0, st.size - +rg[2]), b = rg[1] && rg[2] ? Math.min(+rg[2], st.size - 1) : st.size - 1; if (a > b || a >= st.size) { res.writeHead(416, { 'content-range': 'bytes */' + st.size }); return res.end(); } res.writeHead(206, { ...h, 'content-range': `bytes ${a}-${b}/${st.size}`, 'content-length': b - a + 1 }); return fs.createReadStream(blob(m[1]), { start: a, end: b }).pipe(res); }
+ res.writeHead(200, { ...h, 'content-length': st.size }); return fs.createReadStream(blob(m[1])).pipe(res);
+ }
+ if (p === '/react' && 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) throw fail(404, 'Message not found');
+ const e = String(b.emoji || ''); if (e && !EMOJI.test(e)) throw fail(400, 'That is not an emoji'); x.react = x.react || {}; if (e) x.react[me.id] = e; else delete x.react[me.id]; saveMsgs(c.id);
+ emit(c.members, { t: 'upd', conv: c.id, id: x.id }); return json(res, 200, { ok: true });
+ }
+ if ((p === '/edit' || p === '/delete') && req.method === 'POST') {
+ const b = JSON.parse(await readBody(req, 2e4) || '{}'), c = convOf(String(b.conv || ''), me), x = msgs(c.id).find(y => y.id === String(b.msg)); if (!x) throw fail(404, 'Message not found'); if (x.from !== me.id) throw fail(403, 'You can only change your own messages'); if (x.deleted) throw fail(400, 'That message was deleted');
+ if (p === '/edit') { const t = str(b.text, MAX_TEXT).trim(); if (!t) throw fail(400, 'Write something'); if (Date.now() - x.ts > 48 * 3600e3) throw fail(400, 'Messages can be edited for two days'); x.text = t; x.edited = true; }
+ 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 === '/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 === '/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) }); }
+ 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 };
diff --git a/docs/v2-roadmap.md b/docs/v2-roadmap.md
index d7fa5d9..ac181fc 100644
--- a/docs/v2-roadmap.md
+++ b/docs/v2-roadmap.md
@@ -4,19 +4,19 @@ A family-first hub. Status: `[ ]` planned, `[~]` in progress, `[x]` done. Size:
Decided: no photos tab, no voice or video calling (text messaging only).
## 1. Family Chat (a built-in messenger; text only)
-A messenger inside the hub for the family, so family chats do not need Signal. Every hub account is a contact, and a "Family" group exists from the start. Family chats and Signal chats show in one inbox (labelled by source); people outside the family still use Signal. Messages are kept on the family's own server.
+A messenger inside the hub for the family, so family chats do not need Signal. Every hub account is a contact, and a "Family" group exists from the start. Family chats and Signal chats are meant to show in one inbox (labelled by source; not built yet: for now Chat and Signal are separate tabs); people outside the family still use Signal. Messages are kept on the family's own server.
-**Phase 1: the basics (L)**
-- [ ] Direct chats and group chats (names, members, add and remove people)
-- [ ] Text with links, emoji and line breaks
-- [ ] Attachments: photos, video, files (stored on the server)
-- [ ] Replies (quote), reactions
-- [ ] Unread counts, search, drafts that survive closing the app
-- [ ] Live delivery in the open app, push notifications in the background
-- [ ] Mute and archive per chat
+**Phase 1: the basics (L): done (chat.js, public/chat-view.js; the Chat tab)**
+- [x] Direct chats and group chats (names, members, add and remove people)
+- [x] Text with links, emoji and line breaks
+- [x] Attachments: photos, video, files (stored on the server)
+- [x] Replies (quote), reactions
+- [x] Unread counts, search, drafts that survive closing the app
+- [x] Live delivery in the open app, push notifications in the background
+- [x] Mute and archive per chat
**Phase 2: feels like Signal (M)**
-- [ ] Edit and delete for everyone
+- [x] Edit and delete for everyone
- [ ] Sent, delivered and read ticks
- [ ] Typing indicators and online presence
- [ ] Disappearing messages (a timer per chat)
diff --git a/package.json b/package.json
index e4202a7..e08ddfe 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
- "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata screentime; do node tools/$t-test.js || exit 1; done",
+ "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata screentime chat; do node tools/$t-test.js || exit 1; done",
"audit": "npm audit --omit=dev --audit-level=high"
},
"keywords": [],
diff --git a/public/app.js b/public/app.js
index e6df3c1..3d2b5ef 100644
--- a/public/app.js
+++ b/public/app.js
@@ -97,6 +97,7 @@ const ICONS = {
download: '',
arrup: '',
arrdn: '',
+ bell: '',
chef: '',
people: '',
search: '',
@@ -166,8 +167,8 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) +
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
// ---- Navigation
-const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['contacts', 'Contacts'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['recipes', 'Recipes'], ['store', 'Store'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['bible', 'Bible'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
-const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
+const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['contacts', 'Contacts'], ['chat', 'Chat'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['recipes', 'Recipes'], ['store', 'Store'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['bible', 'Bible'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
+const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', chat: 'chat', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry, and the listening and reading tabs share "Listen" and "Read" (each part still needs its own permission). */
const leafOf = id => Object.keys(MERGED).find(k => MERGED[k][1].includes(id));
const navTabs = () => { const out = [], done = new Set(); for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0])) continue; let id = t[0], label = t[1];
@@ -177,14 +178,14 @@ const navTabs = () => { const out = [], done = new Set(); for (const t of TABS)
const leafTabs = () => TABS.filter(t => t[0] !== 'verse' && t[0] !== 'home' && allowed(t[0]));
const tabLabel = id => (TABS.find(x => x[0] === id) || (MERGED[id] ? [0, MERGED[id][0]] : id === 'planner' ? [0, 'Planner'] : [0, '']))[1];
/** Menu entries grouped by kind. Anything not listed lands in the last group so a new tab is never lost. */
-const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'signal']], ['Media', ['listen', 'reading', 'video', 'store']], ['', ['settings']]];
+const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Media', ['listen', 'reading', 'video', 'store']], ['', ['settings']]];
const navGroups = (tabs = navTabs()) => {
const out = NAV_GROUPS.map(([label, ids]) => ({ label, tabs: ids.map(id => tabs.find(t => t[0] === id)).filter(Boolean) }));
const known = new Set(NAV_GROUPS.flatMap(g => g[1])); out[out.length - 2].tabs.push(...tabs.filter(t => !known.has(t[0])));
return out.filter(g => g.tabs.length);
};
const sg = { st: null, convs: [], cur: null, unread: 0, es: null, timers: [], reply: null, tray: [], names: {} }; // Signal state
-const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', store: '#d9822b', recipes: '#e0742b', contacts: '#5b8def', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' };
+const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', store: '#d9822b', chat: '#2aa6a0', recipes: '#e0742b', contacts: '#5b8def', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' };
// The phone's bottom bar shows up to five entries you choose (Edit bottom bar, in the Apps sheet), then "More".
const DEFAULT_PINS = ['home', 'files', 'planner', 'listen', 'mail'];
const navPins = () => { const have = new Set(navTabs().map(t => t[0])), saved = store.get('nav_pins'); const out = (Array.isArray(saved) && saved.length ? saved : DEFAULT_PINS).filter(id => have.has(id)).slice(0, 5); return out.length ? out : [...have].slice(0, 4); };
@@ -198,13 +199,13 @@ function renderNav(active = activeTab) {
if (typeof sgDockBadge === 'function') sgDockBadge();
if (typeof sgBadgeEverywhere === 'function') sgBadgeEverywhere();
const badge = n => n ? `${n > 99 ? '99+' : n}` : '';
- const tab = ([id, label]) => `${icon(TAB_ICON[id])}${label}${id === 'signal' ? badge(sg.unread) : id === 'mail' ? badge(mailUnread) : ''}`;
+ const tab = ([id, label]) => `${icon(TAB_ICON[id])}${label}${id === 'signal' ? badge(sg.unread) : id === 'chat' ? badge(ch.unread) : id === 'mail' ? badge(mailUnread) : ''}`;
const brand = `
NE Hub `;
const mine = navTabs();
if (phone.matches) {
const pins = navPins(), rest = mine.filter(t => !pins.includes(t[0])), more = !pins.includes(active);
$('#nav').innerHTML = pins.map(id => mine.find(t => t[0] === id)).filter(Boolean).map(tab).join('') +
- (rest.length ? `${icon('more')}More${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : ''}${rest.some(t => t[0] === 'mail') ? badge(mailUnread) : ''}` : '');
+ (rest.length ? `${icon('more')}More${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : rest.some(t => t[0] === 'chat') ? badge(ch.unread) : ''}${rest.some(t => t[0] === 'mail') ? badge(mailUnread) : ''}` : '');
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openLauncher(); };
} else {
$('#nav').innerHTML = brand + navGroups(mine).map((g, i) => (i ? `${g.label ? `${esc(g.label)}` : ''}
` : '') + g.tabs.map(tab).join('')).join('') + ``;
@@ -254,7 +255,7 @@ function drawSubnav(name) {
const navIdOf = name => name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name;
let curRoute = '';
-const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
+const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
let editor;
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
@@ -322,6 +323,7 @@ function homeApps() {
office: () => tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice'),
audio: () => tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen'),
podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'),
+ chat: () => tile('#/chat', '#2aa6a0', 'chat', 'Chat', ch.unread ? ch.unread + ' unread' : 'Family messages'),
store: () => tile('#/store', '#d9822b', 'store', 'Store', 'Books, audiobooks, radio & more'),
recipes: () => tile('#/recipes', '#e0742b', 'chef', 'Recipes', 'Mealie & shopping list'),
contacts: () => tile('#/contacts', '#5b8def', 'people', 'Contacts', 'People & birthdays'),
@@ -332,7 +334,7 @@ function homeApps() {
mail: () => tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com'),
};
T.bible = () => tile('#/bible', '#8b5a2b', 'bible', 'Bible', 'Berean Standard Bible');
- const HOME_GROUPS = [['Work', ['planner', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]];
+ const HOME_GROUPS = [['Work', ['planner', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]];
return HOME_GROUPS.map(([label, ids]) => [label, ids.filter(id => T[id] && allowed(id))]).filter(g => g[1].length).map(([label, ids]) => `${esc(label)}
${ids.map(id => T[id]()).join('')}
`).join('');
}
diff --git a/public/boot.js b/public/boot.js
index 2842852..7537830 100644
--- a/public/boot.js
+++ b/public/boot.js
@@ -18,7 +18,7 @@
setInterval(checkForUpdate, 10 * 60e3);
await loadMe();
await loadSettings();
- sgStart();
+ sgStart(); if (typeof chStart === 'function') chStart();
addEventListener('hashchange', route);
restorePlayer();
route();
diff --git a/public/chat-view.js b/public/chat-view.js
new file mode 100644
index 0000000..cc3c4dc
--- /dev/null
+++ b/public/chat-view.js
@@ -0,0 +1,176 @@
+'use strict';
+// ---- Chat: the family messenger (see chat.js on the server). It looks like the Signal screen on purpose and shares its styles (sg*) and helpers (sgTime, sgLink...).
+const ch = { convs: [], cur: null, root: null, es: null, unread: 0, reply: null, tray: [], names: {}, people: null, showArchived: false, sendQ: null, found: null };
+const chq = q => (ch.root || document).querySelector(q);
+const chApi = (p, o) => jfetch('/api/chat' + p, o);
+const chPost = (p, body) => chApi(p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
+const chOnView = () => location.hash.startsWith('#/chat');
+const chMounted = () => !!ch.root && document.contains(ch.root);
+const chDraftKey = id => 'ch_draft:' + (ME ? ME.id : '') + ':' + id;
+const chGetDraft = id => { try { return localStorage.getItem(chDraftKey(id)) || ''; } catch { return ''; } };
+const chSetDraft = (id, t) => { try { t ? localStorage.setItem(chDraftKey(id), t) : localStorage.removeItem(chDraftKey(id)); } catch {} };
+
+// ---- live updates, kept running whatever screen is open (so the unread count on the menu is always right)
+async function chStart() {
+ if (!allowed('chat') || ch.es) return;
+ try { const st = await chApi('/status'); ch.unread = st.unread || 0; renderNav(activeTab); } catch { return; }
+ ch.es = new EventSource('/api/chat/events');
+ ch.es.onmessage = e => { let ev; try { ev = JSON.parse(e.data); } catch { return; } chEvent(ev); };
+}
+let chEvT = null;
+function chEvent(ev) {
+ if (typeof ev.unread === 'number' && ev.unread !== ch.unread) { ch.unread = ev.unread; renderNav(activeTab); }
+ if (ev.t === 'msg' && !ev.out) {
+ const looking = chMounted() && ch.cur === ev.conv && document.visibilityState === 'visible' && document.hasFocus();
+ if (!looking) { toast((ev.name || 'Chat') + ': ' + (ev.preview || '')); if (typeof sgAlerts === 'function' && sgAlerts().sound && typeof sgChime === 'function') sgChime(); }
+ }
+ if (!chMounted()) return;
+ clearTimeout(chEvT); chEvT = setTimeout(chRefresh, 150);
+}
+
+function viewChat(args = []) { ch.cur = args[0] ? decodeURIComponent(args[0]) : null; return chMount($('#view')); }
+async function chMount(root) {
+ ch.root = root; root.innerHTML = '';
+ root.innerHTML = ``;
+ chq('#chnew').onclick = chNewChat; let t; chq('#chq').oninput = () => { chDrawList(); clearTimeout(t); t = setTimeout(chSearchMessages, 350); };
+ await chRefresh(true);
+}
+async function chRefresh(first) {
+ try {
+ const r = await chApi('/convs'); ch.convs = r.convs; ch.unread = r.unread; renderNav(activeTab);
+ if (!chq('#chlist')) return; chDrawList();
+ if (ch.cur) await chDrawThread(first); else chq('#chm').innerHTML = 'Choose a chat, or start a new one.
';
+ } catch (e) { const l = chq('#chlist'); if (l) l.innerHTML = `${esc(e.message)}
`; }
+}
+async function chSearchMessages() {
+ const q = (chq('#chq') || {}).value ? chq('#chq').value.trim() : ''; if (q.length < 2) { ch.found = null; return chDrawList(); }
+ try { const r = await chApi('/search?q=' + encodeURIComponent(q)); if ((chq('#chq') || {}).value.trim() === q) { ch.found = r.results; chDrawList(); } } catch { /* the list still works */ }
+}
+function chRow(c) {
+ return `${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}
+ ${esc(c.name)}${c.muted ? ' 🔕' : ''}${esc(c.preview || '')}${c.preview || c.ts ? sgTime(c.ts) : ''}${c.unread ? `${c.unread}` : ''}`;
+}
+function chDrawList() {
+ const q = (chq('#chq') || {}).value ? chq('#chq').value.toLowerCase().trim() : '', box = chq('#chlist'); if (!box) return;
+ const match = c => !q || c.name.toLowerCase().includes(q) || (c.preview || '').toLowerCase().includes(q), live = ch.convs.filter(c => !c.archived && match(c)), arch = ch.convs.filter(c => c.archived && match(c));
+ const found = q.length >= 2 && ch.found ? ch.found : [];
+ box.innerHTML = live.map(chRow).join('') + (arch.length ? `${ch.showArchived || q ? arch.map(chRow).join('') : ''}` : '')
+ + (found.length ? `In messages
${found.map(f => `${esc(f.convName)}${esc(f.msg.name)}: ${esc(f.msg.text)}${sgTime(f.msg.ts)}`).join('')}` : '')
+ || '';
+ if (!box.innerHTML) box.innerHTML = `${q ? 'Nothing matches' : 'No chats yet. Tap New to start one.'}
`;
+ const a = box.querySelector('#charch'); if (a) a.onclick = () => { ch.showArchived = !ch.showArchived; chDrawList(); };
+ box.oncontextmenu = e => { const r = e.target.closest('[data-id]'); if (r) { e.preventDefault(); chChatMenu(r.dataset.id); } };
+}
+
+function chBubble(m, c, prev) {
+ const att = (m.att || []).map(a => { const u = '/api/chat/file/' + a.id;
+ return /^image\//.test(a.type || '') ? `
`
+ : /^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' })}
+ ${Object.keys(reacts).length ? `
${Object.entries(reacts).map(([e, n]) => e + (n > 1 ? ' ' + n : '')).join(' ')}` : ''}
`;
+}
+async function chDrawThread(first) {
+ const box = chq('#chm'); if (!box) return;
+ const id = ch.cur; let r;
+ try { r = await chApi('/conv/' + encodeURIComponent(id)); } catch (e) { box.innerHTML = `${esc(e.message)}
`; return; }
+ if (ch.cur !== id || !chq('#chm')) return; ch.names = r.names || {};
+ const c = r.conv, old = box.querySelector('.sgmsgs'), stick = !old || old.scrollHeight - old.scrollTop - old.clientHeight < 80;
+ if (!box.querySelector('.sgbar')) {
+ box.innerHTML = `${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(', ') : '';
+ 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); };
+ 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 ? `` : ''}
+ ${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 cp = el.querySelector('#chcp'); if (cp) cp.onclick = async () => { closeSheet(); try { await navigator.clipboard.writeText(m.text); toast('Copied'); } catch { toast('Could not copy'); } };
+ const ed = el.querySelector('#ched'); if (ed) ed.onclick = () => { closeSheet(); textSheet('Edit message', 'Message', 'Save', async t => { try { await chPost('/edit', { conv: c.id, msg: m.id, text: t }); } catch (e) { toast(e.message); } }, m.text); };
+ const dl = el.querySelector('#chdl'); if (dl) dl.onclick = () => { closeSheet(); confirmSheet('Delete this message?', 'It disappears for everyone in the chat.', 'Delete', async () => { try { await chPost('/delete', { conv: c.id, msg: m.id }); } catch (e) { toast(e.message); } }); };
+}
+function chDrawReply() {
+ const b = chq('#chrep'); if (!b) return; if (!ch.reply) { b.hidden = true; return; }
+ b.hidden = false; b.innerHTML = `${esc(ch.reply.out ? 'You' : ch.reply.name)}${esc(ch.reply.text || 'Attachment')}`;
+ chq('#chrx').onclick = () => { ch.reply = null; chDrawReply(); };
+}
+function chDrawTray() {
+ const t = chq('#chtray'); if (!t) return; t.hidden = !ch.tray.length;
+ t.innerHTML = ch.tray.map((f, i) => `${/^image\//.test(f.type) ? `
` : icon('clip')}${esc(f.name)}`).join('');
+ t.querySelectorAll('[data-x]').forEach(b => b.onclick = () => { ch.tray.splice(+b.dataset.x, 1); chDrawTray(); });
+}
+function chWireComposer() {
+ const ta = chq('#chtext'), grow = () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 140) + 'px'; };
+ ta.oninput = () => { grow(); chSetDraft(ch.cur, ta.value); };
+ ta.onkeydown = e => { if (e.key === 'Enter' && !e.shiftKey && matchMedia('(pointer: fine)').matches) { e.preventDefault(); chq('#chform').requestSubmit(); } };
+ chq('#chat').onclick = () => chq('#chfile').click();
+ chq('#chfile').onchange = e => { ch.tray.push(...e.target.files); e.target.value = ''; chDrawTray(); };
+ ta.addEventListener('paste', e => { const fs = [...(e.clipboardData && e.clipboardData.files || [])]; if (fs.length) { ch.tray.push(...fs); chDrawTray(); } });
+ // The message appears at once, dimmed as "Sending…"; if it fails, the words and files go back into the box so nothing is lost.
+ chq('#chform').onsubmit = e => {
+ e.preventDefault(); const text = ta.value, files = [...ch.tray], reply = ch.reply, to = ch.cur; if (!text.trim() && !files.length) return;
+ ta.value = ''; grow(); chSetDraft(to, ''); ch.tray = []; ch.reply = null; chDrawTray(); chDrawReply(); ta.focus();
+ const list = chq('#chmsgs'), tmp = document.createElement('div'); tmp.className = 'sgb out'; tmp.style.opacity = '.65'; tmp.innerHTML = `${esc(text) || 'Attachment'}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
+ try {
+ const ids = []; for (const f of files) { const up = await chApi('/upload?conv=' + encodeURIComponent(to) + '&name=' + encodeURIComponent(f.name), { method: 'POST', body: f, headers: { 'content-type': f.type || 'application/octet-stream' } }); ids.push(up.id); }
+ await chPost('/send', { conv: to, text, files: ids, quote: reply ? reply.id : undefined }); tmp.remove(); await chRefresh(); const el = chq('#chmsgs'); if (el) el.scrollTop = el.scrollHeight;
+ } catch (err) { tmp.remove(); toast(err.message || 'Could not send'); if (ch.cur === to && !ta.value) { ta.value = text; grow(); ch.tray = files; ch.reply = reply; chDrawTray(); chDrawReply(); } }
+ });
+ };
+}
+
+// ---- starting chats, groups and managing them
+async function chPeople() { if (!ch.people) ch.people = await chApi('/people'); return ch.people; }
+async function chNewChat() {
+ let people = []; try { people = await chPeople(); } catch (e) { return toast(e.message); }
+ const el = openSheet('New chat', 'Pick someone in the family', `${people.map(p => `
`).join('') || '
There is nobody else with Chat yet. An admin can turn it on for people in Settings.
'}
+ ${people.length > 1 ? '' : ''}`);
+ el.querySelectorAll('[data-p]').forEach(b => b.onclick = async () => { try { const r = await chPost('/new', { type: 'direct', with: b.dataset.p }); closeSheet(); location.hash = '#/chat/' + encodeURIComponent(r.conv.id); } catch (e) { toast(e.message); } });
+ const g = el.querySelector('#chg'); if (g) g.onclick = () => { closeSheet(); chNewGroup(people); };
+}
+function chNewGroup(people) {
+ const el = openSheet('New group', '', ``);
+ el.querySelector('#chgx').onclick = closeSheet; setTimeout(() => el.querySelector('#chgn').focus(), 60);
+ el.querySelector('#chgf').onsubmit = async e => { e.preventDefault(); try { const r = await chPost('/new', { type: 'group', name: el.querySelector('#chgn').value, members: [...el.querySelectorAll('[data-p]:checked')].map(c => c.dataset.p) }); closeSheet(); location.hash = '#/chat/' + encodeURIComponent(r.conv.id); } catch (x) { el.querySelector('#chge').textContent = x.message; } };
+}
+function chChatMenu(id) {
+ const c = ch.convs.find(x => x.id === id); if (!c) return; const acts = [];
+ acts.push({ label: c.muted ? 'Unmute' : 'Mute', icon: 'bell', run: async () => { try { await chPost('/conv/mute', { id, on: !c.muted }); toast(c.muted ? 'Unmuted' : 'Muted. No alerts from this chat.'); chRefresh(); } catch (e) { toast(e.message); } } });
+ acts.push({ label: c.archived ? 'Move out of the archive' : 'Archive', icon: 'files', run: async () => { try { await chPost('/conv/archive', { id, on: !c.archived }); if (!c.archived && ch.cur === id) location.hash = '#/chat'; chRefresh(); } catch (e) { toast(e.message); } } });
+ if (c.type === 'group') {
+ acts.push({ label: 'Rename the group', icon: 'edit', run: () => textSheet('Rename group', 'Group name', 'Rename', async n => { try { await chPost('/conv/update', { id, name: n }); chRefresh(); } catch (e) { toast(e.message); } }, c.name) });
+ acts.push({ label: 'People in the group', icon: 'people', run: () => chMembers(c) });
+ acts.push({ label: 'Leave the group', icon: 'trash', danger: true, run: () => confirmSheet('Leave this group?', c.name, 'Leave', async () => { try { await chPost('/conv/update', { id, leave: true }); location.hash = '#/chat'; chRefresh(); } catch (e) { toast(e.message); } }) });
+ }
+ actionSheet(c.name, c.type === 'group' ? c.members.length + ' people' : 'Chat', acts);
+}
+async function chMembers(c) {
+ let people = []; try { people = await chPeople(); } catch (e) { return toast(e.message); }
+ const owner = ME && (ME.role === 'admin' || c.by === ME.id), others = people.filter(p => !c.members.some(m => m.id === p.id));
+ const el = openSheet(c.name, 'People in the group', `${c.members.map(m => `
${esc(sgInitial(m.name))}${esc(m.name)}${ME && m.id === ME.id ? ' (you)' : ''}${m.id === c.by ? ' · made it' : ''}${owner && !(ME && m.id === ME.id) ? `` : ''}
`).join('')}
+ ${others.length ? `Add someone
${others.map(p => ``).join('')}
` : ''}`);
+ 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] }));
+}
diff --git a/public/index.html b/public/index.html
index dae4cde..f148284 100644
--- a/public/index.html
+++ b/public/index.html
@@ -92,6 +92,7 @@
+
diff --git a/public/signal-view.js b/public/signal-view.js
index 95610af..2e98215 100644
--- a/public/signal-view.js
+++ b/public/signal-view.js
@@ -29,7 +29,7 @@ function sgChime() {
o.connect(g); g.connect(sgAC.destination); o.start(t + d); o.stop(t + d + 0.45); });
}
function sgBadgeEverywhere() { // Signal chats, unread mail and tasks due: one number on the tab title and the installed app's icon
- const n = (sg.unread || 0) + (typeof mailUnread === 'number' ? mailUnread : 0) + (typeof taskDue === 'number' ? taskDue : 0);
+ const n = (sg.unread || 0) + (typeof ch === 'object' ? ch.unread || 0 : 0) + (typeof mailUnread === 'number' ? mailUnread : 0) + (typeof taskDue === 'number' ? taskDue : 0);
document.title = (n ? '(' + (n > 99 ? '99+' : n) + ') ' : '') + 'NE Hub';
try { if (navigator.setAppBadge) { n ? navigator.setAppBadge(n) : navigator.clearAppBadge(); } } catch {}
}
diff --git a/public/sw.js b/public/sw.js
index 80bd9c6..72fb5d4 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-v58';
+const CACHE = 'hub-v59';
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/push.js b/push.js
index af49601..b9dd1dc 100644
--- a/push.js
+++ b/push.js
@@ -23,7 +23,7 @@ function keys() {
return vapid;
}
-const DEFAULTS = { mail: true, mailSeen: null, tasks: true, tasksAt: '08:00', events: true, eventMins: 15, tz: 'UTC', subs: [], sent: [], tasksDay: '' };
+const DEFAULTS = { chat: true, mail: true, mailSeen: null, tasks: true, tasksAt: '08:00', events: true, eventMins: 15, tz: 'UTC', subs: [], sent: [], tasksDay: '' };
const file = dir => path.join(dir, 'push.json');
const load = dir => { try { return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(file(dir), 'utf8')) }; } catch { return { ...DEFAULTS, subs: [], sent: [] }; } };
const save = (dir, o) => atomicWrite(file(dir), JSON.stringify(o));
@@ -113,7 +113,7 @@ function start() { if (!timer) { timer = setInterval(() => tick().catch(e => con
async function handle(req, res) {
const p = new URL(req.url, 'http://x').pathname, dir = users.dir(), st = load(dir);
- const pub = () => ({ mail: st.mail, tasks: st.tasks, tasksAt: st.tasksAt, events: st.events, eventMins: st.eventMins, devices: st.subs.length });
+ const pub = () => ({ chat: st.chat, mail: st.mail, tasks: st.tasks, tasksAt: st.tasksAt, events: st.events, eventMins: st.eventMins, devices: st.subs.length });
if (p === '/api/push/key' && req.method === 'GET') return json(res, 200, { publicKey: keys().publicKey, ...pub() });
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
const b = JSON.parse((await readBody(req)) || '{}');
@@ -129,6 +129,7 @@ async function handle(req, res) {
if (typeof b.tasks === 'boolean') st.tasks = b.tasks;
if (typeof b.events === 'boolean') st.events = b.events;
if (typeof b.mail === 'boolean') st.mail = b.mail;
+ if (typeof b.chat === 'boolean') st.chat = b.chat;
if (/^([01]\d|2[0-3]):[0-5]\d$/.test(b.tasksAt || '')) st.tasksAt = b.tasksAt;
if ([5, 10, 15, 30, 60].includes(b.eventMins)) st.eventMins = b.eventMins;
if (b.tz && validTz(b.tz)) st.tz = b.tz;
@@ -141,4 +142,6 @@ async function handle(req, res) {
}
json(res, 404, { error: 'Not found' });
}
-module.exports = { handle, start, tick, localNow };
+/** A notification for one person's devices (a chat message, say), unless they turned that kind off. */
+async function notify(uid, payload) { const dir = users.dirOf(uid), st = load(dir); if (!st.subs.length || st.chat === false) return; await send(dir, st, payload); }
+module.exports = { handle, start, tick, localNow, notify };
diff --git a/server.js b/server.js
index 12c9863..2008ae9 100644
--- a/server.js
+++ b/server.js
@@ -44,6 +44,7 @@ const vbv = require('./vbv'); // Verse By Verse Ministry teaching (free
const recipes = require('./recipes'); // recipes and the shopping list, from Mealie (MEALIE_URL, MEALIE_TOKEN)
const contacts = require('./contacts'); // each person's address book (mail suggestions, calendar birthdays, vCard import and export)
const screentime = require('./screentime'); // fewer web addresses for iPad Screen Time: a report-only policy, the list of what devices reached, and a picture pass-through
+const chat = require('./chat'); // Family Chat: a messenger for the people with an account here
const musicdata = require('./musicdata'); // what was played, favorites, smart and Family playlists for Music
const mystore = require('./mystore'); // My Store: your own books, music, apps and extensions for everyone to use
const radio = require('./radio'); // internet radio (Radio Browser directory, The Journey FM)
@@ -556,6 +557,7 @@ const ACCESS = [
[/^\/api\/bible\//, ['bible']],
[/^\/api\/read\//, ['read']],
[/^\/api\/radio\//, ['radio']],
+ [/^\/api\/chat(\/|$)/, ['chat']],
[/^\/api\/music(\/|$)/, ['music']],
[/^\/api\/store\/my(\/|$)/, ['store']],
[/^\/api\/recipes(\/|$)/, ['recipes']],
@@ -657,6 +659,7 @@ async function handleRequest(req, res) {
if (pathname.startsWith('/api/read/')) return books.handle(req, res);
if (pathname.startsWith('/api/radio/')) return radio.handle(req, res);
if (pathname === '/api/csp-report' || pathname === '/api/img' || pathname === '/api/screentime') return screentime.handle(req, res, user);
+ if (pathname.startsWith('/api/chat/')) return chat.handle(req, res);
if (pathname.startsWith('/api/music/')) return musicdata.handle(req, res);
if (pathname === '/api/store/my' || pathname.startsWith('/api/store/my/')) return mystore.handle(req, res);
if (pathname === '/api/recipes' || pathname.startsWith('/api/recipes/')) return recipes.handle(req, res);
diff --git a/tools/chat-test.js b/tools/chat-test.js
new file mode 100644
index 0000000..2adccf9
--- /dev/null
+++ b/tools/chat-test.js
@@ -0,0 +1,50 @@
+// Family Chat: conversations, messages, attachments, replies, reactions, edit and delete, unread counts, search, mute and archive, live events, and who may see what.
+const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
+process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-chat-'));
+const users = require('../users'), chat = require('../chat');
+const P = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['chat'], handle: 'Yg' }), c: users.create({ name: 'Cy', role: 'user', tabs: ['chat'], handle: 'Yw' }), d: users.create({ name: 'Di', role: 'user', tabs: ['music'], handle: 'ZA' }) };
+const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] || 'a'], () => chat.handle(req, res))).listen(0, async () => {
+ const B = 'http://127.0.0.1:' + hub.address().port + '/api/chat', call = (p, o = {}, who) => fetch(B + p, { ...o, headers: { ...(o.headers || {}), ...(who ? { 'x-user': who } : {}) } }), post = (p, b, who) => call(p, { method: 'POST', body: JSON.stringify(b) }, who), j = r => r.json();
+ try {
+ // who is in the family: only people with the chat tab
+ assert.deepEqual((await j(await call('/people', {}, 'a'))).map(x => x.name).sort(), ['Bob', 'Cy'], 'people without Chat are not offered');
+ let r = await post('/new', { type: 'direct', with: P.d.id }, 'a'); assert.equal(r.status, 400, 'cannot chat with someone who has no Chat');
+ let dm = (await j(await post('/new', { type: 'direct', with: P.b.id }, 'a'))).conv; assert.equal(dm.name, 'Bob'); assert.equal((await j(await post('/new', { type: 'direct', with: P.a.id }, 'b'))).conv.id, dm.id, 'the same chat from the other side, with the other name'); assert.equal((await j(await post('/new', { type: 'direct', with: P.a.id }, 'b'))).conv.name, 'Ann');
+ // live events
+ const events = []; const ctl = new AbortController(); const es = await fetch(B + '/events', { headers: { 'x-user': 'b' }, signal: ctl.signal }); const reader = es.body.getReader(); (async () => { const dec = new TextDecoder(); let buf = ''; try { for (;;) { const { value, done } = await reader.read(); if (done) break; buf += dec.decode(value); let i; while ((i = buf.indexOf('\n\n')) >= 0) { const chunk = buf.slice(0, i); buf = buf.slice(i + 2); const d = /^data: (.*)$/m.exec(chunk); if (d) events.push(JSON.parse(d[1])); } } } catch { /* closed */ } })();
+ await new Promise(ok => setTimeout(ok, 100));
+ // sending
+ r = await post('/send', { conv: dm.id, text: ' Hello Bob ' }, 'a'); const m1 = (await j(r)).msg; assert.equal(m1.text, 'Hello Bob'); assert.equal(m1.out, true);
+ await new Promise(ok => setTimeout(ok, 150)); const ev = events.find(e => e.t === 'msg'); assert.ok(ev && ev.out === false && ev.name === 'Ann' && ev.preview === 'Hello Bob' && ev.unread === 1, 'Bob is told at once, with the unread count');
+ assert.equal((await post('/send', { conv: dm.id, text: '' }, 'a')).status, 400); assert.equal((await post('/send', { conv: dm.id, text: 'x' }, 'c')).status, 404, 'a stranger cannot write in it');
+ let list = await j(await call('/convs', {}, 'b')); assert.equal(list.unread, 1); assert.equal(list.convs[0].unread, 1); assert.equal(list.convs[0].preview, 'Ann: '.slice(0, 0) + 'Hello Bob', 'a direct chat preview has no name'); assert.equal((await j(await call('/convs', {}, 'c'))).convs.length, 0, 'Cy sees nothing');
+ assert.equal((await j(await call('/conv/' + encodeURIComponent(dm.id), {}, 'b'))).msgs.length, 1, 'a chat id works with its colons written as %3A'); await post('/read', { id: dm.id }, 'b'); assert.equal((await j(await call('/convs', {}, 'b'))).unread, 0, 'reading clears it');
+ // replies, reactions
+ const m2 = (await j(await post('/send', { conv: dm.id, text: 'Sure!', quote: m1.id }, 'b'))).msg; assert.equal(m2.quote.text, 'Hello Bob'); assert.equal(m2.quote.name, 'Ann');
+ assert.equal((await post('/react', { conv: dm.id, msg: m1.id, emoji: '👍' }, 'b')).status, 200); assert.equal((await post('/react', { conv: dm.id, msg: m1.id, emoji: '' }, 'b')).status, 400, 'only emoji');
+ let th = await j(await call('/conv/' + dm.id, {}, 'a')); assert.deepEqual(th.msgs[0].react, { [P.b.id]: '👍' }); await post('/react', { conv: dm.id, msg: m1.id, emoji: '' }, 'b'); assert.deepEqual((await j(await call('/conv/' + dm.id, {}, 'a'))).msgs[0].react, {}, 'an empty emoji takes it back');
+ // attachments
+ const png = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64');
+ r = await call('/upload?conv=' + dm.id + '&name=../../pic.gif', { method: 'POST', body: png, headers: { 'content-type': 'image/gif' } }, 'a'); const up = await j(r); assert.equal(up.name, 'pic.gif', 'a file name cannot reach other folders'); assert.equal(up.size, png.length);
+ assert.equal((await call('/upload?conv=' + dm.id + '&name=x', { method: 'POST', body: png }, 'c')).status, 404);
+ let ma = (await j(await post('/send', { conv: dm.id, text: 'a photo', files: [up.id] }, 'a'))).msg; assert.equal(ma.att[0].type, 'image/gif'); assert.equal((await post('/send', { conv: dm.id, text: 'again', files: [up.id] }, 'a')).status, 400, 'an upload is attached once');
+ r = await call('/file/' + up.id, {}, 'b'); assert.equal(r.status, 200); assert.equal(r.headers.get('content-type'), 'image/gif'); assert.equal(Buffer.from(await r.arrayBuffer()).length, png.length); assert.equal((await call('/file/' + up.id, {}, 'c')).status, 404, 'only the chat sees its files');
+ const html = (await j(await call('/upload?conv=' + dm.id + '&name=x.html', { method: 'POST', body: '', headers: { 'content-type': 'text/html' } }, 'a'))); r = await call('/file/' + html.id, {}, 'b'); assert.equal(r.headers.get('content-type'), 'application/octet-stream', 'a web page is never shown inline'); assert.match(r.headers.get('content-disposition'), /attachment/);
+ r = await call('/file/' + up.id, { headers: { range: 'bytes=0-3' } }, 'b'); assert.equal(r.status, 206);
+ // edit and delete
+ assert.equal((await post('/edit', { conv: dm.id, msg: m1.id, text: 'Hello again' }, 'b')).status, 403, 'only the author edits'); assert.equal((await post('/edit', { conv: dm.id, msg: m1.id, text: 'Hello again' }, 'a')).status, 200);
+ assert.equal((await j(await call('/conv/' + dm.id, {}, 'b'))).msgs[0].edited, true); assert.equal((await post('/delete', { conv: dm.id, msg: ma.id }, 'a')).status, 200); th = await j(await call('/conv/' + dm.id, {}, 'b')); const gone = th.msgs.find(x => x.id === ma.id); assert.equal(gone.deleted, true); assert.equal(gone.att, undefined); assert.equal((await call('/file/' + up.id, {}, 'b')).status, 404, 'its file is gone too');
+ // groups
+ assert.equal((await post('/new', { type: 'group', name: '', members: [P.b.id] }, 'a')).status, 400);
+ const g = (await j(await post('/new', { type: 'group', name: 'Family', members: [P.b.id, P.c.id, P.d.id] }, 'a'))).conv; assert.deepEqual(g.members.map(x => x.name).sort(), ['Ann', 'Bob', 'Cy'], 'someone without Chat is left out');
+ await post('/send', { conv: g.id, text: 'Dinner at 6' }, 'a'); const gl = (await j(await call('/convs', {}, 'c'))).convs; assert.equal(gl[0].name, 'Family'); assert.equal(gl[0].preview, 'Ann: Dinner at 6', 'a group preview names who wrote it'); assert.equal(gl[0].unread, 1);
+ assert.equal((await post('/conv/update', { id: g.id, remove: [P.c.id] }, 'b')).status, 403, 'only the maker or an admin removes people'); assert.equal((await post('/conv/update', { id: g.id, name: 'Our Family', remove: [P.c.id] }, 'a')).status, 200); assert.equal((await j(await call('/convs', {}, 'c'))).convs.length, 0, 'Cy no longer has the group');
+ assert.equal((await post('/conv/update', { id: g.id, leave: true }, 'b')).status, 200); assert.deepEqual((await j(await call('/conv/' + g.id, {}, 'a'))).conv.members.map(x => x.name), ['Ann']);
+ // mute, archive, search
+ 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');
+ ctl.abort(); console.log('chat ok');
+ } catch (e) { console.error(e); process.exitCode = 1; }
+ hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 80);
+});
diff --git a/users.js b/users.js
index a109cb5..d905439 100644
--- a/users.js
+++ b/users.js
@@ -11,9 +11,9 @@ const USERS_DIR = path.join(DATA_DIR, 'users');
fs.mkdirSync(DATA_DIR, { recursive: true });
// Tabs an admin can switch on or off for a person. Home and Settings are always available to everyone (Settings only shows their own).
-const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'radio', 'store', 'recipes', 'contacts', 'bible', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links'];
+const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'radio', 'chat', 'store', 'recipes', 'contacts', 'bible', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links'];
const ALWAYS = ['home', 'settings'];
-const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', video: 'Movies & TV', audio: 'Audiobooks', podcast: 'Podcast', radio: 'Radio', store: 'Store', recipes: 'Recipes', contacts: 'Contacts', bible: 'Bible', read: 'Books', verse: 'Podcast Admin (in Links)', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' };
+const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', video: 'Movies & TV', audio: 'Audiobooks', podcast: 'Podcast', radio: 'Radio', chat: 'Chat', store: 'Store', recipes: 'Recipes', contacts: 'Contacts', bible: 'Bible', read: 'Books', verse: 'Podcast Admin (in Links)', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' };
let cache = null;
const load = () => { if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { cache = { users: [] }; } if (!Array.isArray(cache.users)) cache.users = []; return cache; };