Files
hub/inbox.js
T
nmemmertandClaude Sonnet 5.5 a09d6b17f4
build-container / build (push) Successful in 3m2s
Clearing a notification settles what it was about; fix missed phone pushes
Reading or clearing a notification marks its chat and new mail read and closes its banner; opening a chat clears its entry. Close the chat stream while the app is hidden so the server sends the phone push, renew changed push subscriptions, and re-send the subscription on each app open.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 08:55:49 -04:00

46 lines
4.1 KiB
JavaScript

// Notifications center: a short list of what happened for each person (chat messages, reminders, announcements) so nothing depends on a phone buzz being seen.
// Each person's list lives in their own folder (inbox.json); a chat conversation keeps one entry that grows a count instead of filling the list.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const atomicWrite = require('./atomic');
const users = require('./users');
const { readBody } = require('./body');
const MAX = 120, json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').slice(0, n);
const file = uid => path.join(users.dirOf(uid), 'inbox.json');
const load = uid => { try { const j = JSON.parse(fs.readFileSync(file(uid), 'utf8')); return Array.isArray(j.items) ? j.items : []; } catch { return []; } };
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. */
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; 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), 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, markTagRead };