From 924a024b0b875a455b3289539965169975a2a1a4 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:06:19 -0400 Subject: [PATCH] PWA: install button, one icon badge for Signal, mail and tasks due, share target for files, manifest launch handler Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 ++ public/boot.js | 2 ++ public/index.html | 1 + public/manifest.webmanifest | 25 ++++++++++++++++++++++++- public/settings.js | 19 ++++++++++++++++++- public/share.js | 17 +++++++++++++++++ public/signal-view.js | 7 ++++--- public/sw.js | 14 +++++++++++++- public/tasks.js | 7 +++++++ 9 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 public/share.js diff --git a/README.md b/README.md index eb384da..cb48579 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Mobile-first; bottom tabs on phones, side rail on iPad and desktop (tuned for 12 docker compose up -d --build # http://localhost:8080 # or: node server.js +**Install extras**: Settings has an Install button where the browser offers one (Chrome, Edge, Android). The installed app's icon badge counts unread Signal chats, unread mail and tasks that are overdue or due today. On Android and desktop Chrome/Edge, NE Hub appears in the Share menu: share files to it, pick a Seafile folder, and they upload. + Put it behind your reverse proxy with HTTPS (needed for "Add to Home Screen" and the service worker), e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docker-compose.yml. diff --git a/public/boot.js b/public/boot.js index 36d2fde..ed07890 100644 --- a/public/boot.js +++ b/public/boot.js @@ -22,7 +22,9 @@ addEventListener('hashchange', route); restorePlayer(); route(); + shareCheck(); mailPoll(); setInterval(mailPoll, 2 * 60e3); + taskPoll(); setInterval(taskPoll, 10 * 60e3); if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {}); })(); diff --git a/public/index.html b/public/index.html index 038ba73..e5af507 100644 --- a/public/index.html +++ b/public/index.html @@ -77,6 +77,7 @@ + diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index 28ebca6..5a9bfc3 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -91,5 +91,28 @@ } ] } - ] + ], + "dir": "ltr", + "prefers_related_applications": false, + "launch_handler": { + "client_mode": [ + "navigate-existing", + "auto" + ] + }, + "share_target": { + "action": "/share-target", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "files": [ + { + "name": "files", + "accept": [ + "*/*" + ] + } + ] + } + } } \ No newline at end of file diff --git a/public/settings.js b/public/settings.js index 22aa398..5499f01 100644 --- a/public/settings.js +++ b/public/settings.js @@ -91,8 +91,9 @@ function viewSettings() {

Signal alerts

How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.

-
${remindersHtml()}

Install

iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.

`; +
${remindersHtml()}

Install

${installHtml()}`; remindersInit(); + installInit(); // only show what this person may use const hide = (cond, sel) => { if (cond) document.querySelectorAll(sel).forEach(e => e.remove()); }; hide(!((allowed('mail') && cfg.mail) || cfg.mailAdmin), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); hide(!allowed('signal'), '#sec-signal'); hide(!(allowed('files') || allowed('office')), '#row-sf, #out'); @@ -173,3 +174,19 @@ function viewSettings() { $('#hubout').onclick = async () => { try { mcClear(); for (const k of Object.keys(localStorage)) if (k.startsWith('dc:')) localStorage.removeItem(k); } catch {} await authPost('/api/auth/logout'); location.replace('/login'); }; const out = $('#out'); if (out) out.onclick = () => { store.del('sf_token'); toast('Signed out'); viewSettings(); }; } + +// ---- Install: the browser's own prompt where it offers one (Chrome, Edge, Android); iPhone/iPad have to use Share +let installEvt = null; +addEventListener('beforeinstallprompt', e => { e.preventDefault(); installEvt = e; const b = document.getElementById('install-btn'); if (b) installInit(); }); +addEventListener('appinstalled', () => { installEvt = null; const b = document.getElementById('install-box'); if (b) b.innerHTML = '

NE Hub is installed on this device.

'; }); +const isInstalled = () => matchMedia('(display-mode: standalone)').matches || navigator.standalone === true; +function installHtml() { + const body = isInstalled() ? '

NE Hub is installed on this device.

' + : '

iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.

'; + return '
' + body + '
'; +} +function installInit() { + const b = document.getElementById('install-btn'); if (!b || !installEvt) return; + b.hidden = false; + b.onclick = async () => { const ev = installEvt; installEvt = null; b.hidden = true; try { await ev.prompt(); } catch {} }; +} diff --git a/public/share.js b/public/share.js new file mode 100644 index 0000000..4b9d87f --- /dev/null +++ b/public/share.js @@ -0,0 +1,17 @@ +'use strict'; +// Files shared into NE Hub from other apps (the manifest's share target; sw.js keeps them): choose a Seafile folder, then upload. +async function shareCheck() { + if (!('caches' in window)) return; + let c, keys; + try { c = await caches.open('hub-share'); keys = await c.keys(); } catch { return; } + if (!keys.length) return; + const clear = async () => { for (const k of await c.keys()) await c.delete(k); }; + if (!allowed('files')) { await clear(); toast('You do not have the Files tab, so shared files were discarded'); return; } + const files = []; + for (const k of keys) { const r = await c.match(k); if (r) files.push(new File([await r.blob()], decodeURIComponent(r.headers.get('x-name') || 'file'), { type: r.headers.get('content-type') || '' })); } + const loc = store.get('office_loc'); + const dst = await chooseFolder('Save ' + (files.length === 1 ? '“' + files[0].name + '”' : files.length + ' shared files') + ' to…', loc ? { repo: loc.repo, repoName: loc.repoName || '', dir: loc.dir } : null, { okLabel: 'Upload here', newFolder: true }); + await clear(); + if (!dst) return toast('Shared files discarded'); + enqueueUploads(dst.repo, dst.dir, files.map(file => ({ file }))); +} diff --git a/public/signal-view.js b/public/signal-view.js index 40cc6e1..ad646e2 100644 --- a/public/signal-view.js +++ b/public/signal-view.js @@ -28,9 +28,10 @@ function sgChime() { g.gain.setValueAtTime(0.0001, t + d); g.gain.exponentialRampToValueAtTime(0.22, t + d + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, t + d + 0.4); o.connect(g); g.connect(sgAC.destination); o.start(t + d); o.stop(t + d + 0.45); }); } -function sgBadgeEverywhere() { - document.title = (sg.unread ? '(' + (sg.unread > 99 ? '99+' : sg.unread) + ') ' : '') + 'NE Hub'; - try { if (navigator.setAppBadge) { sg.unread ? navigator.setAppBadge(sg.unread) : navigator.clearAppBadge(); } } catch {} +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); + document.title = (n ? '(' + (n > 99 ? '99+' : n) + ') ' : '') + 'NE Hub'; + try { if (navigator.setAppBadge) { n ? navigator.setAppBadge(n) : navigator.clearAppBadge(); } } catch {} } function sgAlert(ev) { // a message from someone else arrived and you are not looking at that chat const a = sgAlerts(); diff --git a/public/sw.js b/public/sw.js index a2e6293..c503fdd 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,6 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v8'; +const CACHE = 'hub-v9'; +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']; // Install: keep the icons, and (when signed in) the page itself with the scripts and styles it names, so the app opens offline. @@ -21,8 +22,19 @@ self.addEventListener('activate', e => { e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k))))); self.clients.claim(); }); +// Share target: other apps send files here (POST); keep them and open the app, which asks where to put them. +async function takeShare(request) { + try { + const form = await request.formData(), c = await caches.open(SHARE); + for (const k of await c.keys()) await c.delete(k); + let i = 0; + for (const f of form.getAll('files')) if (f && f.name) await c.put('/share-files/' + (i++), new Response(f, { headers: { 'x-name': encodeURIComponent(f.name), 'content-type': f.type || 'application/octet-stream' } })); + } catch { /* nothing usable was shared */ } + return Response.redirect(self.location.origin + '/#/files', 303); +} self.addEventListener('fetch', e => { const u = new URL(e.request.url); + if (e.request.method === 'POST' && u.origin === location.origin && u.pathname === '/share-target') return e.respondWith(takeShare(e.request)); if (e.request.method !== 'GET' || u.origin !== location.origin || u.pathname.startsWith('/sf/') || u.pathname.startsWith('/plex/') || u.pathname.startsWith('/api/')) return; e.respondWith(fetch(e.request).then(r => { if (r.ok) { const copy = r.clone(); caches.open(CACHE).then(c => c.put(e.request, copy)); } diff --git a/public/tasks.js b/public/tasks.js index b85bdc1..e29306c 100644 --- a/public/tasks.js +++ b/public/tasks.js @@ -99,6 +99,13 @@ function newListSheet(lists, list) { el.querySelectorAll('[data-rm]').forEach(b => b.onclick = () => confirmSheet('Delete list?', 'Its tasks are deleted too.', 'Delete', async () => { await jfetch('/api/tasks/lists/delete', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); store.del('task_list'); viewTasks(); })); } +// Overdue and due-today tasks, for the icon badge +let taskDue = 0; +async function taskPoll() { + if (!allowed('tasks')) return; + try { const s = await jfetch('/api/tasks/summary?today=' + ymd(new Date())), n = (s.overdue || 0) + (s.today || 0); if (n !== taskDue) { taskDue = n; renderNav(); } } catch {} +} + // Home: a card only when something is overdue or due today async function renderTasksCard(el) { if (!el) return;