diff --git a/public/mail.js b/public/mail.js
index 8d5d332..700738b 100644
--- a/public/mail.js
+++ b/public/mail.js
@@ -206,6 +206,7 @@ function mailConnectScreen(st) {
}
function mailShell(mode) {
+ mail.sel = null;
const fl = mail.folders.length ? mail.folders : [{ id: 'inbox', name: 'Inbox', unread: mailUnread }];
$('#view').innerHTML = `
@@ -213,13 +214,14 @@ function mailShell(mode) {
${mail.accts.length > 1 ? mailChips() : ''}
-
-
+
+
`;
$('#mfsel').onchange = e => { mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(e.target.value); };
mailBindChips();
$('#mnew').onclick = () => mailCompose();
+ $('#msel').onclick = () => mailSelMode(!mail.sel);
$('#mrf').onclick = () => { mailLoadList(true); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; mailPoll(); }).catch(() => {}); };
let t; $('#mq').oninput = e => { clearTimeout(t); t = setTimeout(() => { mail.q = e.target.value.trim(); mailLoadList(true); }, 400); };
}
@@ -243,13 +245,144 @@ async function mailLoadList(reset) {
}
function mailMarkSel(id) { document.querySelectorAll('a.mrow').forEach(a => a.classList.toggle('sel', !!id && decodeURIComponent(a.getAttribute('href').slice(9)) === id)); }
function mailDrawList() {
- const rows = $('#mrows'); if (!rows) return;
+ const rows = $('#mrows'); if (!rows) return; mailSwipeInit(rows);
const sel = (location.hash.match(/^#\/mail\/m\/([^/]+)/) || [])[1];
- rows.innerHTML = mail.items.length ? mail.items.map(m => `
+ rows.innerHTML = mail.items.length ? mail.items.map(m => `${mail.sel ? `` : ''}
${esc(mailFrom(m.from))}${esc(m.subject)}${esc(m.preview)}
- ${esc(mailWhen(m.date))}${m.flagged ? icon('check') : ''}${m.attach ? icon('clip') : ''}`).join('') : `${mail.q ? 'Nothing matches.' : 'No messages here.'}
`;
+ ${esc(mailWhen(m.date))}${m.flagged ? `${icon('bookmark')}` : ''}${m.attach ? icon('clip') : ''}`).join('') : `${mail.q ? 'Nothing matches.' : 'No messages here.'}
`;
const more = $('#mmore'); more.innerHTML = mail.more ? '' : '';
if ($('#mmb')) $('#mmb').onclick = () => mailLoadList(false);
+ mailDrawSelBar();
+}
+
+// ---- Swipe a message in the list: left deletes it, right archives it (touch screens). The row follows your finger and the colour under it shows what will happen;
+// let go past a third of the way (or flick it) and it leaves at once and finishes in the background, otherwise it springs back.
+function mailSwipeInit(rows) {
+ if (rows.dataset.swipe) return; rows.dataset.swipe = '1';
+ let row = null, x0 = 0, y0 = 0, dx = 0, t0 = 0, mode = '', w = 0;
+ const reset = (anim) => { if (!row) return; const r = row; r.style.transition = anim ? 'transform .18s ease-out' : ''; r.style.transform = ''; setTimeout(() => { r.classList.remove('swl', 'swr'); r.style.transition = ''; r.style.removeProperty('--sww'); }, anim ? 200 : 0); };
+ rows.addEventListener('touchstart', e => {
+ const a = e.target.closest && e.target.closest('a.mrow'); if (!a || e.touches.length !== 1 || mail.sel) { row = null; return; }
+ row = a; x0 = e.touches[0].clientX; y0 = e.touches[0].clientY; dx = 0; mode = ''; t0 = Date.now(); w = a.offsetWidth;
+ }, { passive: true });
+ rows.addEventListener('touchmove', e => {
+ if (!row) return; const t = e.touches[0], mx = t.clientX - x0, my = t.clientY - y0;
+ if (!mode) { if (Math.abs(my) > 12 && Math.abs(my) > Math.abs(mx)) { row = null; return; } if (Math.abs(mx) > 12 && Math.abs(mx) > Math.abs(my) * 1.4) { mode = 'swipe'; row.style.setProperty('--sww', w + 'px'); } else return; }
+ dx = mx; row.classList.toggle('swl', dx < 0); row.classList.toggle('swr', dx > 0); row.style.transition = ''; row.style.transform = `translateX(${dx}px)`;
+ row.dataset.sw = dx < 0 ? 'Delete' : 'Archive'; row.classList.toggle('swgo', Math.abs(dx) > w / 3);
+ }, { passive: true });
+ const end = () => {
+ if (!row) return; const r = row; row = null;
+ if (mode !== 'swipe') return;
+ const fast = Math.abs(dx) > 60 && Date.now() - t0 < 250; r.dataset.swiped = '1'; setTimeout(() => delete r.dataset.swiped, 400);
+ if (Math.abs(dx) > w / 3 || fast) {
+ const id = decodeURIComponent(r.getAttribute('href').slice(9)), kind = dx < 0 ? 'delete' : 'archive';
+ if (kind === 'delete' && /(^|~)deleteditems$/.test(mail.folder)) { row = r; reset(true); row = null; const it = mail.items.find(x => x.id === id); return confirmSheet('Delete for good?', it ? it.subject : '', 'Delete', () => mailRowAway(id, kind)); }
+ r.style.transition = 'transform .16s ease-in, opacity .16s'; r.style.transform = `translateX(${dx < 0 ? -w : w}px)`; r.style.opacity = '0.2';
+ setTimeout(() => mailRowAway(id, kind), 160);
+ } else { row = r; reset(true); row = null; }
+ };
+ rows.addEventListener('touchend', end); rows.addEventListener('touchcancel', () => { if (row) { reset(true); row = null; } });
+ rows.addEventListener('click', e => { const a = e.target.closest && e.target.closest('a.mrow'); if (a && a.dataset.swiped) { e.preventDefault(); e.stopPropagation(); } }, true); // the finger lifting after a swipe is not a tap
+ // Selecting: while it is on, a tap picks or unpicks a message; a long press on a phone (or Ctrl/Cmd-click, Shift-click for a range, on a computer) starts it.
+ let lp = 0, lpRow = null, lx = 0, ly = 0, lpFired = false;
+ rows.addEventListener('touchstart', e => { const a = e.target.closest && e.target.closest('a.mrow'); if (!a || e.touches.length !== 1) return; lpRow = a; lx = e.touches[0].clientX; ly = e.touches[0].clientY; clearTimeout(lp);
+ lpFired = false; lp = setTimeout(() => { if (!lpRow) return; const r = lpRow; lpRow = null; lpFired = true; if (navigator.vibrate) navigator.vibrate(15); mailSelMode(true, decodeURIComponent(r.getAttribute('href').slice(9))); }, 520); }, { passive: true });
+ rows.addEventListener('touchmove', e => { if (lpRow && (Math.abs(e.touches[0].clientX - lx) > 8 || Math.abs(e.touches[0].clientY - ly) > 8)) { clearTimeout(lp); lpRow = null; } }, { passive: true });
+ ['touchend', 'touchcancel'].forEach(t => rows.addEventListener(t, e => { clearTimeout(lp); lpRow = null; if (lpFired) { lpFired = false; mail.quiet = Date.now() + 250; if (e.cancelable) e.preventDefault(); } })); // lifting the finger after a long press must not count as a tap on anything
+ rows.addEventListener('contextmenu', e => { if (e.target.closest && e.target.closest('a.mrow') && 'ontouchstart' in window) e.preventDefault(); });
+ rows.addEventListener('click', e => {
+ const a = e.target.closest && e.target.closest('a.mrow'); if (!a || a.dataset.swiped) return; if (Date.now() < (mail.quiet || 0)) { e.preventDefault(); e.stopPropagation(); return; } const id = decodeURIComponent(a.getAttribute('href').slice(9));
+ if (!mail.sel && !(e.ctrlKey || e.metaKey || e.shiftKey)) return;
+ e.preventDefault(); e.stopPropagation();
+ if (!mail.sel) mailSelMode(true, id);
+ else if (e.shiftKey && mail.selLast) { const ids = mail.items.map(x => x.id), i = ids.indexOf(mail.selLast), j = ids.indexOf(id); if (i >= 0 && j >= 0) ids.slice(Math.min(i, j), Math.max(i, j) + 1).forEach(x => mail.sel.add(x)); mailSelPaint(); }
+ else mailSelToggle(id);
+ }, true);
+}
+function mailSelMode(on, first) {
+ mail.sel = on ? new Set(first ? [first] : []) : null; mail.selLast = first || null;
+ if (on && /^#\/mail\/m\//.test(location.hash) && matchMedia('(max-width: 899px)').matches) location.hash = '#/mail/f/' + encodeURIComponent(mail.folder);
+ mailSelPaint();
+}
+/** Show who is picked without rebuilding the list (the row under a finger must stay in the page, or the finger's release never reaches it). */
+function mailSelPaint() {
+ document.querySelectorAll('#mrows a.mrow').forEach(a => {
+ const id = decodeURIComponent(a.getAttribute('href').slice(9)), on = !!mail.sel && mail.sel.has(id); let c = a.querySelector(':scope > .mchk');
+ if (mail.sel && !c) { c = document.createElement('span'); c.className = 'mchk'; a.insertBefore(c, a.firstChild); } else if (!mail.sel && c) c.remove();
+ if (c) c.classList.toggle('on', on); a.classList.toggle('picked', on); if (mail.sel) a.classList.remove('sel');
+ });
+ mailDrawSelBar();
+}
+function mailSelToggle(id) { if (!mail.sel) return; if (mail.sel.has(id)) mail.sel.delete(id); else mail.sel.add(id); mail.selLast = id; mailSelPaint(); }
+function mailDrawSelBar() {
+ const bar = $('#mselbar'); if (!bar) return;
+ document.body.classList.toggle('mselecting', !!mail.sel);
+ if (!mail.sel) { bar.hidden = true; bar.innerHTML = ''; return; }
+ const picked = mail.items.filter(m => mail.sel.has(m.id)), n = picked.length, anyUnread = picked.some(m => !m.read), anyUnflag = picked.some(m => !m.flagged), all = n && n === mail.items.length;
+ const b = (a, label, ic, cls = '') => ``;
+ bar.hidden = false;
+ bar.innerHTML = `${n ? n + ' selected' : 'Select messages'}${b('all', all ? 'Select none' : 'Select all', '')}${b('done', 'Done', '', 'btn')}
+ ${b(anyUnread ? 'read' : 'unread', anyUnread ? 'Mark read' : 'Mark unread', 'mail')}${b(anyUnflag ? 'flag' : 'unflag', anyUnflag ? 'Flag' : 'Unflag', 'check')}${b('archive', 'Archive', 'files')}${b('move', 'Move', 'newdir')}${b('delete', 'Delete', 'trash', 'danger')}
`;
+ bar.querySelectorAll('[data-s]').forEach(x => x.onclick = () => mailSelAct(x.dataset.s));
+}
+function mailSelAct(a) {
+ if (Date.now() < (mail.quiet || 0)) return; // the finger-lift that ended a long press
+ if (a === 'done') return mailSelMode(false);
+ if (a === 'all') { if (mail.sel.size === mail.items.length) mail.sel.clear(); else mail.items.forEach(m => mail.sel.add(m.id)); return mailSelPaint(); }
+ const ids = [...mail.sel].filter(id => mail.items.some(m => m.id === id)); if (!ids.length) return;
+ if (a === 'move') {
+ const fl = mail.folders.filter(f => f.id !== mail.folder);
+ const el = openSheet('Move to', ids.length + (ids.length > 1 ? ' messages' : ' message'), `${fl.map(f => `
`).join('') || '
No other folders.
'}
`);
+ return el.querySelectorAll('[data-f]').forEach(r => r.onclick = () => { closeSheet(); mailBatch(ids, 'move', r.dataset.f); });
+ }
+ if (a === 'delete' && /(^|~)deleteditems$/.test(mail.folder)) return confirmSheet('Delete for good?', ids.length + (ids.length > 1 ? ' messages' : ' message') + ' will be deleted for good.', 'Delete', () => mailBatch(ids, 'delete', true));
+ mailBatch(ids, a);
+}
+/** Change one message in the open list and in the saved copy of it. */
+function mailPatchItem(id, patch) {
+ const it = mail.items.find(x => x.id === id); if (it) Object.assign(it, patch);
+ const c = mcGet('l:' + mail.folder); if (c && c.items) { const x = c.items.find(y => y.id === id); if (x) { Object.assign(x, patch); mcPut('l:' + mail.folder, { items: c.items, more: c.more, next: c.next }); } }
+}
+/** Do the same thing to several messages. The list changes at once; each message is then sent to the server (a few at a time), and any the server refuses go back as they were. */
+async function mailBatch(ids, a, extra) {
+ const items = ids.map(id => mail.items.find(x => x.id === id)).filter(Boolean), before = new Map(items.map(m => [m.id, { ...m }])), at = new Map(items.map(m => [m.id, mail.items.indexOf(m)]));
+ const removes = ['archive', 'move', 'delete'].includes(a), inbox = mailInInbox(); let dUnread = 0;
+ mail.sel = null; mail.selLast = null;
+ if (removes) {
+ const gone = new Set(ids); mail.items = mail.items.filter(m => !gone.has(m.id)); dUnread = -items.filter(m => !m.read).length;
+ const sv = mcGet('l:' + mail.folder); if (sv && sv.items) mcPut('l:' + mail.folder, { items: sv.items.filter(x => !gone.has(x.id)), more: sv.more, next: sv.next });
+ if (mail.msg && gone.has(mail.msg.id)) { mail.msg = null; location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); }
+ } else if (a === 'read' || a === 'unread') { const read = a === 'read'; dUnread = items.filter(m => m.read !== read).length * (read ? -1 : 1); items.forEach(m => { mailSetRead(m.id, read); }); }
+ else if (a === 'flag' || a === 'unflag') items.forEach(m => mailPatchItem(m.id, { flagged: a === 'flag' }));
+ if (inbox && dUnread) { mailUnread = Math.max(0, mailUnread + dUnread); renderNav(); }
+ mailDrawList();
+ const call = m => a === 'move' ? jfetch('/api/mail/move', { method: 'POST', body: JSON.stringify({ id: m.id, to: extra }) })
+ : a === 'archive' ? jfetch('/api/mail/move', { method: 'POST', body: JSON.stringify({ id: m.id, to: 'archive' }) })
+ : a === 'delete' ? jfetch('/api/mail/delete', { method: 'POST', body: JSON.stringify({ id: m.id, ...(extra ? { permanent: true } : {}) }) })
+ : jfetch('/api/mail/update', { method: 'POST', body: JSON.stringify(a === 'read' || a === 'unread' ? { id: m.id, read: a === 'read' } : { id: m.id, flagged: a === 'flag' }) });
+ const failed = []; let next = 0, lastErr = '';
+ await Promise.all([0, 1, 2, 3].map(async () => { while (next < items.length) { const m = items[next++]; try { await call(m); } catch (e) { failed.push(m); lastErr = e.message; } } }));
+ const word = { read: 'Marked read', unread: 'Marked unread', flag: 'Flagged', unflag: 'Unflagged', archive: 'Archived', move: 'Moved', delete: extra ? 'Deleted' : 'Moved to Deleted' }[a], ok = items.length - failed.length;
+ if (failed.length) {
+ for (const m of failed) { const o = before.get(m.id); if (removes) mail.items.splice(Math.min(at.get(m.id), mail.items.length), 0, o); else { if (a === 'read' || a === 'unread') mailSetRead(m.id, o.read); else mailPatchItem(m.id, { flagged: o.flagged }); } }
+ if (inbox) { jfetch('/api/mail/status').catch(() => {}); } mailDrawList();
+ toast(`${ok ? word + ' ' + ok + ', ' : ''}${failed.length} failed: ${lastErr}`);
+ } else toast(`${word}${items.length > 1 ? ' (' + items.length + ')' : ''}`);
+ mailPoll(); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; mailRefreshFolders(); }).catch(() => {});
+}
+function mailRowAway(id, kind) {
+ const at = mail.items.findIndex(x => x.id === id), had = mail.items[at]; if (!had) return mailDrawList();
+ const permanent = kind === 'delete' && /(^|~)deleteditems$/.test(mail.folder);
+ mail.items.splice(at, 1);
+ const sv = mcGet('l:' + mail.folder); if (sv && sv.items) mcPut('l:' + mail.folder, { items: sv.items.filter(x => x.id !== id), more: sv.more, next: sv.next });
+ if (mail.msg && mail.msg.id === id) { mail.msg = null; location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); }
+ mailDrawList();
+ if (!had.read && mailInInbox() && mailUnread > 0) { mailUnread--; renderNav(); }
+ toast(kind === 'archive' ? 'Archived' : permanent ? 'Deleted' : 'Moved to Deleted');
+ const refresh = () => { mailPoll(); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; mailRefreshFolders(); }).catch(() => {}); };
+ jfetch('/api/mail/' + (kind === 'archive' ? 'move' : 'delete'), { method: 'POST', body: JSON.stringify(kind === 'archive' ? { id, to: 'archive' } : { id, ...(permanent ? { permanent: true } : {}) }) })
+ .then(refresh, e => { toast(e.message); if (!had.read && mailInInbox()) { mailUnread++; renderNav(); } mail.items.splice(Math.min(at, mail.items.length), 0, had); mailDrawList(); refresh(); });
}
// 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
diff --git a/public/reader.js b/public/reader.js
index b775c08..d86f150 100644
--- a/public/reader.js
+++ b/public/reader.js
@@ -356,13 +356,15 @@ class RdEpub {
doc.addEventListener('keydown', e => R.keys(e));
doc.addEventListener('touchstart', e => { x0 = e.changedTouches[0].screenX; y0 = e.changedTouches[0].screenY; t0 = Date.now(); }, { passive: true });
doc.addEventListener('touchend', e => { const dx = e.changedTouches[0].screenX - x0, dy = e.changedTouches[0].screenY - y0; if (this.R.P.flow === 'paged' && Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && Date.now() - t0 < 700) dx < 0 ? this.next() : this.prev(); });
- doc.addEventListener('click', e => { if (e.target.closest && e.target.closest('a[href]')) return; const sel = contents.window.getSelection(); if (sel && !sel.isCollapsed) return; const w = contents.window.innerWidth; if (this.R.P.flow !== 'paged') return R.toggleBars(); if (e.clientX < w * 0.22) this.prev(); else if (e.clientX > w * 0.78) this.next(); else R.toggleBars(); });
+ doc.addEventListener('click', e => { if (e.target.closest && e.target.closest('a[href]')) return; const sel = contents.window.getSelection(); if (sel && !sel.isCollapsed) return; if (this.R.P.flow !== 'paged') return R.toggleBars(); const x = this.tapX(contents, e.clientX); if (x < 0.22) this.prev(); else if (x > 0.78) this.next(); else R.toggleBars(); });
doc.addEventListener('mousedown', () => R.hideSel());
});
const start = loc && !/^\d+$/.test(loc) ? loc : undefined;
try { await this.rend.display(start); } catch { await this.rend.display(); }
for (const n of R.notes) this.drawNote(n);
}
+ /** Where a tap landed as a fraction of the reading area. The page frame is wider than the screen (the pages sit side by side inside it), so its own coordinates say nothing about the screen. */
+ tapX(contents, clientX) { const f = contents.window.frameElement.getBoundingClientRect(), s = this.stage.getBoundingClientRect(); return (f.left + clientX - s.left) / s.width; }
restyle(k) {
const R = this.R, P = R.P, t = rdTheme(P);
if (k === 'flow') { const loc = this.at && this.at.start.cfi; return this.build(loc); }
diff --git a/public/style.css b/public/style.css
index 69f999a..4a647de 100644
--- a/public/style.css
+++ b/public/style.css
@@ -874,3 +874,22 @@ kbd { display: inline-block; padding: 1px 7px; border: 1px solid var(--line); bo
.rdseg { display: flex; gap: 6px; flex-wrap: wrap; } .rdseg button { flex: 1; min-height: 40px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel2); color: var(--text); padding: 0 10px; white-space: nowrap; }
.rdseg button.on { background: var(--accent); color: var(--accent-t); border-color: transparent; font-weight: 600; }
.rdstep { display: flex; align-items: center; gap: 12px; } .rdstep span { min-width: 56px; text-align: center; font-weight: 600; } .rdstep .btn { min-width: 64px; justify-content: center; }
+
+/* Mail: swipe a message left to delete, right to archive */
+.mrow { position: relative; touch-action: pan-y; will-change: transform; background: var(--panel); }
+.mrow.swl { box-shadow: var(--sww, 100vw) 0 0 var(--danger); } .mrow.swr { box-shadow: calc(-1 * var(--sww, 100vw)) 0 0 #2f9e6e; }
+.mrow.swl.swgo { box-shadow: var(--sww, 100vw) 0 0 #b3261e; }
+.mrow.swl::after, .mrow.swr::after { content: attr(data-sw); position: absolute; top: 0; bottom: 0; display: flex; align-items: center; color: #fff; font-weight: 600; padding: 0 20px; white-space: nowrap; }
+.mrow.swl::after { left: 100%; } .mrow.swr::after { right: 100%; }
+
+/* Mail: choosing several messages */
+.mchk { width: 22px; height: 22px; border-radius: 50%; border: 2px solid var(--muted); flex: none; margin-top: 1px; position: relative; }
+.mchk.on { background: var(--accent); border-color: var(--accent); }
+.mchk.on::after { content: ''; position: absolute; left: 6px; top: 2px; width: 5px; height: 10px; border: solid var(--accent-t); border-width: 0 2px 2px 0; transform: rotate(45deg); }
+.mrow.picked { background: color-mix(in srgb, var(--accent) 12%, var(--panel)); }
+.mselbar { position: sticky; bottom: 8px; z-index: 4; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 8px 10px; margin: 10px 0 0; box-shadow: 0 4px 16px rgba(0,0,0,.12); }
+.msbtop { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } .msbtop .btn { min-height: 36px; }
+.msbrow { display: flex; gap: 6px; flex-wrap: wrap; } .msb { min-height: 40px; flex: 1 1 auto; justify-content: center; padding: 0 10px; font-size: 14px; } .msb svg { width: 18px; height: 18px; margin-right: 4px; }
+.msb:disabled { opacity: .45; }
+body.mselecting .mrow { user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }
+.mflag { color: var(--danger); } .mflag svg { fill: currentColor; }
diff --git a/public/sw.js b/public/sw.js
index 4eff674..fc5f810 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-v34';
+const CACHE = 'hub-v36';
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'];