Keyboard shortcuts for mail and a shortcut list, offline help and refresh on return, push alerts for new mail

This commit is contained in:
nmemmert committed 2026-10-23 09:32:56 -04:00
1 parent 93ae8f8840
commit 2c8afdef79
8 files changed
+71 -7

No files matched your search

+7 -1
View File
@@ -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 };
+1
View File
@@ -80,6 +80,7 @@
<script src="/signal-view.js"></script>
<script src="/mail.js"></script>
<script src="/share.js"></script>
<script src="/keys.js"></script>
<script src="/boot.js"></script>
<script src="/shell.js"></script>
</body>
+27
View File
@@ -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) => `<tr><td style="padding:4px 14px 4px 0"><kbd>${k}</kbd></td><td>${d}</td></tr>`;
const el = openSheet('Keyboard shortcuts', '', `<table style="width:100%;font-size:14px"><tbody>
<tr><th colspan="2" style="text-align:left;padding-top:4px">Anywhere</th></tr>${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')}
<tr><th colspan="2" style="text-align:left;padding-top:10px">Mail</th></tr>${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')}
<tr><th colspan="2" style="text-align:left;padding-top:10px">Video player</th></tr>${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')}</tbody></table>
<div class="bar"><button class="btn ghost" id="kx" style="flex:1;justify-content:center">Close</button></div>`);
el.querySelector('#kx').onclick = closeSheet;
}
+2 -1
View File
@@ -21,6 +21,7 @@ async function remindersInit() {
box.innerHTML = `<label class="row" style="cursor:pointer"><span class="grow"><b>Reminders on this device</b><small id="remst" style="white-space:normal">${Notification.permission === 'denied' ? 'Blocked in the browser settings for this site; allow notifications there first' : on ? 'On' : 'Off'}${info.devices ? ' · ' + info.devices + (info.devices === 1 ? ' device' : ' devices') + ' in total' : ''}</small></span><input type="checkbox" id="remon" ${on ? 'checked' : ''} ${Notification.permission === 'denied' ? 'disabled' : ''}></label>
${allowed('tasks') ? `<label class="row" style="cursor:pointer"><span class="grow"><b>Tasks due today</b><small>One summary each morning</small></span><input type="checkbox" id="remt" ${info.tasks ? 'checked' : ''}></label>
<div class="row"><span class="grow"><b>Morning time</b></span><input type="time" id="remat" value="${esc(info.tasksAt)}" style="width:auto;min-width:130px"></div>` : ''}
${allowed('mail') ? `<label class="row" style="cursor:pointer"><span class="grow"><b>New mail</b><small>When a new unread message arrives (checked every few minutes)</small></span><input type="checkbox" id="remmail" ${info.mail ? 'checked' : ''}></label>` : ''}
${allowed('calendar') ? `<label class="row" style="cursor:pointer"><span class="grow"><b>Upcoming events</b><small>Shortly before they start</small></span><input type="checkbox" id="reme" ${info.events ? 'checked' : ''}></label>
<div class="row"><span class="grow"><b>How early</b></span><select id="remm" style="width:auto">${[5, 10, 15, 30, 60].map(m => `<option value="${m}" ${m === info.eventMins ? 'selected' : ''}>${m} min</option>`).join('')}</select></div>` : ''}
<div class="row"><span class="grow"><small>Send a test to every device that is on</small></span><button class="btn ghost" id="remtest" style="min-height:36px">Test</button></div>
@@ -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());
+16 -2
View File
@@ -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. <button type="button" id="offinfo" style="background:none;border:0;color:inherit;text-decoration:underline;font:inherit;cursor:pointer;padding:0 0 0 6px;min-height:0">What still works?</button>';
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:', `<ul style="line-height:1.7;padding-left:18px">
<li><b>Mail</b>: your last message lists and every message you have opened. Deleting and replying wait until you are back online.</li>
<li><b>Files</b>: folders you have opened, and the saved list of documents.</li>
<li><b>Calendar, tasks, reminders</b>: what was loaded last time.</li>
<li><b>Music, video, Signal</b>: need the connection (the song or movie streams from your server).</li></ul>
<p class="sub">Nothing is lost: it all catches up when you reconnect.</p><div class="bar"><button class="btn ghost" id="offx" style="flex:1;justify-content:center">Close</button></div>`);
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');
+1
View File
@@ -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; }
+1 -1
View File
@@ -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'];
+16 -2
View File
@@ -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;