Mail: faster open/delete (instant delete and archive, separate background IMAP connection, one-fetch message read, bodies saved on device with prefetch); Lyrics: no sweep across line-synced lines
build-container / build (push) Successful in 10m28s
build-container / build (push) Successful in 10m28s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3f57e4bde1
commit
bbbb8ef5d5
4 files changed
+62
-28
No files matched your search
+17
-18
@@ -41,27 +41,28 @@ function friendly(a, e) {
|
||||
// ---- connections: one per account, kept for a minute after the last use
|
||||
const IDLE = 5 * 60e3; // a connection is kept this long after its last use: opening one (TLS and login) is the slowest part of a request
|
||||
const pool = new Map(); // account id -> { client, timer, key }
|
||||
const forget = id => { folderCache.delete(id); boxCache.delete(id); const e = pool.get(id); if (e) { clearTimeout(e.timer); try { e.client.close(); } catch {} pool.delete(id); } };
|
||||
const drop = id => { const e = pool.get(id); if (e) { clearTimeout(e.timer); try { e.client.close(); } catch {} pool.delete(id); } };
|
||||
const forget = id => { folderCache.delete(id); boxCache.delete(id); drop(id); drop(id + ':bg'); };
|
||||
const keyOf = a => a.address + '\n' + a.pass;
|
||||
async function open(a) {
|
||||
const e = pool.get(a.id);
|
||||
if (e && e.key === keyOf(a) && e.client.usable) { clearTimeout(e.timer); e.timer = setTimeout(() => forget(a.id), IDLE).unref(); return e.client; }
|
||||
forget(a.id);
|
||||
async function open(a, slot) { // slot 'bg': a second connection for counts and folder lists, so they never queue in front of opening or deleting a message
|
||||
const pid = slot ? a.id + ':' + slot : a.id, e = pool.get(pid);
|
||||
if (e && e.key === keyOf(a) && e.client.usable) { clearTimeout(e.timer); e.timer = setTimeout(() => drop(pid), IDLE).unref(); return e.client; }
|
||||
drop(pid);
|
||||
const c = cfgOf(a);
|
||||
const client = new ImapFlow({ host: c.host, port: c.port, secure: c.secure, ...(c.test ? { doSTARTTLS: false, tls: { rejectUnauthorized: false } } : {}), auth: { user: a.address, pass: a.pass }, logger: false, emitLogs: false, socketTimeout: 60e3, greetingTimeout: 20e3, connectionTimeout: 20e3 });
|
||||
client.on('error', () => {}); client.on('close', () => { const cur = pool.get(a.id); if (cur && cur.client === client) pool.delete(a.id); });
|
||||
client.on('error', () => {}); client.on('close', () => { const cur = pool.get(pid); if (cur && cur.client === client) pool.delete(a.id); });
|
||||
try { await client.connect(); } catch (err) { throw fail(err.authenticationFailed ? 401 : 502, friendly(a, err)); }
|
||||
pool.set(a.id, { client, key: keyOf(a), timer: setTimeout(() => forget(a.id), IDLE).unref() });
|
||||
pool.set(pid, { client, key: keyOf(a), timer: setTimeout(() => drop(pid), IDLE).unref() });
|
||||
return client;
|
||||
}
|
||||
/** Run `fn(client)`; if the connection had gone stale, reconnect once. */
|
||||
async function withClient(a, fn) {
|
||||
async function withClient(a, fn, slot) {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const client = await open(a);
|
||||
const client = await open(a, slot);
|
||||
try { return await fn(client); }
|
||||
catch (err) {
|
||||
if (err.status) throw err;
|
||||
if (attempt === 0 && (!client.usable || /closed|ECONNRESET|socket|EPIPE|Connection not available/i.test(err.message || ''))) { forget(a.id); continue; }
|
||||
if (attempt === 0 && (!client.usable || /closed|ECONNRESET|socket|EPIPE|Connection not available/i.test(err.message || ''))) { drop(slot ? a.id + ':' + slot : a.id); continue; }
|
||||
throw fail(502, friendly(a, err));
|
||||
}
|
||||
}
|
||||
@@ -105,7 +106,7 @@ async function folders(a) {
|
||||
const rows = keep.map(b => { const k = byPath[b.path], st = counts.get(b.path) || {}; return { id: k || 'p-' + Buffer.from(b.path).toString('base64url'), name: k ? NAMES[k] : b.path.replace(/^\[.*?\]\//, ''), unread: st.unseen || 0, total: st.messages || 0, well: !!k }; });
|
||||
const order = Object.keys(NAMES);
|
||||
return rows.sort((x, y) => (x.well ? order.indexOf(x.id) : 99) - (y.well ? order.indexOf(y.id) : 99) || x.name.localeCompare(y.name));
|
||||
});
|
||||
}, 'bg');
|
||||
folderCache.set(a.id, { t: Date.now(), v }); return v;
|
||||
}
|
||||
|
||||
@@ -141,12 +142,10 @@ async function source(a, mid) {
|
||||
return withClient(a, async client => {
|
||||
const lock = await client.getMailboxLock(path);
|
||||
try {
|
||||
const dl = await client.download(String(uid), undefined, { uid: true });
|
||||
if (!dl || !dl.content) throw fail(404, 'That message is gone');
|
||||
const chunks = []; let n = 0;
|
||||
for await (const ch of dl.content) { n += ch.length; if (n > MAX_SOURCE) throw fail(400, 'That message is too large to open here'); chunks.push(ch); }
|
||||
const meta = await client.fetchOne(String(uid), { flags: true }, { uid: true }).catch(() => null);
|
||||
return { parsed: await simpleParser(Buffer.concat(chunks)), path, uid, flags: meta ? meta.flags : new Set() };
|
||||
const m = await client.fetchOne(String(uid), { source: { maxLength: MAX_SOURCE + 1 }, flags: true }, { uid: true }); // (one round trip for the body and the flags)
|
||||
if (!m || !m.source) throw fail(404, 'That message is gone');
|
||||
if (m.source.length > MAX_SOURCE) throw fail(400, 'That message is too large to open here');
|
||||
return { parsed: await simpleParser(m.source), path, uid, flags: m.flags || new Set() };
|
||||
} finally { lock.release(); }
|
||||
});
|
||||
}
|
||||
@@ -193,7 +192,7 @@ async function del(a, mid, permanent) {
|
||||
}
|
||||
/** Counts shown in the folder list are kept 30 s; reading, moving, deleting or sending changes them, so the next request must ask again. */
|
||||
const dropFolders = id => { if (id) folderCache.delete(id); else folderCache.clear(); };
|
||||
async function unread(a) { return withClient(a, async client => (await client.status('INBOX', { unseen: true })).unseen || 0); }
|
||||
async function unread(a) { return withClient(a, async client => (await client.status('INBOX', { unseen: true })).unseen || 0, 'bg'); }
|
||||
|
||||
// ---- sending
|
||||
function transport(a) {
|
||||
|
||||
+42
-7
@@ -16,7 +16,8 @@ function mcPut(k, v) {
|
||||
localStorage.setItem(ix, JSON.stringify(list));
|
||||
} catch { /* storage full or blocked: go without */ }
|
||||
}
|
||||
function mcClear() { try { for (const k of Object.keys(localStorage)) if (k.startsWith('mc:' + (ME ? ME.id : '') + ':')) localStorage.removeItem(k); } catch {} }
|
||||
function mcClear() { if (typeof mbClear === 'function') mbClear();
|
||||
try { for (const k of Object.keys(localStorage)) if (k.startsWith('mc:' + (ME ? ME.id : '') + ':')) localStorage.removeItem(k); } catch {} }
|
||||
const mailFrom = f => (f && (f.name || f.address)) || '';
|
||||
const mailWhen = iso => {
|
||||
const d = new Date(iso), now = new Date(); if (isNaN(d)) return '';
|
||||
@@ -234,7 +235,7 @@ async function mailLoadList(reset) {
|
||||
if (!$('#mrows') || mail.folder !== folder) return;
|
||||
mail.items = showing ? mailApplyMine(r.items) : mail.items.concat(mailApplyMine(r.items)); mail.more = r.more; mail.next = r.next;
|
||||
if (first) mcPut('l:' + folder, { items: r.items, more: r.more, next: r.next });
|
||||
mailDrawList();
|
||||
mailDrawList(); mailPrefetch();
|
||||
} catch (e) { if (showing) return; const el = $('#mrows'); if (el) el.innerHTML = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; if (/Connect again/.test(e.message)) { mail.st = null; mcClear(); setTimeout(route, 1200); } }
|
||||
}
|
||||
function mailMarkSel(id) { document.querySelectorAll('a.mrow').forEach(a => a.classList.toggle('sel', !!id && decodeURIComponent(a.getAttribute('href').slice(9)) === id)); }
|
||||
@@ -248,8 +249,34 @@ function mailDrawList() {
|
||||
if ($('#mmb')) $('#mmb').onclick = () => mailLoadList(false);
|
||||
}
|
||||
|
||||
// Opened messages are also kept on this device (IndexedDB, the newest 80), so one you have read opens at once, even offline. A message's text never
|
||||
// changes, so only its read/flagged state is taken fresh (from the list). Cleared with the rest of the saved mail when you disconnect or sign out.
|
||||
const mbDB = () => mbDB.p || (mbDB.p = new Promise(ok => { try { const r = indexedDB.open('hub-mail', 1); r.onupgradeneeded = () => r.result.createObjectStore('b'); r.onsuccess = () => ok(r.result); r.onerror = () => ok(null); } catch { ok(null); } }));
|
||||
const mbKey = id => (ME ? ME.id : '') + ':' + id;
|
||||
async function mbGet(id) { const db = await mbDB(); if (!db) return null; return new Promise(ok => { try { const r = db.transaction('b').objectStore('b').get(mbKey(id)); r.onsuccess = () => ok(r.result && r.result.m || null); r.onerror = () => ok(null); } catch { ok(null); } }); }
|
||||
async function mbPut(id, m) {
|
||||
const db = await mbDB(); if (!db || JSON.stringify(m).length > 1.5e6) return;
|
||||
try {
|
||||
const st = db.transaction('b', 'readwrite').objectStore('b'); st.put({ t: Date.now(), m }, mbKey(id));
|
||||
const all = st.getAll(), keys = st.getAllKeys();
|
||||
keys.onsuccess = () => { const rows = keys.result.map((k, i) => [k, all.result[i].t]).sort((x, y) => y[1] - x[1]); for (const [k] of rows.slice(80)) st.delete(k); }; // (all was asked first, so it is done when keys is)
|
||||
} catch {}
|
||||
}
|
||||
async function mbClear() { const db = await mbDB(); if (db) try { db.transaction('b', 'readwrite').objectStore('b').clear(); } catch {} }
|
||||
const mailCache = new Map(); // message id -> { t, p: promise }: a message you point at or tap is fetched once and kept for a minute
|
||||
const mailFetch = id => { const c = mailCache.get(id); if (c && Date.now() - c.t < 60e3) return c.p; const p = jfetch('/api/mail/message?id=' + encodeURIComponent(id)); mailCache.set(id, { t: Date.now(), p }); p.catch(() => mailCache.delete(id)); if (mailCache.size > 30) mailCache.delete(mailCache.keys().next().value); return p; };
|
||||
const mailFetch = id => {
|
||||
const c = mailCache.get(id); if (c && Date.now() - c.t < 60e3) return c.p;
|
||||
const p = mbGet(id).then(saved => {
|
||||
if (saved) { const it = mail.items.find(x => x.id === id); return it ? { ...saved, read: it.read, flagged: it.flagged } : saved; } // (opens at once)
|
||||
return jfetch('/api/mail/message?id=' + encodeURIComponent(id)).then(m => { mbPut(id, m); return m; });
|
||||
});
|
||||
mailCache.set(id, { t: Date.now(), p }); p.catch(() => mailCache.delete(id)); if (mailCache.size > 30) mailCache.delete(mailCache.keys().next().value); return p;
|
||||
};
|
||||
let mailPre = 0; // fetches the newest unread-or-recent messages of the open list one at a time, so they are already here when tapped
|
||||
async function mailPrefetch() {
|
||||
const run = ++mailPre, ids = mail.items.slice(0, 10).map(x => x.id);
|
||||
for (const id of ids) { if (run !== mailPre || !location.hash.startsWith('#/mail')) return; try { if (!(await mbGet(id))) await mailFetch(id); } catch {} }
|
||||
}
|
||||
document.addEventListener('pointerover', e => { const a = e.target.closest && e.target.closest('a.mrow'); if (a && a.getAttribute('href').startsWith('#/mail/m/')) mailFetch(decodeURIComponent(a.getAttribute('href').slice(9))).catch(() => {}); }, { passive: true });
|
||||
document.addEventListener('touchstart', e => { const a = e.target.closest && e.target.closest('a.mrow'); if (a && a.getAttribute('href').startsWith('#/mail/m/')) mailFetch(decodeURIComponent(a.getAttribute('href').slice(9))).catch(() => {}); }, { passive: true });
|
||||
// What this device has just done to a message (read, unread), kept for a minute: a list that was already on its way from the server
|
||||
@@ -307,15 +334,23 @@ function mailDrawReader() {
|
||||
async function mailAction(a) {
|
||||
const m = mail.msg; if (!m) return;
|
||||
const post = (p, body) => jfetch('/api/mail/' + p, { method: 'POST', body: JSON.stringify({ id: m.id, ...body }) });
|
||||
const back = keep => { if (!keep) { mail.items = mail.items.filter(x => x.id !== m.id); const sv = mcGet('l:' + mail.folder); if (sv && sv.items) mcPut('l:' + mail.folder, { items: sv.items.filter(x => x.id !== m.id), more: sv.more, next: sv.next }); } mail.msg = null; location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); mailPoll(); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; }).catch(() => {}); };
|
||||
const refresh = () => { mailPoll(); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; mailRefreshFolders(); }).catch(() => {}); };
|
||||
const gone = () => { mail.items = mail.items.filter(x => x.id !== m.id); const sv = mcGet('l:' + mail.folder); if (sv && sv.items) mcPut('l:' + mail.folder, { items: sv.items.filter(x => x.id !== m.id), more: sv.more, next: sv.next }); };
|
||||
const back = keep => { if (!keep) gone(); mail.msg = null; location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); refresh(); };
|
||||
// Archive and delete leave at once and finish in the background; if the server refuses, the message comes back
|
||||
const away = (p, body, said) => {
|
||||
const had = mail.items.find(x => x.id === m.id), at = mail.items.findIndex(x => x.id === m.id);
|
||||
toast(said); back();
|
||||
post(p, body).catch(e => { toast(e.message); if (had) { mail.items.splice(Math.min(at < 0 ? 0 : at, mail.items.length), 0, had); mailDrawList(); } refresh(); });
|
||||
};
|
||||
try {
|
||||
if (a === 'reply' || a === 'replyAll' || a === 'forward') return mailCompose({ mode: a, msg: m });
|
||||
if (a === 'archive') { await post('move', { to: 'archive' }); toast('Archived'); return back(); }
|
||||
if (a === 'archive') return away('move', { to: 'archive' }, 'Archived');
|
||||
if (a === 'unread') { await post('update', { read: false }); m.read = false; mailSetRead(m.id, false); toast('Marked unread'); if (mailInInbox()) { mailUnread++; renderNav(); } return back(true); }
|
||||
if (a === 'flag') { await post('update', { flagged: !m.flagged }); m.flagged = !m.flagged; const it = mail.items.find(x => x.id === m.id); if (it) it.flagged = m.flagged; mailDrawReader(); return mailDrawList(); }
|
||||
if (a === 'delete') {
|
||||
if (/(^|~)deleteditems$/.test(mail.folder)) return confirmSheet('Delete for good?', m.subject, 'Delete', async () => { try { await post('delete', { permanent: true }); toast('Deleted'); back(); } catch (e) { toast(e.message); } });
|
||||
await post('delete', {}); toast('Moved to Deleted'); return back();
|
||||
if (/(^|~)deleteditems$/.test(mail.folder)) return confirmSheet('Delete for good?', m.subject, 'Delete', () => away('delete', { permanent: true }, 'Deleted'));
|
||||
return away('delete', {}, 'Moved to Deleted');
|
||||
}
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
+2
-2
@@ -452,8 +452,8 @@ body.lyropen { overflow: hidden; }
|
||||
.ll.on { opacity: 1; transform: scale(1.04); }
|
||||
.lgap { height: 24px; }
|
||||
.ll .lt { color: inherit; }
|
||||
.ll.on .lt { background: linear-gradient(90deg, var(--accent) calc(var(--p, 0) * 100%), var(--text) calc(var(--p, 0) * 100%)); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
||||
.ll.on .lt:has(.w) { background: none; color: inherit; }
|
||||
.ll.on .lt { color: var(--accent); } /* (a line-synced song shows the whole current line; only real word timing sweeps across words) */
|
||||
.ll.on .lt:has(.w) { color: inherit; }
|
||||
.ll .w { color: inherit; }
|
||||
.ll.on .w { background: linear-gradient(90deg, var(--accent) calc(var(--p, 0) * 100%), var(--text) calc(var(--p, 0) * 100%)); -webkit-background-clip: text; background-clip: text; color: transparent; white-space: pre-wrap; }
|
||||
.ll.on .w.sung { background: none; color: var(--accent); }
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v14';
|
||||
const CACHE = 'hub-v16';
|
||||
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'];
|
||||
|
||||
|
||||
Reference in new issue
Block a user