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 ? `
${items.map(i => { const [ic, col] = INBOX_ICON[i.kind] || ['bell', '#6a7280']; return `${icon(ic)}${esc(i.title)}${i.count > 1 ? ` (${i.count})` : ''}${esc(i.body)}${inboxAgo(i.at)}${i.read ? '' : ''}`; }).join('')}
` : '

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 `

Reminders

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.

Checking…
`; diff --git a/public/sw.js b/public/sw.js index 8d5ae10..b180e47 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-v89'; +const CACHE = 'hub-v90'; 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']; @@ -59,6 +59,14 @@ self.addEventListener('push', e => { let d = {}; try { d = e.data.json(); } catch {} e.waitUntil(self.registration.showNotification(d.title || 'NE Hub', { body: d.body || '', icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', tag: d.tag, data: { url: d.url || '#/home' } })); }); +// The push service can replace a device's subscription: make a new one and give it to the server so reminders keep arriving. +self.addEventListener('pushsubscriptionchange', e => e.waitUntil((async () => { + try { + const k = await (await fetch('/api/push/key', { credentials: 'same-origin' })).json(); + const s = await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: k.publicKey }); + await fetch('/api/push/subscribe', { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ subscription: s.toJSON() }) }); + } catch { /* signed out or offline: the app re-registers when it is next opened */ } +})())); self.addEventListener('notificationclick', e => { e.notification.close(); const url = '/' + ((e.notification.data && e.notification.data.url) || ''); diff --git a/push.js b/push.js index df772e3..0916a1c 100644 --- a/push.js +++ b/push.js @@ -36,11 +36,11 @@ function localNow(tz, at = new Date()) { } async function send(dir, st, uid, payload) { - if (uid) inbox.add(uid, { kind: 'remind', title: payload.title, body: payload.body, url: payload.url, tag: payload.tag }); // (reminders also land in the Notifications list) - keys(); + if (uid) inbox.add(uid, { kind: 'remind', title: payload.title, body: payload.body, url: payload.url, tag: payload.tag, ref: payload.ref }); // (reminders also land in the Notifications list) + keys(); const { ref, ...wire } = payload; let changed = false; await Promise.all(st.subs.map(async s => { - try { await webpush.sendNotification(s.sub, JSON.stringify(payload), { TTL: 3600 }); } + try { await webpush.sendNotification(s.sub, JSON.stringify(wire), { TTL: 3600 }); } catch (e) { if (e.statusCode === 404 || e.statusCode === 410) { s.dead = true; changed = true; } else console.warn('[push] send failed:', e.statusCode || e.message); } })); if (changed) { st.subs = st.subs.filter(s => !s.dead); save(dir, st); } @@ -87,7 +87,7 @@ async function tick(now = new Date(), force = false) { const fresh = now2.filter(m => !seen.includes(m.id)); if (fresh.length) { st.mailSeen = seen.concat(fresh.map(m => m.id)).slice(-200); dirty = true; - await send(dir, st, u.id, { title: fresh.length === 1 ? (fresh[0].from || 'New mail') : fresh.length + ' new messages', body: fresh.length === 1 ? fresh[0].subject : fresh.slice(0, 3).map(m => m.from || m.subject).join(', '), url: '#/mail', tag: 'mail' }); + await send(dir, st, u.id, { title: fresh.length === 1 ? (fresh[0].from || 'New mail') : fresh.length + ' new messages', body: fresh.length === 1 ? fresh[0].subject : fresh.slice(0, 3).map(m => m.from || m.subject).join(', '), url: '#/mail', tag: 'mail', ref: { mail: fresh.map(m => ({ a: m.acct, id: m.mid })).filter(r => r.a && r.id) } }); } } } catch (e) { console.warn('[push] mail check failed:', e.message); } diff --git a/tools/inbox-test.js b/tools/inbox-test.js index 49ea7e8..eda11a7 100644 --- a/tools/inbox-test.js +++ b/tools/inbox-test.js @@ -14,7 +14,18 @@ const hub = http.createServer((req, res) => users.runAs(P[req.headers['x-user'] await post('/api/chat/send', { conv: dm.id, text: 'new one' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.equal(r.items.filter(i => i.kind === 'chat').length, 1, 'still unread: updated in place'); assert.equal(r.items.find(i => i.kind === 'chat').count, 3); r = await j(await post('/api/inbox/read', { all: true }, 'b')); assert.equal(r.unread, 0); await post('/api/chat/send', { conv: dm.id, text: 'after reading' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.equal(r.items.filter(i => i.kind === 'chat').length, 2, 'a read entry stays; a new one starts'); - await post('/api/inbox/clear', {}, 'b'); assert.equal((await j(await call('/api/inbox', {}, 'b'))).items.length, 0); console.log('inbox ok'); + await post('/api/inbox/clear', {}, 'b'); assert.equal((await j(await call('/api/inbox', {}, 'b'))).items.length, 0); + // reading or clearing a notification settles the chat itself, and opening the chat settles the notification + const unr = async () => (await j(await call('/api/chat/status', {}, 'b'))).unread; + assert.equal(await unr(), 0, 'clearing the notifications read the chat'); + await post('/api/chat/send', { conv: dm.id, text: 'one more' }, 'a'); assert.equal(await unr(), 1); assert.equal((await j(await call('/api/inbox', {}, 'b'))).unread, 1); + await post('/api/chat/read', { id: dm.id }, 'b'); assert.equal((await j(await call('/api/inbox', {}, 'b'))).unread, 0, 'opening the chat settled its notification'); + await post('/api/chat/send', { conv: dm.id, text: 'last' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.equal(await unr(), 1); + await post('/api/inbox/read', { id: r.items[0].id }, 'b'); assert.equal(await unr(), 0, 'marking one notification read read its chat'); + await post('/api/chat/send', { conv: dm.id, text: 'and again' }, 'a'); assert.equal(await unr(), 1); + await post('/api/inbox/read', { all: true }, 'b'); assert.equal(await unr(), 0, 'mark all read read the chats too'); + await post('/api/chat/announcement', { text: 'Again' }, 'a'); r = await j(await call('/api/inbox', {}, 'b')); assert.ok(r.items[0].ref && r.items[0].ref.announce, 'an announcement knows which one it was'); + console.log('inbox ok'); } catch (e) { console.error(e); process.exitCode = 1; } hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50); });