From 8acf9418dc5337dd4b41d7a70fa23e05ee61f241 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 20:15:53 +0000 Subject: [PATCH 1/2] 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']; From b6944b4ee10a70f5b8e6f2b2a8e269cfd7e4074a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 20:22:08 +0000 Subject: [PATCH 2/2] Mail: select several messages (long press, Ctrl/Cmd or Shift click, or the select button) to mark read or unread, flag, archive, move or delete Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj --- public/mail.js | 99 +++++++++++++++++++++++++++++++++++++++++++++--- public/style.css | 12 ++++++ public/sw.js | 2 +- 3 files changed, 107 insertions(+), 6 deletions(-) diff --git a/public/mail.js b/public/mail.js index 14841e3..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); }; } @@ -245,11 +247,12 @@ function mailMarkSel(id) { document.querySelectorAll('a.mrow').forEach(a => a.cl function mailDrawList() { 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; @@ -259,7 +262,7 @@ function mailSwipeInit(rows) { 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; } + 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 => { @@ -281,6 +284,92 @@ function mailSwipeInit(rows) { }; 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(); diff --git a/public/style.css b/public/style.css index 6e3ffaa..4a647de 100644 --- a/public/style.css +++ b/public/style.css @@ -881,3 +881,15 @@ kbd { display: inline-block; padding: 1px 7px; border: 1px solid var(--line); bo .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 6f1274a..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-v35'; +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'];