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'];