Mail: instant first paint from the last saved accounts, folders and list; Outlook folders in one batched call; gzip for app files and large mail responses

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 10:39:40 -04:00
1 parent fb7aabff61
commit 84a0ea10e9
6 files changed
+69 -17

No files matched your search

+41 -10
View File
@@ -4,6 +4,19 @@
let mailUnread = 0;
const mail = { st: null, accts: [], acct: '', folders: [], folder: 'inbox', items: [], more: false, next: 0, q: '', msg: null, images: false, loading: false };
// The last mail you looked at is kept on this device (subjects, senders, dates; no message bodies) so Mail can show it the instant you open the tab
// while the fresh copy loads behind it. Removed when you disconnect an account or sign out.
const mcKey = k => 'mc:' + (ME ? ME.id : '') + ':' + k;
const mcGet = k => { try { const o = JSON.parse(localStorage.getItem(mcKey(k))); return o && o.t ? o : null; } catch { return null; } };
function mcPut(k, v) {
try {
localStorage.setItem(mcKey(k), JSON.stringify({ t: Date.now(), ...v }));
const ix = mcKey('index'), list = (JSON.parse(localStorage.getItem(ix) || '[]')).filter(x => x !== k); list.unshift(k);
for (const old of list.splice(16)) localStorage.removeItem(mcKey(old));
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 {} }
const mailFrom = f => (f && (f.name || f.address)) || '';
const mailWhen = iso => {
const d = new Date(iso), now = new Date(); if (isNaN(d)) return '';
@@ -45,7 +58,7 @@ function mailSettings() {
box.innerHTML = (s.accounts.length ? s.accounts.map(a => `<div class="row"><span class="grow"><b>${esc(MAIL_LABEL[a.type] || a.type)}</b><small>${esc(a.address || 'Connected')}${a.error ? ' · ' + esc(a.error) : ''}</small></span><button class="btn danger" data-dis="${esc(a.id)}" style="min-height:36px">Disconnect</button></div>`).join('') : `<div class="row"><span class="grow"><b>No mail account yet</b><small>${s.expired ? 'Microsoft signed you out. ' : ''}Add Outlook.com, Gmail or iCloud Mail</small></span></div>`)
+ `<div class="row"><span class="grow"></span><button class="btn ghost" id="madd" style="min-height:36px">Add an account</button></div>`;
$('#madd').onclick = () => mailAddAccount(s);
box.querySelectorAll('[data-dis]').forEach(d => d.onclick = () => { const a = s.accounts.find(x => x.id === d.dataset.dis); confirmSheet('Disconnect ' + (a ? a.address : 'this account') + '?', 'NE Hub forgets the sign-in. Your mailbox is not touched.', 'Disconnect', async () => { try { await jfetch('/api/mail/disconnect', { method: 'POST', body: JSON.stringify({ acct: d.dataset.dis }) }); mail.st = null; mail.items = []; mail.folders = []; mail.acct = ''; await mailPoll(); mailSettings(); } catch (e) { toast(e.message); } }); });
box.querySelectorAll('[data-dis]').forEach(d => d.onclick = () => { const a = s.accounts.find(x => x.id === d.dataset.dis); confirmSheet('Disconnect ' + (a ? a.address : 'this account') + '?', 'NE Hub forgets the sign-in. Your mailbox is not touched.', 'Disconnect', async () => { try { await jfetch('/api/mail/disconnect', { method: 'POST', body: JSON.stringify({ acct: d.dataset.dis }) }); mail.st = null; mcClear(); mail.items = []; mail.folders = []; mail.acct = ''; await mailPoll(); mailSettings(); } catch (e) { toast(e.message); } }); });
}).catch(() => { box.innerHTML = '<div class="row"><span class="grow"><small>Could not check</small></span></div>'; });
else box.innerHTML = '';
if (cfg.mailAdmin) mailAdminCard();
@@ -128,22 +141,33 @@ async function viewMail(args = []) {
// The accounts come from the server without any network call to the mail providers ("light"), so the page can draw at once; the folders, the message
// list and the real unread counts then load side by side instead of one after another.
let st = mail.st;
if (!st) { v.innerHTML = '<div class="wrap"><h1>Mail</h1><p class="empty">Loading…</p></div>'; try { st = await jfetch('/api/mail/status?light=1'); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Mail</h1><p class="err">${esc(e.message)}</p></div>`; return; } }
if (!st) {
const saved = mcGet('st'); // the accounts as of last time: draw from them now and check with the server behind it
if (saved && saved.connected) {
st = saved; mail.st = saved;
jfetch('/api/mail/status?light=1').then(fresh => { if (!fresh.connected || fresh.accounts.map(a => a.id).join() !== saved.accounts.map(a => a.id).join()) { mail.st = null; mcClear(); if (location.hash.startsWith('#/mail')) route(); } else { mail.st = { ...fresh, accounts: fresh.accounts.map(a => ({ ...a, unread: (mail.st.accounts.find(x => x.id === a.id) || {}).unread ?? a.unread })) }; mcPut('st', mail.st); } }).catch(() => {});
} else {
v.innerHTML = '<div class="wrap"><h1>Mail</h1><p class="empty">Loading…</p></div>';
try { st = await jfetch('/api/mail/status?light=1'); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Mail</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (st.connected) mcPut('st', { connected: true, accounts: st.accounts, outlook: st.outlook, providers: st.providers });
}
}
if (!location.hash.startsWith('#/mail')) return;
mail.st = st; mail.accts = st.accounts || [];
if (!st.connected) { mail.st = null; return mailConnectScreen(st); }
if (!st.connected) { mail.st = null; mcClear(); return mailConnectScreen(st); }
const [kind, key] = args;
const idAcct = x => String(x || '').includes('~') ? String(x).split('~')[0] : '';
const want = idAcct(kind === 'f' ? key : kind === 'm' ? key : '') || (mail.accts.some(a => a.id === mail.acct) ? mail.acct : mail.accts[0].id);
if (want !== mail.acct) { mail.acct = want; mail.folders = []; mail.items = []; if (!(kind === 'f' && idAcct(key))) mail.folder = want + '~inbox'; }
if (kind === 'f' && key) mail.folder = idAcct(key) ? key : mail.acct + '~' + key;
else if (!String(mail.folder).startsWith(mail.acct + '~')) mail.folder = mail.acct + '~inbox';
if (!mail.folders.length) { const f = mcGet('f:' + mail.acct); if (f) mail.folders = f.folders; }
mailShell(kind === 'm' && key ? 'read' : 'list');
const acct = mail.acct;
if (!mail.items.length || kind === 'f' || kind === undefined) mailLoadList(true); else mailDrawList();
if (kind === 'm' && key) mailOpen(key); else { mail.msg = null; mailDrawReader(); }
// folders (with counts) and the real unread numbers arrive when they arrive
jfetch('/api/mail/folders?acct=' + encodeURIComponent(acct)).then(f => { if (mail.acct !== acct) return; mail.folders = f; mailRefreshFolders(); }).catch(() => {});
jfetch('/api/mail/folders?acct=' + encodeURIComponent(acct)).then(f => { if (mail.acct !== acct) return; mail.folders = f; mcPut('f:' + acct, { folders: f }); mailRefreshFolders(); }).catch(() => {});
mailPoll().then(() => { if (mail.st && location.hash.startsWith('#/mail')) mailRefreshFolders(); });
}
/** Redraw the folder pickers (names and unread counts) without touching the list or the open message. */
@@ -188,14 +212,21 @@ function mailShell(mode) {
}
async function mailLoadList(reset) {
if (reset) { mail.items = []; mail.next = 0; mail.more = false; }
const rows = $('#mrows'); if (rows && reset) rows.innerHTML = '<p class="empty">Loading…</p>';
const folder = mail.folder, first = reset && !mail.q;
if (reset) {
mail.items = []; mail.next = 0; mail.more = false;
const saved = first && mcGet('l:' + folder);
if (saved) { mail.items = saved.items; mail.more = saved.more; mail.next = saved.next; mailDrawList(); } // last time's list, right away
else { const rows = $('#mrows'); if (rows) rows.innerHTML = '<p class="empty">Loading…</p>'; }
}
const showing = reset && mail.items.length; // a saved list is on screen: the fresh one replaces it instead of being added to it
try {
const r = await jfetch(`/api/mail/messages?folder=${encodeURIComponent(mail.folder)}&skip=${mail.next}${mail.q ? '&q=' + encodeURIComponent(mail.q) : ''}`);
if (!$('#mrows')) return;
mail.items = mail.items.concat(r.items); mail.more = r.more; mail.next = r.next;
const r = await jfetch(`/api/mail/messages?folder=${encodeURIComponent(folder)}&skip=${showing ? 0 : mail.next}${mail.q ? '&q=' + encodeURIComponent(mail.q) : ''}`);
if (!$('#mrows') || mail.folder !== folder) return;
mail.items = showing ? r.items : mail.items.concat(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();
} catch (e) { 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; setTimeout(route, 1200); } }
} 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 mailDrawList() {
const rows = $('#mrows'); if (!rows) return;
+1 -1
View File
@@ -170,6 +170,6 @@ function viewSettings() {
el.querySelector('#sx').onclick = closeSheet;
} catch (e) { toast(e.message); }
};
$('#hubout').onclick = async () => { await authPost('/api/auth/logout'); location.replace('/login'); };
$('#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(); };
}