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 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 20:20:21 +02:00
co-authored by Claude Opus 5.5
parent 3957dd540a
commit 9c08cbb140
5 changed files with 56 additions and 10 deletions
+1 -1
View File
@@ -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;
+3 -1
View File
@@ -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",
+10 -4
View File
@@ -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<void> {
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<ThreadListOut>(threadsPath(INBOX, { limit: COUNT, order: "desc" }));
const threads: ThreadListOut["threads"] = [];
for (let page = 0; page < PAGES; page++) {
const list = await getJson<ThreadListOut>(threadsPath(INBOX, { offset: page * PAGE, limit: PAGE, order: "desc" }));
threads.push(...list.threads);
if (localStorage.getItem("mail.listOrder") === "oldest") {
await viaWorker(`/api${threadsPath(INBOX, { limit: COUNT, order: "asc" })}`);
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<ThreadDetail>(threadPath(summary.id, order, INBOX));
for (const m of detail.messages) await saveMessage(m);
+36 -2
View File
@@ -82,11 +82,45 @@ async function tellServer(sub: PushSubscription): Promise<void> {
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<NotificationPermission> {
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<PushState> {
export async function enablePush(onWaiting?: () => void): Promise<PushState> {
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";
+5 -1
View File
@@ -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() {
<p className="text-xs text-app-faint">{t("push.blocked")}</p>
) : state === "unsupported" ? (
<p className="text-xs text-app-faint">{t("push.unsupported")}</p>
) : waiting ? (
<p className="text-xs text-amber-600 dark:text-amber-400">{t("push.waiting")}</p>
) : error ? (
<p className="text-xs text-red-500">{error}</p>
) : null}