diff --git a/mail.js b/mail.js index c8abc60..f3b5f51 100644 --- a/mail.js +++ b/mail.js @@ -329,4 +329,10 @@ async function handle(req, res) { } catch (e) { json(res, e.status || 400, { error: e.message }); } } -module.exports = { enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml }; +/** 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 */ } } + return out; +} +module.exports = { newest, enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml }; diff --git a/public/index.html b/public/index.html index b2e8c1d..8bbbd6f 100644 --- a/public/index.html +++ b/public/index.html @@ -80,6 +80,7 @@ + diff --git a/public/keys.js b/public/keys.js new file mode 100644 index 0000000..141b140 --- /dev/null +++ b/public/keys.js @@ -0,0 +1,27 @@ +'use strict'; +// Desktop keyboard shortcuts for Mail (the video and music players have their own, in video.js and player.js), and "?" for the list of all of them. +addEventListener('keydown', e => { + if (e.metaKey || e.ctrlKey || e.altKey || document.getElementById('sheet') || document.body.classList.contains('editing')) return; + const t = e.target, tag = t.tagName || '', k = e.key; + if (/^(INPUT|TEXTAREA|SELECT)$/.test(tag) || t.isContentEditable) return; + if (k === '?') { e.preventDefault(); return keysHelp(); } + if (!location.hash.startsWith('#/mail') || typeof mail === 'undefined') return; + if (k === 'j' || k === 'k') { // next / previous message in the list + const rows = [...document.querySelectorAll('a.mrow')]; if (!rows.length) return; + const sel = rows.findIndex(a => a.classList.contains('sel')), i = Math.max(0, Math.min(rows.length - 1, sel < 0 ? 0 : sel + (k === 'j' ? 1 : -1))); + e.preventDefault(); location.hash = rows[i].getAttribute('href'); rows[i].scrollIntoView({ block: 'nearest' }); return; + } + if (!mail.msg) return; + const act = { e: 'archive', '#': 'delete', Delete: 'delete', r: 'reply', a: 'replyAll', f: 'forward', u: 'unread', s: 'flag' }[k]; + if (act) { e.preventDefault(); mailAction(act); } + else if (k === 'Escape' || k === 'x') { location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); } +}); +function keysHelp() { + const row = (k, d) => `${k}${d}`; + const el = openSheet('Keyboard shortcuts', '', ` + ${row('Ctrl/⌘ K', 'Search and commands')}${row('?', 'This list')}${row('Space', 'Play or pause music')}${row('← →', 'Back or forward 10 s')}${row('N / P', 'Next or previous track')} + ${row('J / K', 'Next or previous message')}${row('E', 'Archive')}${row('# or Delete', 'Delete')}${row('R / A / F', 'Reply, reply all, forward')}${row('U', 'Mark unread')}${row('S', 'Flag')}${row('Esc or X', 'Back to the list')} + ${row('Space or K', 'Play or pause')}${row('← → / J L', 'Back or forward 10 s (30 s with Shift or J/L)')}${row('↑ ↓', 'Volume')}${row('F', 'Full screen')}${row('M', 'Mute')}${row('N / P', 'Next or previous episode')}${row('0–9', 'Jump to 0–90% of the way through')}
Anywhere
Mail
Video player
+
`); + el.querySelector('#kx').onclick = closeSheet; +} diff --git a/public/reminders.js b/public/reminders.js index 8fbb588..ac6c4b1 100644 --- a/public/reminders.js +++ b/public/reminders.js @@ -21,6 +21,7 @@ async function remindersInit() { box.innerHTML = ` ${allowed('tasks') ? `
Morning time
` : ''} + ${allowed('mail') ? `` : ''} ${allowed('calendar') ? `
How early
` : ''}
Send a test to every device that is on
@@ -42,7 +43,7 @@ async function remindersInit() { } catch (e) { on1.checked = !on1.checked; toast(e.message); } }; const pref = (id, key, get) => { const el = document.getElementById(id); if (el) el.onchange = () => post('prefs', { [key]: get(el) }).catch(e => toast(e.message)); }; - pref('remt', 'tasks', e => e.checked); pref('remat', 'tasksAt', e => e.value); pref('reme', 'events', e => e.checked); pref('remm', 'eventMins', e => +e.value); + pref('remmail', 'mail', e => e.checked); pref('remt', 'tasks', e => e.checked); pref('remat', 'tasksAt', e => e.value); pref('reme', 'events', e => e.checked); pref('remm', 'eventMins', e => +e.value); document.getElementById('remclear').onclick = async () => { try { const list = await reg.getNotifications(); list.forEach(n => n.close()); diff --git a/public/shell.js b/public/shell.js index 813d3bc..83f80b1 100644 --- a/public/shell.js +++ b/public/shell.js @@ -4,10 +4,24 @@ // ---- Offline notice const bar = document.createElement('div'); bar.id = 'offline'; bar.setAttribute('role', 'status'); bar.hidden = true; - bar.textContent = 'You are offline. Showing what was saved on this device; changes will not sync until you are back online.'; + bar.innerHTML = 'You are offline. '; document.body.appendChild(bar); const sync = () => { bar.hidden = navigator.onLine; }; - addEventListener('online', sync); addEventListener('offline', sync); sync(); + bar.querySelector('#offinfo').onclick = () => { + const el = openSheet('You are offline', 'The app opens, and these still work from what this device has saved:', ` +

Nothing is lost: it all catches up when you reconnect.

`); + el.querySelector('#offx').onclick = closeSheet; + }; + // Back online, or coming back to the app after a while away: refresh what is on screen quietly. + let hiddenAt = 0; + const refresh = () => { try { if (typeof mailPoll === 'function') mailPoll(); if (location.hash.startsWith('#/mail') && typeof mailLoadList === 'function' && typeof mail !== 'undefined' && mail.st && $('#mrows') && !mail.msg) mailLoadList(true); else if (/^#\/(home|planner|calendar|tasks)?$/.test(location.hash) && typeof route === 'function' && !document.getElementById('sheet')) route(); } catch {} }; + addEventListener('online', () => { sync(); refresh(); }); + document.addEventListener('visibilitychange', () => { if (document.hidden) hiddenAt = Date.now(); else if (hiddenAt && Date.now() - hiddenAt > 60e3 && navigator.onLine) { hiddenAt = 0; refresh(); } }); + addEventListener('offline', sync); sync(); // ---- Skip link and landmarks const skip = document.createElement('a'); diff --git a/public/style.css b/public/style.css index cc9505a..86f6d0d 100644 --- a/public/style.css +++ b/public/style.css @@ -796,3 +796,4 @@ body.nav-mini .ngrp span { display: none; } .vhero { --vtint: transparent; } .vhero::before { content: ''; position: absolute; inset: -8px -16px 0 -16px; z-index: -1; background: radial-gradient(120% 90% at 20% 0, color-mix(in srgb, var(--vtint) 45%, transparent), transparent 70%); pointer-events: none; transition: background .5s; } .vempty { grid-column: 1 / -1; display: grid; justify-items: center; gap: 8px; padding: 36px 12px; color: var(--muted); } .vempty svg { width: 40px; height: 40px; opacity: .6; } +kbd { display: inline-block; padding: 1px 7px; border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 6px; background: var(--panel2); font: 12px ui-monospace, Menlo, monospace; } diff --git a/public/sw.js b/public/sw.js index 67bc89e..ade7fed 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-v19'; +const CACHE = 'hub-v21'; 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']; diff --git a/push.js b/push.js index 535c094..af49601 100644 --- a/push.js +++ b/push.js @@ -23,7 +23,7 @@ function keys() { return vapid; } -const DEFAULTS = { tasks: true, tasksAt: '08:00', events: true, eventMins: 15, tz: 'UTC', subs: [], sent: [], tasksDay: '' }; +const DEFAULTS = { mail: true, mailSeen: null, tasks: true, tasksAt: '08:00', events: true, eventMins: 15, tz: 'UTC', subs: [], sent: [], tasksDay: '' }; const file = dir => path.join(dir, 'push.json'); const load = dir => { try { return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(file(dir), 'utf8')) }; } catch { return { ...DEFAULTS, subs: [], sent: [] }; } }; const save = (dir, o) => atomicWrite(file(dir), JSON.stringify(o)); @@ -75,6 +75,19 @@ async function tick(now = new Date(), force = false) { } } } + if (checkEvents && st.mail && users.can(u, 'mail')) { // new unread mail (the first look only notes what is already there) + try { + const now2 = (await mail.newest()).filter(m => !m.read), seen = st.mailSeen; + if (!Array.isArray(seen)) { st.mailSeen = now2.map(m => m.id); dirty = true; } + else { + 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, { 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' }); + } + } + } catch (e) { console.warn('[push] mail check failed:', e.message); } + } if (checkEvents && st.events && users.can(u, 'calendar')) { try { const from = now.getTime(), to = from + (st.eventMins + 5) * 60e3; @@ -100,7 +113,7 @@ function start() { if (!timer) { timer = setInterval(() => tick().catch(e => con async function handle(req, res) { const p = new URL(req.url, 'http://x').pathname, dir = users.dir(), st = load(dir); - const pub = () => ({ tasks: st.tasks, tasksAt: st.tasksAt, events: st.events, eventMins: st.eventMins, devices: st.subs.length }); + const pub = () => ({ mail: st.mail, tasks: st.tasks, tasksAt: st.tasksAt, events: st.events, eventMins: st.eventMins, devices: st.subs.length }); if (p === '/api/push/key' && req.method === 'GET') return json(res, 200, { publicKey: keys().publicKey, ...pub() }); if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' }); const b = JSON.parse((await readBody(req)) || '{}'); @@ -115,6 +128,7 @@ async function handle(req, res) { if (p === '/api/push/prefs') { if (typeof b.tasks === 'boolean') st.tasks = b.tasks; if (typeof b.events === 'boolean') st.events = b.events; + if (typeof b.mail === 'boolean') st.mail = b.mail; if (/^([01]\d|2[0-3]):[0-5]\d$/.test(b.tasksAt || '')) st.tasksAt = b.tasksAt; if ([5, 10, 15, 30, 60].includes(b.eventMins)) st.eventMins = b.eventMins; if (b.tz && validTz(b.tz)) st.tz = b.tz;