From 8acf9418dc5337dd4b41d7a70fa23e05ee61f241 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 20:15:53 +0000 Subject: [PATCH] Mail: swipe a message left to delete, right to archive. Books: page flip by tap and swipe works on touch screens The page frame is wider than the screen (pages sit side by side inside it), so tap positions were measured against the wrong width and taps on touch devices never turned the page. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj --- public/mail.js | 46 +++++++++++++++++++++++++++++++++++++++++++++- public/reader.js | 4 +++- public/style.css | 7 +++++++ public/sw.js | 2 +- 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/public/mail.js b/public/mail.js index 8d5d332..14841e3 100644 --- a/public/mail.js +++ b/public/mail.js @@ -243,7 +243,7 @@ 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 => ` ${esc(mailFrom(m.from))}${esc(m.subject)}${esc(m.preview)} @@ -252,6 +252,50 @@ function mailDrawList() { if ($('#mmb')) $('#mmb').onclick = () => mailLoadList(false); } +// ---- 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) { 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 +} +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 // 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); } })); 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..6e3ffaa 100644 --- a/public/style.css +++ b/public/style.css @@ -874,3 +874,10 @@ 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%; } diff --git a/public/sw.js b/public/sw.js index 4eff674..6f1274a 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-v35'; 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'];