Keyboard shortcuts for mail and a shortcut list, offline help and refresh on return, push alerts for new mail
This commit is contained in:
1 parent
93ae8f8840
commit
2c8afdef79
8 files changed
+71
-7
No files matched your search
@@ -329,4 +329,10 @@ async function handle(req, res) {
|
|||||||
} catch (e) { json(res, e.status || 400, { error: e.message }); }
|
} 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 };
|
||||||
@@ -80,6 +80,7 @@
|
|||||||
<script src="/signal-view.js"></script>
|
<script src="/signal-view.js"></script>
|
||||||
<script src="/mail.js"></script>
|
<script src="/mail.js"></script>
|
||||||
<script src="/share.js"></script>
|
<script src="/share.js"></script>
|
||||||
|
<script src="/keys.js"></script>
|
||||||
<script src="/boot.js"></script>
|
<script src="/boot.js"></script>
|
||||||
<script src="/shell.js"></script>
|
<script src="/shell.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
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>
|
${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>` : ''}
|
<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>
|
${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"><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>
|
<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); }
|
} 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)); };
|
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 () => {
|
document.getElementById('remclear').onclick = async () => {
|
||||||
try {
|
try {
|
||||||
const list = await reg.getNotifications(); list.forEach(n => n.close());
|
const list = await reg.getNotifications(); list.forEach(n => n.close());
|
||||||
|
|||||||
+16
-2
@@ -4,10 +4,24 @@
|
|||||||
// ---- Offline notice
|
// ---- Offline notice
|
||||||
const bar = document.createElement('div');
|
const bar = document.createElement('div');
|
||||||
bar.id = 'offline'; bar.setAttribute('role', 'status'); bar.hidden = true;
|
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);
|
document.body.appendChild(bar);
|
||||||
const sync = () => { bar.hidden = navigator.onLine; };
|
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
|
// ---- Skip link and landmarks
|
||||||
const skip = document.createElement('a');
|
const skip = document.createElement('a');
|
||||||
|
|||||||
@@ -796,3 +796,4 @@ body.nav-mini .ngrp span { display: none; }
|
|||||||
.vhero { --vtint: transparent; }
|
.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; }
|
.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; }
|
.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
@@ -1,5 +1,5 @@
|
|||||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
// 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 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'];
|
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function keys() {
|
|||||||
return vapid;
|
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 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 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));
|
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')) {
|
if (checkEvents && st.events && users.can(u, 'calendar')) {
|
||||||
try {
|
try {
|
||||||
const from = now.getTime(), to = from + (st.eventMins + 5) * 60e3;
|
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) {
|
async function handle(req, res) {
|
||||||
const p = new URL(req.url, 'http://x').pathname, dir = users.dir(), st = load(dir);
|
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 (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' });
|
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
|
||||||
const b = JSON.parse((await readBody(req)) || '{}');
|
const b = JSON.parse((await readBody(req)) || '{}');
|
||||||
@@ -115,6 +128,7 @@ async function handle(req, res) {
|
|||||||
if (p === '/api/push/prefs') {
|
if (p === '/api/push/prefs') {
|
||||||
if (typeof b.tasks === 'boolean') st.tasks = b.tasks;
|
if (typeof b.tasks === 'boolean') st.tasks = b.tasks;
|
||||||
if (typeof b.events === 'boolean') st.events = b.events;
|
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 (/^([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 ([5, 10, 15, 30, 60].includes(b.eventMins)) st.eventMins = b.eventMins;
|
||||||
if (b.tz && validTz(b.tz)) st.tz = b.tz;
|
if (b.tz && validTz(b.tz)) st.tz = b.tz;
|
||||||
|
|||||||
Reference in new issue
Block a user