Installable app, push notifications about new mail, Install app entry
The webmail is now a PWA: web app manifest, icons (any + maskable, a status-bar badge), theme-coloured title bar following the chosen theme, and an app-icon badge with the unread Inbox count. The service worker is always installed; offline access is on only when it is registered as /sw.js?offline=1, so turning it on or off is an update that applies at once. Notifications (Settings → Accounts → Notifications, per device) use Web Push. Encryption (RFC 8291) and VAPID signing (RFC 8292) are implemented in app/push/webpush.py — pywebpush needs a newer cryptography than we pin — and checked against the RFC's worked example. The server makes its key pair on first use (private half encrypted with MASTER_KEY). New mail found by the live sync in an Inbox enqueues a job 5 s later that tells the user's devices about messages still unread there, under 2 hours old and not from the user; more than three become one "N new messages". A subscription belongs to the session it was made under: signed out or revoked, nothing more is sent there. Endpoints are limited to the known push services (no SSRF). Nothing shows while the app is in front (except on Apple, which requires it). Tested with real pushes through Google's service in Chromium and Opera. The mobile drawer gets "Install app": Chrome's own install dialog where the browser offers one, else short steps for iPhone/iPad, Opera and Firefox, with "Don't show this again". docs/nas-install.md §5.3 covers installing, notifications, Public URL and a later Android app (TWA). Migration 0022: push_subscriptions, app_settings.vapid_*. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,13 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/ultimo-logo-signet.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#f3f4f6" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#1b1e20" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Ultimo" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title>Ultimo Email</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "Ultimo Email",
|
||||
"short_name": "Ultimo",
|
||||
"description": "Your email, calendar and contacts.",
|
||||
"start_url": "/mail",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#f3f4f6",
|
||||
"categories": ["productivity", "business"],
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
|
||||
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
],
|
||||
"shortcuts": [
|
||||
{ "name": "Inbox", "url": "/mail", "icons": [{ "src": "/icon-192.png", "sizes": "192x192" }] },
|
||||
{ "name": "Calendar", "url": "/p/calendar", "icons": [{ "src": "/icon-192.png", "sizes": "192x192" }] }
|
||||
]
|
||||
}
|
||||
+103
-16
@@ -1,12 +1,18 @@
|
||||
// Offline access (Settings → Accounts → "Offline access").
|
||||
// The app's service worker: notifications about new mail (always), and offline
|
||||
// access (Settings → Accounts → "Offline access") on devices where it is on.
|
||||
//
|
||||
// Registered only on devices where the user turned it on. While the server
|
||||
// answers, everything comes from the network exactly as before; each answer is
|
||||
// also saved here. Only when the server cannot be reached is the saved copy
|
||||
// served instead — marked with `X-WM-Offline: 1`, which tells the page to show
|
||||
// that it is offline. Saved mail is the newest Inbox conversations (fetched by
|
||||
// the page, see lib/offlinePrefetch.ts) plus the ones opened recently, at most
|
||||
// MAX_THREADS; older ones are dropped.
|
||||
// Offline access is on when this worker was registered as /sw.js?offline=1
|
||||
// (lib/offline.ts); registered as plain /sw.js it leaves every request alone.
|
||||
// Turning the setting on or off re-registers under the other URL, which the
|
||||
// browser treats as an update, so the change takes effect at once.
|
||||
//
|
||||
// With offline access on: while the server answers, everything comes from the
|
||||
// network exactly as before; each answer is also saved here. Only when the
|
||||
// server cannot be reached is the saved copy served instead — marked with
|
||||
// `X-WM-Offline: 1`, which tells the page to show that it is offline. Saved
|
||||
// mail is the newest Inbox conversations (fetched by the page, see
|
||||
// lib/offlinePrefetch.ts) plus the ones opened recently, at most MAX_THREADS;
|
||||
// older ones are dropped.
|
||||
//
|
||||
// Saved mail is wiped when the server answers a request with 401 (signed out,
|
||||
// session expired or revoked), when another user signs in, and on request from
|
||||
@@ -18,7 +24,9 @@
|
||||
// What the page expects this worker to understand (lib/offlinePrefetch.ts
|
||||
// WORKER_VERSION): raise both together when the messages below change, so a
|
||||
// page never talks to an older worker that would not answer.
|
||||
const VERSION = 3;
|
||||
const VERSION = 4;
|
||||
|
||||
const OFFLINE = new URL(self.location.href).searchParams.get("offline") === "1";
|
||||
|
||||
const SHELL = "wm-shell-v1";
|
||||
const DATA = "wm-data-v1";
|
||||
@@ -413,6 +421,7 @@ function isAppPage(url) {
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (!OFFLINE) return; // the browser does what it would without a worker
|
||||
const req = event.request;
|
||||
if (req.method !== "GET") return;
|
||||
const url = new URL(req.url);
|
||||
@@ -454,7 +463,9 @@ async function fetchForPage(url, port) {
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data?.type === "purge") event.waitUntil(purge());
|
||||
if (event.data?.type === "version" && event.ports[0]) event.ports[0].postMessage({ version: VERSION });
|
||||
if (event.data?.type === "version" && event.ports[0]) {
|
||||
event.ports[0].postMessage({ version: VERSION, offline: OFFLINE });
|
||||
}
|
||||
if (event.data?.type === "fetch" && event.ports[0]) {
|
||||
event.waitUntil(fetchForPage(event.data.url, event.ports[0]));
|
||||
}
|
||||
@@ -465,11 +476,13 @@ self.addEventListener("install", (event) => {
|
||||
// turned this on is never reloaded.
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/", { cache: "no-store" });
|
||||
if (res.ok) await saveApp(res);
|
||||
} catch {
|
||||
/* saved on the next visit */
|
||||
if (OFFLINE) {
|
||||
try {
|
||||
const res = await fetch("/", { cache: "no-store" });
|
||||
if (res.ok) await saveApp(res);
|
||||
} catch {
|
||||
/* saved on the next visit */
|
||||
}
|
||||
}
|
||||
await self.skipWaiting();
|
||||
})(),
|
||||
@@ -479,10 +492,84 @@ self.addEventListener("install", (event) => {
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
// Offline access off: nothing may stay saved on this device.
|
||||
for (const name of await caches.keys()) {
|
||||
if (name.startsWith("wm-") && name !== SHELL && name !== DATA) await caches.delete(name);
|
||||
if (name.startsWith("wm-") && (!OFFLINE || (name !== SHELL && name !== DATA))) await caches.delete(name);
|
||||
}
|
||||
await self.clients.claim();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
// ---- notifications about new mail ---------------------------------------------------
|
||||
//
|
||||
// The server (app/push/service.py) sends { type: "mail", title, body, tag, url,
|
||||
// unread } encrypted to this browser. One notification per conversation (`tag`),
|
||||
// replaced when more arrives in it; the app icon's badge shows unread Inbox mail.
|
||||
|
||||
/** A window of the app the user is looking at right now. */
|
||||
async function lookingAtApp() {
|
||||
const wins = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
return wins.some((w) => w.visibilityState === "visible" && w.focused);
|
||||
}
|
||||
|
||||
async function showMail(data) {
|
||||
if (typeof data.unread === "number" && self.navigator.setAppBadge) {
|
||||
await (data.unread ? self.navigator.setAppBadge(data.unread) : self.navigator.clearAppBadge()).catch(() => {});
|
||||
}
|
||||
// The app is open in front of the user: the new mail is already on screen.
|
||||
// Not on Apple's push service, which withdraws permission from a site whose
|
||||
// pushes show nothing.
|
||||
const sub = await self.registration.pushManager.getSubscription();
|
||||
const apple = Boolean(sub?.endpoint.includes(".push.apple.com"));
|
||||
if (!apple && (await lookingAtApp())) return;
|
||||
await self.registration.showNotification(data.title || "New mail", {
|
||||
body: data.body || "",
|
||||
tag: data.tag || "new-mail",
|
||||
renotify: true,
|
||||
icon: "/icon-192.png",
|
||||
badge: "/badge-96.png",
|
||||
timestamp: Date.now(),
|
||||
data: { url: data.url || "/mail" },
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
let data = {};
|
||||
try {
|
||||
data = event.data ? event.data.json() : {};
|
||||
} catch {
|
||||
/* not ours to read: still say something, browsers require it */
|
||||
}
|
||||
event.waitUntil(showMail(data));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url = new URL(event.notification.data?.url || "/mail", self.location.origin).href;
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const wins = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
// An open window of the app: bring it forward and let the app go there
|
||||
// itself (lib/push.ts), keeping whatever else it has open.
|
||||
const win = wins.find((w) => w.focused) ?? wins.find((w) => w.visibilityState === "visible") ?? wins[0];
|
||||
if (win) {
|
||||
await win.focus().catch(() => {});
|
||||
win.postMessage({ type: "open", url });
|
||||
return;
|
||||
}
|
||||
await self.clients.openWindow(url);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
// The push service replaced this browser's subscription (rare; Firefox does it
|
||||
// after long idle). Subscribe again with the same server key; the app sends the
|
||||
// new one to the server the next time it starts (lib/push.ts).
|
||||
self.addEventListener("pushsubscriptionchange", (event) => {
|
||||
const key = event.oldSubscription?.options?.applicationServerKey;
|
||||
if (!key) return;
|
||||
event.waitUntil(
|
||||
self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key }).catch(() => {}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect } from "react";
|
||||
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
|
||||
import { OfflineController } from "./components/Offline";
|
||||
import { PushController } from "./components/PushController";
|
||||
import { useT } from "./i18n";
|
||||
import { isOfflineError } from "./lib/api";
|
||||
import { useMe } from "./lib/auth";
|
||||
@@ -50,6 +51,7 @@ function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
return (
|
||||
<>
|
||||
<OfflineController />
|
||||
<PushController />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { useT } from "../i18n";
|
||||
import { hideInstall, promptInstall, useInstallWay, type InstallWay } from "../lib/install";
|
||||
|
||||
const STEPS: Record<Exclude<InstallWay, "prompt" | null>, "install.stepsIos" | "install.stepsOpera" | "install.stepsFirefox"> = {
|
||||
ios: "install.stepsIos",
|
||||
opera: "install.stepsOpera",
|
||||
firefox: "install.stepsFirefox",
|
||||
};
|
||||
|
||||
function InstallGlyph() {
|
||||
// A phone with an arrow into it.
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={20} height={20} fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="6" y="2.5" width="12" height="19" rx="2.5" />
|
||||
<path d="M12 7v7m-3-3 3 3 3-3M10.5 18.5h3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Install app" in the mobile drawer: the browser's own install dialog where
|
||||
* there is one, else how to add Ultimo to the Home Screen in this browser.
|
||||
* Hidden once installed, when opened as the app, or when the user hides it. */
|
||||
export function InstallAppEntry({ onDone }: { onDone?: () => void }) {
|
||||
const t = useT();
|
||||
const way = useInstallWay();
|
||||
const [steps, setSteps] = useState<Exclude<InstallWay, "prompt" | null> | null>(null);
|
||||
if (!way && !steps) return null;
|
||||
|
||||
const open = async () => {
|
||||
if (way === "prompt") {
|
||||
await promptInstall();
|
||||
onDone?.();
|
||||
} else if (way) {
|
||||
setSteps(way);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{way && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-app-accent hover:bg-app-hover"
|
||||
onClick={() => void open()}
|
||||
>
|
||||
<InstallGlyph />
|
||||
{t("install.entry")}
|
||||
</button>
|
||||
)}
|
||||
{steps &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] backdrop-blur-sm sm:items-center"
|
||||
onClick={() => setSteps(null)}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={t("install.title")}
|
||||
className="w-full max-w-sm rounded-2xl bg-app-elevated p-5 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<img src="/icon-192.png" alt="" className="h-10 w-10" />
|
||||
<h2 className="text-base font-semibold">{t("install.title")}</h2>
|
||||
</div>
|
||||
<p className="whitespace-pre-line text-sm text-app-muted">{t(STEPS[steps])}</p>
|
||||
<div className="mt-5 flex items-center justify-between gap-3">
|
||||
<button
|
||||
className="text-sm text-app-faint hover:text-app-fg"
|
||||
onClick={() => {
|
||||
hideInstall();
|
||||
setSteps(null);
|
||||
onDone?.();
|
||||
}}
|
||||
>
|
||||
{t("install.hide")}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg bg-app-accent px-4 py-1.5 text-sm font-medium text-white"
|
||||
onClick={() => {
|
||||
setSteps(null);
|
||||
onDone?.();
|
||||
}}
|
||||
>
|
||||
{t("install.gotIt")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export function OfflineController() {
|
||||
const current = useOfflineNotice();
|
||||
const wasOffline = useRef(false);
|
||||
|
||||
// Install (or remove) the service worker to match this device's setting.
|
||||
// Install the service worker, with offline access as this device has it set.
|
||||
useEffect(() => {
|
||||
void startOffline();
|
||||
}, [enabled]);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { closeThreadNotification, resyncPush } from "../lib/push";
|
||||
|
||||
/** Notifications about new mail, the page's side (signed-in pages only): keep
|
||||
* the server's copy of this device's subscription current, open what a clicked
|
||||
* notification points at, and close a conversation's notification once it is
|
||||
* open here. */
|
||||
export function PushController() {
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
void resyncPush();
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
// A notification was clicked while the app was open (public/sw.js).
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
if (e.data?.type !== "open" || typeof e.data.url !== "string") return;
|
||||
const url = new URL(e.data.url, window.location.origin);
|
||||
if (url.origin === window.location.origin) navigate(url.pathname + url.search);
|
||||
};
|
||||
navigator.serviceWorker.addEventListener("message", onMessage);
|
||||
return () => navigator.serviceWorker.removeEventListener("message", onMessage);
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const m = pathname.match(/^\/mail\/t\/([0-9a-fA-F-]{36})/);
|
||||
if (m) void closeThreadNotification(m[1]).catch(() => {});
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -597,6 +597,25 @@ export const en = {
|
||||
"offline.settingSaving": "Saving recent mail…",
|
||||
"offline.settingUnsupported":
|
||||
"Not available here: browsers allow offline access only when the app is opened over HTTPS.",
|
||||
"push.settingTitle": "Notifications",
|
||||
"push.settingHelp":
|
||||
"Get a notification on this device when new mail arrives in an Inbox, even when Ultimo isn't open. Shows the sender and subject. Turned off when you sign out.",
|
||||
"push.needsInstall":
|
||||
"On iPhone and iPad, add Ultimo to your Home Screen first (Share → Add to Home Screen), then turn this on from the app.",
|
||||
"push.blocked":
|
||||
"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.failed": "Couldn't turn notifications on.",
|
||||
"install.entry": "Install app",
|
||||
"install.title": "Install Ultimo",
|
||||
"install.stepsIos":
|
||||
"1. Tap the Share button (the square with an arrow) in Safari's toolbar.\n2. Choose Add to Home Screen.\n3. Tap Add. Ultimo opens from your Home Screen like an app, and can show notifications about new mail.",
|
||||
"install.stepsOpera":
|
||||
"1. Open Opera's menu (⋮).\n2. Choose Add to → Home screen.\n\nOpera adds a shortcut to Ultimo on your Home Screen. To install Ultimo as a full app, open this page in Chrome and tap Install app there.",
|
||||
"install.stepsFirefox": "1. Open Firefox's menu (⋮).\n2. Choose Add app to Home screen (or Install).",
|
||||
"install.hide": "Don't show this again",
|
||||
"install.gotIt": "Got it",
|
||||
} as const;
|
||||
|
||||
export type MessageKey = keyof typeof en;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type QueryClient, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api, ApiError } from "./api";
|
||||
import { clearOfflineData } from "./offline";
|
||||
import { disablePush } from "./push";
|
||||
import { wipeLocal } from "./scheduled";
|
||||
|
||||
export function useMe() {
|
||||
@@ -28,6 +29,7 @@ export function signedOut(queryClient: QueryClient) {
|
||||
queryClient.setQueryData(["me"], null);
|
||||
void clearOfflineData(); // mail saved for offline reading goes with the session
|
||||
void wipeLocal(); // and so does anything written offline
|
||||
void disablePush(); // and notifications: a signed-out device shows no mail
|
||||
queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== "me" });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// "Install app" (the mobile drawer). Browsers that can install the web app
|
||||
// themselves (Chrome, Edge, Samsung Internet on Android) announce it with
|
||||
// `beforeinstallprompt`; we keep that event and show their own install dialog
|
||||
// on a click. The others only add a Home Screen shortcut from their own menu
|
||||
// (iPhone/iPad Safari: Share → Add to Home Screen; Opera, Firefox: the browser
|
||||
// menu) — for those the entry says how. Imported by main.tsx before the app
|
||||
// renders: the event can come before React has mounted anything.
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
interface InstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
/** How this browser installs the app, or null when there is nothing to offer. */
|
||||
export type InstallWay =
|
||||
| "prompt" // the browser's own install dialog
|
||||
| "ios" // Share → Add to Home Screen
|
||||
| "opera" // Opera menu → Add to → Home screen
|
||||
| "firefox" // Firefox menu → Add app to Home screen
|
||||
| null;
|
||||
|
||||
const HIDDEN_KEY = "install.hidden";
|
||||
|
||||
let deferred: InstallPromptEvent | null = null;
|
||||
let installed = false;
|
||||
const listeners = new Set<() => void>();
|
||||
let version = 0;
|
||||
|
||||
function changed() {
|
||||
version++;
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
window.addEventListener("beforeinstallprompt", (e) => {
|
||||
e.preventDefault(); // no mini-infobar: the drawer entry offers it instead
|
||||
deferred = e as InstallPromptEvent;
|
||||
changed();
|
||||
});
|
||||
|
||||
window.addEventListener("appinstalled", () => {
|
||||
deferred = null;
|
||||
installed = true;
|
||||
changed();
|
||||
});
|
||||
|
||||
/** Opened as the installed app (or from a Home Screen shortcut). */
|
||||
export function isStandalone(): boolean {
|
||||
return (
|
||||
window.matchMedia?.("(display-mode: standalone)").matches ||
|
||||
window.matchMedia?.("(display-mode: fullscreen)").matches ||
|
||||
(navigator as { standalone?: boolean }).standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
function hidden(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(HIDDEN_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function hideInstall(): void {
|
||||
try {
|
||||
localStorage.setItem(HIDDEN_KEY, "1");
|
||||
} catch {
|
||||
/* shown again next time: harmless */
|
||||
}
|
||||
changed();
|
||||
}
|
||||
|
||||
export function installWay(): InstallWay {
|
||||
if (installed || isStandalone() || hidden()) return null;
|
||||
if (deferred) return "prompt";
|
||||
const ua = navigator.userAgent;
|
||||
const iOS = /iPhone|iPad|iPod/.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||
if (iOS) return "ios";
|
||||
if (/\bOPR\/|\bOPT\//.test(ua)) return "opera";
|
||||
if (/Firefox\//.test(ua) && /Android/.test(ua)) return "firefox";
|
||||
// Chrome without the event: already installed, or not installable here (no
|
||||
// HTTPS) — nothing worth offering.
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useInstallWay(): InstallWay {
|
||||
useSyncExternalStore(
|
||||
(l) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
() => version,
|
||||
);
|
||||
return installWay();
|
||||
}
|
||||
|
||||
/** Show the browser's own install dialog; true when the user installed. */
|
||||
export async function promptInstall(): Promise<boolean> {
|
||||
const event = deferred;
|
||||
if (!event) return false;
|
||||
deferred = null; // each event can show its dialog once
|
||||
await event.prompt();
|
||||
const { outcome } = await event.userChoice;
|
||||
if (outcome === "accepted") installed = true;
|
||||
changed();
|
||||
return outcome === "accepted";
|
||||
}
|
||||
+32
-22
@@ -133,33 +133,43 @@ export function setLastSync(ms: number): void {
|
||||
emitSetting();
|
||||
}
|
||||
|
||||
/** Install the service worker, if this device keeps mail (call on app start). */
|
||||
export async function startOffline(): Promise<void> {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
if (offlineEnabled() && offlineSupported()) {
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register("/sw.js", { scope: "/" });
|
||||
// register() does not look for a newer sw.js when one is installed
|
||||
// already; ask explicitly, so a release reaches this device on its next start.
|
||||
void reg.update().catch(() => {});
|
||||
} catch (err) {
|
||||
console.warn("offline access: service worker not installed", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Turned off (perhaps in another tab): make sure nothing is left behind.
|
||||
const regs = await navigator.serviceWorker.getRegistrations();
|
||||
if (regs.length) await disableOffline();
|
||||
/** The service worker's URL: its offline access is on only under `?offline=1`. */
|
||||
function workerUrl(): string {
|
||||
return offlineEnabled() ? "/sw.js?offline=1" : "/sw.js";
|
||||
}
|
||||
|
||||
/** Whether the service worker is installed and running (it does the saving). */
|
||||
/**
|
||||
* Install the app's service worker (call on app start, and when the setting
|
||||
* changes). Always installed where the browser allows it — it shows
|
||||
* notifications about new mail and makes the app installable — and with offline
|
||||
* access only on devices that keep mail. Registering under the other URL is an
|
||||
* update the browser applies at once (the worker skips waiting).
|
||||
*/
|
||||
export async function startOffline(): Promise<void> {
|
||||
if (!("serviceWorker" in navigator) || !window.isSecureContext) return;
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register(workerUrl(), { scope: "/" });
|
||||
// register() does not look for a newer sw.js when one is installed
|
||||
// already; ask explicitly, so a release reaches this device on its next start.
|
||||
void reg.update().catch(() => {});
|
||||
} catch (err) {
|
||||
console.warn("service worker not installed", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the service worker that saves mail is installed and running. */
|
||||
export async function offlineReady(): Promise<boolean> {
|
||||
if (!("serviceWorker" in navigator) || !offlineEnabled()) return false;
|
||||
// Not `controller`: a page loaded with a hard reload is never controlled,
|
||||
// yet the worker is there and can fetch and save on its behalf.
|
||||
const deadline = Date.now() + 10_000;
|
||||
const timeout = new Promise<null>((resolve) => window.setTimeout(() => resolve(null), 10_000));
|
||||
const reg = await Promise.race([navigator.serviceWorker.ready, timeout]);
|
||||
return Boolean(reg?.active);
|
||||
// Just turned on: the worker without offline access may still be the active one.
|
||||
while (reg?.active && !reg.active.scriptURL.includes("offline=1") && Date.now() < deadline) {
|
||||
await new Promise((r) => window.setTimeout(r, 200));
|
||||
}
|
||||
return Boolean(reg?.active?.scriptURL.includes("offline=1"));
|
||||
}
|
||||
|
||||
export async function enableOffline(): Promise<void> {
|
||||
@@ -176,9 +186,9 @@ export async function disableOffline(): Promise<void> {
|
||||
/* ignore */
|
||||
}
|
||||
emitSetting();
|
||||
if ("serviceWorker" in navigator) {
|
||||
for (const reg of await navigator.serviceWorker.getRegistrations()) await reg.unregister();
|
||||
}
|
||||
// The worker stays (notifications), without offline access; it clears what
|
||||
// it saved when it takes over, and so does this, in case it is slow to.
|
||||
await startOffline();
|
||||
if ("caches" in window) {
|
||||
for (const name of await caches.keys()) if (name.startsWith("wm-")) await caches.delete(name);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const COUNT = 50; // the list's page size, so the first page is saved as shown
|
||||
const PARALLEL = 3;
|
||||
const TIMEOUT_MS = 30_000;
|
||||
// The message protocol this page speaks with public/sw.js (its VERSION).
|
||||
const WORKER_VERSION = 3;
|
||||
const WORKER_VERSION = 4;
|
||||
const CID_RE = /\/api\/messages\/[0-9a-fA-F-]{36}\/cid\/[^"'\s)&]+/g;
|
||||
|
||||
// What the app asks for when it starts: without these it cannot open offline.
|
||||
@@ -51,8 +51,13 @@ function ask<T>(worker: ServiceWorker, message: object, ms: number): Promise<T>
|
||||
async function upToDate(worker: ServiceWorker | null): Promise<boolean> {
|
||||
if (!worker) return false;
|
||||
try {
|
||||
const { version } = await ask<{ version?: number }>(worker, { type: "version" }, 2000);
|
||||
return (version ?? 0) >= WORKER_VERSION;
|
||||
const { version, offline } = await ask<{ version?: number; offline?: boolean }>(
|
||||
worker,
|
||||
{ type: "version" },
|
||||
2000,
|
||||
);
|
||||
// A worker without offline access (the setting was just turned on) saves nothing.
|
||||
return (version ?? 0) >= WORKER_VERSION && offline === true;
|
||||
} catch {
|
||||
return false; // too old to know the question
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Notifications about new mail on this device (Settings → Accounts →
|
||||
// "Notifications"). The browser subscribes to its push service with the
|
||||
// server's key; the server (app/push) sends there when new mail reaches an
|
||||
// Inbox, and the service worker (public/sw.js) shows it. Per device, like
|
||||
// offline access: the subscription itself is the setting.
|
||||
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { apiFetch } from "./api";
|
||||
|
||||
export type PushState =
|
||||
| "unsupported" // no service worker / Push API (or not HTTPS)
|
||||
| "needs-install" // iPhone / iPad: only an app added to the Home Screen may notify
|
||||
| "blocked" // the user said no in the browser's prompt (only the browser can undo it)
|
||||
| "off"
|
||||
| "on";
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let version = 0;
|
||||
|
||||
function changed() {
|
||||
version++;
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
function isAppleMobile(): boolean {
|
||||
return /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||
}
|
||||
|
||||
function standalone(): boolean {
|
||||
return window.matchMedia?.("(display-mode: standalone)").matches || (navigator as { standalone?: boolean }).standalone === true;
|
||||
}
|
||||
|
||||
function supported(): boolean {
|
||||
return window.isSecureContext && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||||
}
|
||||
|
||||
async function registration(): Promise<ServiceWorkerRegistration | null> {
|
||||
if (!("serviceWorker" in navigator)) return null;
|
||||
const timeout = new Promise<null>((resolve) => window.setTimeout(() => resolve(null), 10_000));
|
||||
return Promise.race([navigator.serviceWorker.ready, timeout]);
|
||||
}
|
||||
|
||||
async function current(): Promise<PushSubscription | null> {
|
||||
const reg = await registration();
|
||||
return reg ? reg.pushManager.getSubscription() : null;
|
||||
}
|
||||
|
||||
export async function pushState(): Promise<PushState> {
|
||||
if (!supported()) return isAppleMobile() && !standalone() ? "needs-install" : "unsupported";
|
||||
if (Notification.permission === "denied") return "blocked";
|
||||
return (await current()) && Notification.permission === "granted" ? "on" : "off";
|
||||
}
|
||||
|
||||
/** This device's notification state, kept up to date. */
|
||||
export function usePushState(): PushState | null {
|
||||
const v = useSyncExternalStore(
|
||||
(l) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
() => version,
|
||||
);
|
||||
const [state, setState] = useState<PushState | null>(null);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void pushState().then((s) => live && setState(s));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [v]);
|
||||
return state;
|
||||
}
|
||||
|
||||
function b64uToBytes(value: string): Uint8Array {
|
||||
const b64 = (value + "=".repeat((4 - (value.length % 4)) % 4)).replace(/-/g, "+").replace(/_/g, "/");
|
||||
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function tellServer(sub: PushSubscription): Promise<void> {
|
||||
const { endpoint, keys } = sub.toJSON();
|
||||
await apiFetch("/push/subscriptions", { method: "POST", body: JSON.stringify({ endpoint, keys }) });
|
||||
}
|
||||
|
||||
/** Turn notifications on (must run from a click: browsers ask permission only then). */
|
||||
export async function enablePush(): Promise<PushState> {
|
||||
try {
|
||||
if (!supported()) return pushState();
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") return pushState();
|
||||
const reg = await registration();
|
||||
if (!reg) return "unsupported";
|
||||
const { public_key } = await apiFetch<{ public_key: string }>("/push/key");
|
||||
let sub = await reg.pushManager.getSubscription();
|
||||
// Subscribed with another server key (the server's key was replaced): start over.
|
||||
const key = sub?.options.applicationServerKey;
|
||||
if (sub && key && b64uToBytes(public_key).join() !== new Uint8Array(key).join()) {
|
||||
await sub.unsubscribe();
|
||||
sub = null;
|
||||
}
|
||||
sub ??= await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64uToBytes(public_key) });
|
||||
await tellServer(sub);
|
||||
return pushState();
|
||||
} finally {
|
||||
changed();
|
||||
}
|
||||
}
|
||||
|
||||
/** Turn notifications off on this device (also on sign-out). */
|
||||
export async function disablePush(): Promise<void> {
|
||||
try {
|
||||
const sub = await current();
|
||||
if (!sub) return;
|
||||
const endpoint = sub.endpoint;
|
||||
await sub.unsubscribe().catch(() => {});
|
||||
// The server forgets it too; if it cannot be told now, it finds out on its
|
||||
// next push (the push service answers "gone").
|
||||
await apiFetch("/push/subscriptions/remove", { method: "POST", body: JSON.stringify({ endpoint }) }).catch(
|
||||
() => {},
|
||||
);
|
||||
void (navigator as { clearAppBadge?: () => Promise<void> }).clearAppBadge?.().catch(() => {});
|
||||
} finally {
|
||||
changed();
|
||||
}
|
||||
}
|
||||
|
||||
/** On app start: make sure the server still has this device's subscription
|
||||
* (it may have been renewed by the browser, or dropped by the server). */
|
||||
export async function resyncPush(): Promise<void> {
|
||||
if (!supported() || Notification.permission !== "granted") return;
|
||||
const sub = await current();
|
||||
if (sub) await tellServer(sub).catch(() => {});
|
||||
}
|
||||
|
||||
/** Show the unread Inbox count on the installed app's icon. */
|
||||
export function setAppBadge(count: number): void {
|
||||
const nav = navigator as { setAppBadge?: (n: number) => Promise<void>; clearAppBadge?: () => Promise<void> };
|
||||
void (count ? nav.setAppBadge?.(count) : nav.clearAppBadge?.())?.catch(() => {});
|
||||
}
|
||||
|
||||
/** Close the notification about a conversation once it is open in the app. */
|
||||
export async function closeThreadNotification(threadId: string): Promise<void> {
|
||||
const reg = await navigator.serviceWorker?.getRegistration();
|
||||
for (const n of (await reg?.getNotifications({ tag: `thread-${threadId}` })) ?? []) n.close();
|
||||
}
|
||||
@@ -14,8 +14,15 @@ function systemPrefersDark(): boolean {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
|
||||
// The installed app's title bar / phone status bar: the app's own background
|
||||
// (--app-bg), following the chosen theme rather than the system's.
|
||||
const BAR_COLOR = { light: "#f3f4f6", dark: "#1b1e20" };
|
||||
|
||||
function apply(resolved: "light" | "dark") {
|
||||
document.documentElement.classList.toggle("dark", resolved === "dark");
|
||||
for (const meta of document.querySelectorAll('meta[name="theme-color"]')) {
|
||||
meta.setAttribute("content", BAR_COLOR[resolved]);
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { BrowserRouter } from "react-router-dom";
|
||||
import { App } from "./App";
|
||||
import { I18nProvider } from "./i18n";
|
||||
import { isSessionGone, signedOut } from "./lib/auth";
|
||||
import "./lib/install"; // catches the install event, which can come before the app renders
|
||||
import { ThemeProvider } from "./lib/theme";
|
||||
import "./index.css";
|
||||
import "./plugins/plugin-ui.css"; // .wm-* classes for plugin bundles (SPEC §11.3)
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "../components/compose/windows";
|
||||
import { DraftsList, ScheduledList } from "../components/list/DraftsList";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { InstallAppEntry } from "../components/InstallApp";
|
||||
import { MessageList } from "../components/list/MessageList";
|
||||
import { Resizer } from "../components/Resizer";
|
||||
import { ThemeToggle } from "../components/ThemeToggle";
|
||||
@@ -34,6 +35,7 @@ import { useT } from "../i18n";
|
||||
import type { MessageKey } from "../i18n/en";
|
||||
import { api, isOfflineError } from "../lib/api";
|
||||
import { clearOfflineData, useOffline } from "../lib/offline";
|
||||
import { disablePush, setAppBadge } from "../lib/push";
|
||||
import { flushLocal, useLocalMessages, wipeLocal } from "../lib/scheduled";
|
||||
import { formatClock, getDateLocale, setTimeFormat } from "../lib/timeFormat";
|
||||
import type { AccountOut, QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
|
||||
@@ -283,6 +285,11 @@ export function MailLayout() {
|
||||
}, [timeFormat]);
|
||||
const quotaQuery = useQuery({ queryKey: ["quota"], queryFn: api.quota });
|
||||
const countsQuery = useQuery({ queryKey: ["folder-counts"], queryFn: api.folderCounts });
|
||||
// The installed app's icon shows unread Inbox mail (notifications set it too).
|
||||
const inboxUnread = countsQuery.data?.inbox_unread;
|
||||
useEffect(() => {
|
||||
if (inboxUnread !== undefined) setAppBadge(inboxUnread);
|
||||
}, [inboxUnread]);
|
||||
|
||||
// Folder/inbox views paginate server-side with an accurate grand total; search
|
||||
// results (a single fetched page of hits) still paginate client-side.
|
||||
@@ -985,6 +992,7 @@ export function MailLayout() {
|
||||
<Icon name="settings" size={22} />
|
||||
{!collapsed && t("nav.settings")}
|
||||
</Link>
|
||||
{mobile && <InstallAppEntry onDone={() => setDrawerOpen(false)} />}
|
||||
<button
|
||||
className={`wm-online-only flex w-full items-center gap-2.5 rounded-lg py-2 text-left text-app-muted hover:bg-app-hover hover:text-app-fg ${
|
||||
collapsed ? "justify-center px-0" : "px-3"
|
||||
@@ -994,6 +1002,7 @@ export function MailLayout() {
|
||||
// Anything still waiting in Scheduled goes out first: what is
|
||||
// kept on this device is wiped with the session.
|
||||
await flushLocal().catch(() => []);
|
||||
await disablePush(); // while still signed in, so the server forgets this device
|
||||
await api.logout();
|
||||
await clearOfflineData();
|
||||
await wipeLocal();
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
useOfflineEnabled,
|
||||
} from "../../lib/offline";
|
||||
import { prefetchRecent } from "../../lib/offlinePrefetch";
|
||||
import { disablePush, enablePush, usePushState } from "../../lib/push";
|
||||
import { useTheme } from "../../lib/theme";
|
||||
import { formatClock, getDateLocale, setTimeFormat } from "../../lib/timeFormat";
|
||||
import { PluginSlot } from "../../plugins/PluginSlot";
|
||||
@@ -235,6 +236,66 @@ function formatMB(bytes: number): string {
|
||||
return bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** Settings → Accounts: notifications about new mail on this device. */
|
||||
function NotificationsSetting() {
|
||||
const t = useT();
|
||||
const state = usePushState();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const usable = state === "on" || state === "off";
|
||||
|
||||
const toggle = async (on: boolean) => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (on) {
|
||||
const result = await enablePush();
|
||||
if (result === "off") setError(t("push.notAllowed"));
|
||||
} else {
|
||||
await disablePush();
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : t("push.failed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold">{t("push.settingTitle")}</h2>
|
||||
<div className="flex gap-1 rounded-lg border border-app-border p-0.5 text-sm">
|
||||
{([true, false] as const).map((value) => (
|
||||
<button
|
||||
key={String(value)}
|
||||
disabled={!usable || busy}
|
||||
onClick={() => void toggle(value)}
|
||||
className={`rounded px-3 py-1 disabled:opacity-50 ${
|
||||
(state === "on") === value && usable
|
||||
? "bg-app-active font-medium text-app-fg"
|
||||
: "text-app-muted hover:bg-app-hover"
|
||||
}`}
|
||||
>
|
||||
{t(value ? "settings.on" : "settings.off")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-app-muted">{t("push.settingHelp")}</p>
|
||||
{state === "needs-install" ? (
|
||||
<p className="text-xs text-app-faint">{t("push.needsInstall")}</p>
|
||||
) : state === "blocked" ? (
|
||||
<p className="text-xs text-app-faint">{t("push.blocked")}</p>
|
||||
) : state === "unsupported" ? (
|
||||
<p className="text-xs text-app-faint">{t("push.unsupported")}</p>
|
||||
) : error ? (
|
||||
<p className="text-xs text-red-500">{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Settings → Accounts: keep recent mail on this device for reading offline. */
|
||||
function OfflineAccessSetting() {
|
||||
const t = useT();
|
||||
@@ -563,6 +624,9 @@ function AccountsSection() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="border-t border-app-border pt-5">
|
||||
<NotificationsSetting />
|
||||
</div>
|
||||
<div className="border-t border-app-border pt-5">
|
||||
<OfflineAccessSetting />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user