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:
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" }));
|
||||
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<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, { 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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user