From 9c08cbb140f8df1985ec310fe197d8fb2d9d745f Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Thu, 8 Oct 2026 20:20:21 +0200 Subject: [PATCH] Offline: newest 100 conversations; notifications no longer wait silently Offline access keeps the newest 100 Inbox conversations (the list's first two pages, saved as the app asks for them) plus recently opened ones, up to 130. Turning notifications on in a browser that holds the permission request without showing it (Opera, Chrome's quiet prompts) said nothing and never finished. After a few seconds it now says how to allow notifications for the site, and carries on by itself as soon as they are allowed. Co-Authored-By: Claude Opus 5.5 --- frontend/public/sw.js | 2 +- frontend/src/i18n/en.ts | 4 ++- frontend/src/lib/offlinePrefetch.ts | 16 +++++++--- frontend/src/lib/push.ts | 38 +++++++++++++++++++++-- frontend/src/routes/settings/Settings.tsx | 6 +++- 5 files changed, 56 insertions(+), 10 deletions(-) diff --git a/frontend/public/sw.js b/frontend/public/sw.js index 1def2dc..fb1f3dd 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -33,7 +33,7 @@ const DATA = "wm-data-v1"; const INDEX_KEY = "/__wm/offline-index"; const APP_KEY = "/__wm/app"; -const MAX_THREADS = 80; // the newest 50 Inbox conversations + recently opened ones +const MAX_THREADS = 130; // the newest 100 Inbox conversations + recently opened ones const MAX_ORPHANS = 50; // message parts whose conversation was never saved const MAX_LISTS = 40; // thread lists (one per folder / filter / page) const MAX_AVATARS = 300; diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 3ba56ed..e8e6674 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -591,7 +591,7 @@ export const en = { "offline.cantReach": "Can't reach the server.", "offline.settingTitle": "Offline access", "offline.settingHelp": - "Keep your newest 50 Inbox conversations, and the ones you open, on this device, so you can read them without a connection. They are stored unencrypted in this browser and removed when you sign out — leave this off on shared computers.", + "Keep your newest 100 Inbox conversations, and the ones you open, on this device, so you can read them without a connection. They are stored unencrypted in this browser and removed when you sign out — leave this off on shared computers.", "offline.settingStatus": "{count} conversations saved · {size} · updated {when}", "offline.settingStatusNoTime": "{count} conversations saved · {size}", "offline.settingSaving": "Saving recent mail…", @@ -606,6 +606,8 @@ export const en = { "Notifications are blocked for this site in your browser. Allow them in the browser's site settings, then turn this on.", "push.unsupported": "Not available here: this browser can't show notifications from a website, or the app isn't opened over HTTPS.", "push.notAllowed": "Notifications weren't allowed.", + "push.waiting": + "Waiting for your browser's permission. If it didn't ask, click the icon at the left of the address bar and set Notifications to Allow — they turn on by themselves then.", "push.failed": "Couldn't turn notifications on.", "install.entry": "Install app", "install.title": "Install Ultimo", diff --git a/frontend/src/lib/offlinePrefetch.ts b/frontend/src/lib/offlinePrefetch.ts index 69fe7cf..9cd1e66 100644 --- a/frontend/src/lib/offlinePrefetch.ts +++ b/frontend/src/lib/offlinePrefetch.ts @@ -15,7 +15,8 @@ import type { UiManifest } from "../plugins/types"; import type { MessageEnvelope, ThreadDetail, ThreadListOut, UserSettings } from "./types"; const INBOX = "folder:inbox"; -const COUNT = 50; // the list's page size, so the first page is saved as shown +const PAGE = 50; // the list's page size, so its pages are saved as the app asks for them +const PAGES = 2; // the newest 100 conversations const PARALLEL = 3; const TIMEOUT_MS = 30_000; // The message protocol this page speaks with public/sw.js (its VERSION). @@ -148,12 +149,17 @@ async function run(): Promise { const order = settings.thread_order ?? "asc"; // The list as the app shows it (newest first is the default), plus the other // order when that is what this device uses, so the list itself opens offline. - const list = await getJson(threadsPath(INBOX, { limit: COUNT, order: "desc" })); - if (localStorage.getItem("mail.listOrder") === "oldest") { - await viaWorker(`/api${threadsPath(INBOX, { limit: COUNT, order: "asc" })}`); + const threads: ThreadListOut["threads"] = []; + for (let page = 0; page < PAGES; page++) { + const list = await getJson(threadsPath(INBOX, { offset: page * PAGE, limit: PAGE, order: "desc" })); + threads.push(...list.threads); + if (localStorage.getItem("mail.listOrder") === "oldest") { + await viaWorker(`/api${threadsPath(INBOX, { offset: page * PAGE, limit: PAGE, order: "asc" })}`); + } + if (list.threads.length < PAGE) break; // no further page } - await inPool(list.threads, PARALLEL, async (summary) => { + await inPool(threads, PARALLEL, async (summary) => { for (const who of summary.participants) await saveAvatar(who.email); const detail = await getJson(threadPath(summary.id, order, INBOX)); for (const m of detail.messages) await saveMessage(m); diff --git a/frontend/src/lib/push.ts b/frontend/src/lib/push.ts index 3a5f0d3..3d838f9 100644 --- a/frontend/src/lib/push.ts +++ b/frontend/src/lib/push.ts @@ -82,11 +82,45 @@ async function tellServer(sub: PushSubscription): Promise { await apiFetch("/push/subscriptions", { method: "POST", body: JSON.stringify({ endpoint, keys }) }); } +/** + * Ask for permission to notify. Some browsers (Opera, Chrome's "quiet" + * prompts) hold the request without showing anything and never answer it, so + * also watch the site's permission: allowing notifications in the browser's + * site settings carries on from there. `onWaiting` runs when no answer has come + * after a few seconds, to say what to do. + */ +function askPermission(onWaiting?: () => void): Promise { + if (Notification.permission !== "default") return Promise.resolve(Notification.permission); + return new Promise((resolve) => { + let watched: PermissionStatus | null = null; + let done = false; + const finish = (permission: NotificationPermission) => { + if (done) return; + done = true; + window.clearTimeout(timer); + if (watched) watched.onchange = null; + resolve(permission); + }; + const timer = window.setTimeout(() => onWaiting?.(), 3000); + Notification.requestPermission().then(finish, () => finish(Notification.permission)); + navigator.permissions + ?.query({ name: "notifications" }) + .then((status) => { + watched = status; + status.onchange = () => { + if (status.state === "granted") finish("granted"); + else if (status.state === "denied") finish("denied"); + }; + }) + .catch(() => {}); + }); +} + /** Turn notifications on (must run from a click: browsers ask permission only then). */ -export async function enablePush(): Promise { +export async function enablePush(onWaiting?: () => void): Promise { try { if (!supported()) return pushState(); - const permission = await Notification.requestPermission(); + const permission = await askPermission(onWaiting); if (permission !== "granted") return pushState(); const reg = await registration(); if (!reg) return "unsupported"; diff --git a/frontend/src/routes/settings/Settings.tsx b/frontend/src/routes/settings/Settings.tsx index 2560675..9089d25 100644 --- a/frontend/src/routes/settings/Settings.tsx +++ b/frontend/src/routes/settings/Settings.tsx @@ -377,6 +377,7 @@ function NotificationsSetting() { const state = usePushState(); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + const [waiting, setWaiting] = useState(false); const usable = state === "on" || state === "off"; const toggle = async (on: boolean) => { @@ -384,7 +385,7 @@ function NotificationsSetting() { setError(""); try { if (on) { - const result = await enablePush(); + const result = await enablePush(() => setWaiting(true)); if (result === "off") setError(t("push.notAllowed")); } else { await disablePush(); @@ -393,6 +394,7 @@ function NotificationsSetting() { setError(e instanceof ApiError ? e.message : t("push.failed")); } finally { setBusy(false); + setWaiting(false); } }; @@ -424,6 +426,8 @@ function NotificationsSetting() {

{t("push.blocked")}

) : state === "unsupported" ? (

{t("push.unsupported")}

+ ) : waiting ? ( +

{t("push.waiting")}

) : error ? (

{error}

) : null}