Clearing a notification settles what it was about; fix missed phone pushes
build-container / build (push) Successful in 3m2s

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>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-27 08:55:49 -04:00
1 parent 41c48b10e8
commit a09d6b17f4
9 files changed
+82 -21

No files matched your search

+4
View File
@@ -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); }
+9 -4
View File
@@ -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 = () => `<button class="btn ghost inboxbell" id="inboxbtn" aria-label="Notifications" title="Notifications">${icon('bell')}<i class="nbadge" id="inboxn" ${inboxN ? '' : 'hidden'}>${inboxN > 99 ? '99+' : inboxN}</i></button>`;
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 ? `<div class="list">${items.map(i => { const [ic, col] = INBOX_ICON[i.kind] || ['bell', '#6a7280']; return `<a class="row" href="${esc(i.url || '#/home')}" data-id="${esc(i.id)}" style="${i.read ? 'opacity:.62' : ''}"><span class="ic" style="background:${col}">${icon(ic)}</span><span class="grow"><b>${esc(i.title)}${i.count > 1 ? ` <small>(${i.count})</small>` : ''}</b><small style="white-space:normal">${esc(i.body)}</small></span><small>${inboxAgo(i.at)}</small>${i.read ? '' : '<i class="nbadge" style="position:static;min-width:10px;height:10px;padding:0"></i>'}</a>`; }).join('')}</div>` : '<p class="empty">Nothing yet. Messages, reminders and announcements will show up here.</p>';
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); } };
}
+11
View File
@@ -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 `<div id="sec-rem"><h2>Reminders</h2><p class="sub">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.</p>
<div class="list" id="remlist"><div class="row"><span class="grow"><small>Checking…</small></span></div></div></div>`;
+9 -1
View File
@@ -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) || '');