diff --git a/chat.js b/chat.js index 2675298..33bcfb3 100644 --- a/chat.js +++ b/chat.js @@ -82,6 +82,12 @@ function send(me, b) { 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 }); } +/** This person has read a chat up to now (they opened it, or cleared its notification): their unread count, the senders' ticks and the Notifications list all follow. */ +function markRead(uid, c) { + if (typeof c === 'string') c = convs().find(x => x.id === c && x.members.includes(uid)); if (!c) return; + const st = stateOf(uid), 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(uid, st); + inbox.markTagRead(uid, 'chat:' + c.id); emit([uid], { t: 'read', conv: c.id, unread: totalUnread(uid) }); emit(c.members.filter(x => x !== uid), { t: 'receipt', conv: c.id }); +} /** 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 }; @@ -179,13 +185,13 @@ 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(); 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 === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), c = convOf(String(b.id || ''), me); markRead(me.id, c); 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); for (const x of people()) if (x.id !== me.id) inbox.add(x.id, { kind: 'announce', title: 'Family announcement', body: text, url: '#/family' }); emit(people().map(x => x.id), { t: 'announce' }); return json(res, 200, a); + const a = { id: newId(), text, by: me.name, at: Date.now() }; write(annFile(), a); for (const x of people()) if (x.id !== me.id) inbox.add(x.id, { kind: 'announce', title: 'Family announcement', body: text, url: '#/family', ref: { announce: a.id } }); 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 }); } @@ -239,4 +245,4 @@ function searchFor(me, q) { 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 out.sort((a, b) => b.msg.ts - a.msg.ts).slice(0, 8); } -module.exports = { announcement: () => read(annFile(), null), handle, totalUnread, purge, parsePreview, askParents, searchFor }; +module.exports = { markRead, announcement: () => read(annFile(), null), handle, totalUnread, purge, parsePreview, askParents, searchFor }; diff --git a/inbox.js b/inbox.js index 2a230f5..03d4480 100644 --- a/inbox.js +++ b/inbox.js @@ -12,22 +12,34 @@ const load = uid => { try { const j = JSON.parse(fs.readFileSync(file(uid), 'utf const save = (uid, items) => { try { fs.mkdirSync(path.dirname(file(uid)), { recursive: true }); atomicWrite(file(uid), JSON.stringify({ items }), { mode: 0o600 }); } catch (e) { console.warn('[inbox]', e.message); } }; /** Add something for a person. With a `tag`, a still-unread entry with the same tag is updated (and counted) instead of added again. */ -function add(uid, { kind = 'info', title, body = '', url = '', tag = '' }) { +const mergeRef = (a, b) => !a || !b ? b || a : { ...a, ...b, ...(a.mail && b.mail ? { mail: [...a.mail, ...b.mail].slice(-50) } : {}) }; +function add(uid, { kind = 'info', title, body = '', url = '', tag = '', ref }) { if (!users.get(uid)) return; const items = load(uid), now = Date.now(); const old = tag ? items.find(i => i.tag === tag && !i.read) : null; - if (old) { old.count = (old.count || 1) + 1; old.title = str(title, 100); old.body = str(body, 200); old.at = now; items.sort((a, b) => b.at - a.at); } - else items.unshift({ id: crypto.randomBytes(6).toString('hex'), kind, title: str(title, 100), body: str(body, 200), url: str(url, 200), tag: str(tag, 80), at: now, read: false, count: 1 }); + if (old) { old.count = (old.count || 1) + 1; old.title = str(title, 100); old.body = str(body, 200); old.at = now; if (ref) old.ref = mergeRef(old.ref, ref); items.sort((a, b) => b.at - a.at); } + else items.unshift({ id: crypto.randomBytes(6).toString('hex'), kind, title: str(title, 100), body: str(body, 200), url: str(url, 200), tag: str(tag, 80), ref: ref || undefined, at: now, read: false, count: 1 }); save(uid, items.slice(0, MAX)); } const unread = uid => load(uid).filter(i => !i.read).length; +/** The thing itself was dealt with somewhere else (a chat opened, say): its notification is done too. */ +function markTagRead(uid, tag) { const items = load(uid); let ch = false; for (const i of items) if (i.tag === tag && !i.read) { i.read = true; ch = true; } if (ch) save(uid, items); } +/** Reading or clearing a notification also settles what it was about: a chat is marked read, new mail is marked read. (Reminders, requests and the like stay as they are: they still need doing or answering.) */ +async function settle(uid, items) { + const jobs = []; + for (const i of items) { + const m = /^chat:(.+)$/.exec(i.tag || ''); if (m) { try { require('./chat').markRead(uid, m[1]); } catch (e) { console.warn('[inbox]', e.message); } } + if (i.ref && Array.isArray(i.ref.mail) && i.ref.mail.length) jobs.push(require('./mail').markRead(i.ref.mail).catch(() => {})); + } + await Promise.all(jobs); +} async function handle(req, res) { const p = new URL(req.url, 'http://x').pathname.slice('/api/inbox'.length) || '/', me = users.current(); try { if (p === '/' && req.method === 'GET') { const items = load(me.id); return json(res, 200, { items, unread: items.filter(i => !i.read).length }); } - if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 5e3) || '{}'), items = load(me.id); for (const i of items) if (b.all || i.id === b.id) i.read = true; save(me.id, items); return json(res, 200, { unread: items.filter(i => !i.read).length }); } - if (p === '/clear' && req.method === 'POST') { save(me.id, []); return json(res, 200, { unread: 0 }); } + if (p === '/read' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 5e3) || '{}'), items = load(me.id), hit = items.filter(i => !i.read && (b.all || i.id === b.id)); for (const i of items) if (b.all || i.id === b.id) i.read = true; save(me.id, items); await settle(me.id, hit); return json(res, 200, { unread: unread(me.id) }); } + if (p === '/clear' && req.method === 'POST') { const hit = load(me.id).filter(i => !i.read); save(me.id, []); await settle(me.id, hit); return json(res, 200, { unread: 0 }); } return json(res, 404, { error: 'Not found' }); } catch (e) { return json(res, e instanceof SyntaxError ? 400 : 500, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); } } -module.exports = { handle, add, unread, load }; +module.exports = { handle, add, unread, load, markTagRead }; diff --git a/mail.js b/mail.js index f334cc9..bfee382 100644 --- a/mail.js +++ b/mail.js @@ -332,11 +332,15 @@ async function handle(req, res) { /** The newest messages in each connected account's Inbox (for the push alerts). One account that fails does not stop the others. */ async function newest() { const out = []; - for (const a of accounts()) { try { const r = await withTimeout(providerOf(a).list(a, { folder: 'inbox', skip: 0, q: '' }), 15000); for (const m of r.items.slice(0, 10)) out.push({ id: a.id + ':' + m.id, subject: m.subject, from: (m.from && (m.from.name || m.from.address)) || '', read: !!m.read }); } catch { /* skipped this round */ } } + for (const a of accounts()) { try { const r = await withTimeout(providerOf(a).list(a, { folder: 'inbox', skip: 0, q: '' }), 15000); for (const m of r.items.slice(0, 10)) out.push({ acct: a.id, mid: m.id, id: a.id + ':' + m.id, subject: m.subject, from: (m.from && (m.from.name || m.from.address)) || '', read: !!m.read }); } catch { /* skipped this round */ } } return out; } +/** Mark messages read: what a cleared "new mail" notification stands for. refs: [{ a: account id, id: message id }]. */ +async function markRead(refs) { + for (const r of (Array.isArray(refs) ? refs : []).slice(0, 50)) { try { const a = findAccount(r.a); await providerOf(a).update(a, r.id, { read: true }); dropFolderCache(a.id); } catch { /* gone or unreachable: nothing to mark */ } } +} /** For the admin health page: can each connected account be reached right now? */ async function health() { return Promise.all(accounts().map(async a => { const t = Date.now(); try { await withTimeout(providerOf(a).unread(a), 8000); return { address: a.address, type: a.type, ok: true, ms: Date.now() - t }; } catch (e) { return { address: a.address, type: a.type, ok: false, error: e.message }; } })); } -module.exports = { health, newest, enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml }; +module.exports = { markRead, health, newest, enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml }; diff --git a/public/chat-view.js b/public/chat-view.js index fa0e5a6..92f7c4a 100644 --- a/public/chat-view.js +++ b/public/chat-view.js @@ -17,6 +17,10 @@ async function chStart() { ch.es = new EventSource('/api/chat/events'); ch.es.onmessage = e => { let ev; try { ev = JSON.parse(e.data); } catch { return; } chEvent(ev); }; } +// (iOS keeps a suspended app's connection open for minutes, and the server skips the phone push for anyone it thinks is looking: close the stream whenever the app is hidden) +const chHide = () => { if (ch.es) { ch.es.close(); ch.es = null; } }; +document.addEventListener('visibilitychange', () => { if (document.hidden) chHide(); else chStart(); }); +window.addEventListener('pagehide', chHide); let chEvT = null; function chEvent(ev) { if (typeof ev.unread === 'number' && ev.unread !== ch.unread) { ch.unread = ev.unread; renderNav(activeTab); } diff --git a/public/inbox-view.js b/public/inbox-view.js index 074ca0a..c23675b 100644 --- a/public/inbox-view.js +++ b/public/inbox-view.js @@ -2,8 +2,13 @@ // ---- Notifications: a bell with the count of things you have not seen yet (chat, reminders, announcements); tapping it opens the list. let inboxN = 0; const inboxBell = () => ``; -function inboxWire() { const b = document.getElementById('inboxbtn'); if (b) b.onclick = inboxOpen; inboxPaint(); inboxCount(); } +function inboxWire() { const b = document.getElementById('inboxbtn'); if (b) b.onclick = inboxOpen; inboxPaint(); inboxCount(); if (typeof pushResync === 'function') pushResync(); } function inboxPaint() { const n = document.getElementById('inboxn'); if (n) { n.hidden = !inboxN; n.textContent = inboxN > 99 ? '99+' : inboxN; } } +/** What a read or cleared notification stands for, on this device: its banner on the lock screen goes, and an announcement stops showing on Home. (The server marks the chats and mail read.) */ +async function inboxSettled(items, all) { + for (const i of items) if (i.ref && i.ref.announce) { try { localStorage.setItem('ch_ann_gone:' + (typeof ME !== 'undefined' && ME ? ME.id : ''), i.ref.announce); } catch {} const a = document.getElementById('annc'); if (a) a.innerHTML = ''; } + try { const reg = await navigator.serviceWorker.ready, tags = items.map(i => i.tag).filter(Boolean); for (const n of await reg.getNotifications()) if (all || tags.includes(n.tag)) n.close(); } catch {} +} async function inboxCount() { try { const r = await jfetch('/api/inbox'); inboxN = r.unread; inboxPaint(); } catch {} } setInterval(() => { if (!document.hidden && typeof ME !== 'undefined' && ME && document.getElementById('inboxbtn')) inboxCount(); }, 45000); const inboxAgo = t => { const m = Math.round((Date.now() - t) / 6e4); return m < 1 ? 'now' : m < 60 ? m + ' min ago' : m < 1440 ? Math.round(m / 60) + ' h ago' : new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }; @@ -14,11 +19,11 @@ async function inboxOpen() { const draw = () => { const l = el.querySelector('#inboxl'); if (!l) return; el.querySelector('#inboxbar').hidden = !items.length; l.innerHTML = items.length ? `
Nothing yet. Messages, reminders and announcements will show up here.
'; - l.querySelectorAll('a.row').forEach(a => a.addEventListener('click', () => { const id = a.dataset.id; jfetch('/api/inbox/read', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }).then(r => { inboxN = r.unread; inboxPaint(); }).catch(() => {}); closeSheet(); })); + l.querySelectorAll('a.row').forEach(a => a.addEventListener('click', () => { const id = a.dataset.id; inboxSettled(items.filter(i => i.id === id)); jfetch('/api/inbox/read', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }).then(r => { inboxN = r.unread; inboxPaint(); }).catch(() => {}); closeSheet(); })); }; try { const r = await jfetch('/api/inbox'); items = r.items; inboxN = r.unread; inboxPaint(); } catch (e) { toast(e.message); } draw(); const post = (p, b) => jfetch('/api/inbox/' + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b || {}) }); - el.querySelector('#inboxall').onclick = async () => { try { const r = await post('read', { all: true }); items.forEach(i => i.read = true); inboxN = r.unread; inboxPaint(); draw(); } catch (e) { toast(e.message); } }; - el.querySelector('#inboxclr').onclick = async () => { try { await post('clear'); items = []; inboxN = 0; inboxPaint(); draw(); } catch (e) { toast(e.message); } }; + el.querySelector('#inboxall').onclick = async () => { try { inboxSettled(items.filter(i => !i.read), true); const r = await post('read', { all: true }); items.forEach(i => i.read = true); inboxN = r.unread; inboxPaint(); draw(); } catch (e) { toast(e.message); } }; + el.querySelector('#inboxclr').onclick = async () => { try { inboxSettled(items.filter(i => !i.read), true); await post('clear'); items = []; inboxN = 0; inboxPaint(); draw(); } catch (e) { toast(e.message); } }; } diff --git a/public/reminders.js b/public/reminders.js index ac6c4b1..9b319b2 100644 --- a/public/reminders.js +++ b/public/reminders.js @@ -3,6 +3,17 @@ const remSupported = () => 'serviceWorker' in navigator && 'PushManager' in wind const b64ToBytes = s => { const p = '='.repeat((4 - s.length % 4) % 4), r = atob((s + p).replace(/-/g, '+').replace(/_/g, '/')); return Uint8Array.from([...r].map(c => c.charCodeAt(0))); }; const myTz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; +/** Each time the app opens: if this device has a push subscription, tell the server again (it forgets a device when the push service says it is gone, and the time zone can change). */ +let pushResynced = false; +async function pushResync() { + if (pushResynced) return; pushResynced = true; + try { + if (!remSupported() || Notification.permission !== 'granted') return; + const reg = await Promise.race([navigator.serviceWorker.ready, new Promise(ok => setTimeout(ok, 4000))]), s = reg && await reg.pushManager.getSubscription(); if (!s) return; + await jfetch('/api/push/subscribe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ subscription: s.toJSON(), tz: myTz() }) }); + } catch { /* try again next time the app opens */ } +} + function remindersHtml() { return `Get a notification for tasks due today and events about to start, even when the app is closed. Turn it on for each device you want reminders on.