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}