Add PWA update notice

This commit is contained in:
tianma committed 2026-06-30 23:59:30 +08:00
1 parent c809aceff0
commit e53fc2cab2
4 files changed
+224 -7

No files matched your search

+10 -6
View File
@@ -1,6 +1,7 @@
import { lazy, Suspense, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Navigate, Route, Routes, useNavigate } from "react-router";
import { PwaUpdateNotice } from "@/components/PwaUpdateNotice";
import { api } from "@/lib/api";
import { EVERNOTE_MIGRATION_PATH } from "@/lib/routes";
import type { AuthSession } from "@edgeever/shared";
@@ -111,10 +112,13 @@ const AuthenticatedWorkspace = () => {
};
export const App = () => (
<Routes>
<Route path={EVERNOTE_MIGRATION_PATH} element={<EvernoteMigrationRoute />} />
<Route path="/" element={<AuthenticatedWorkspace />} />
<Route path="/settings" element={<AuthenticatedWorkspace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<>
<Routes>
<Route path={EVERNOTE_MIGRATION_PATH} element={<EvernoteMigrationRoute />} />
<Route path="/" element={<AuthenticatedWorkspace />} />
<Route path="/settings" element={<AuthenticatedWorkspace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<PwaUpdateNotice />
</>
);
+147
View File
@@ -0,0 +1,147 @@
import { useEffect, useMemo, useState } from "react";
import { CheckCircle2, RefreshCw, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
consumePwaUpdateReloadPending,
PWA_UPDATE_NOTICE_EVENT,
type PwaUpdateNoticeEvent,
type PwaUpdateNoticeKind,
} from "@/lib/pwa-update-notice";
type Notice = {
id: number;
kind: PwaUpdateNoticeKind;
};
const isStandaloneApp = () =>
window.matchMedia("(display-mode: standalone)").matches ||
window.matchMedia("(display-mode: fullscreen)").matches ||
Boolean((navigator as Navigator & { standalone?: boolean }).standalone);
const isDesktopViewport = () => window.matchMedia("(min-width: 1024px)").matches;
const getDisplayMode = () => {
const isStandalone = isStandaloneApp();
return {
isDesktop: isDesktopViewport(),
visible: isStandalone,
};
};
const watchDisplayMode = (onChange: () => void) => {
const mediaQueries = [
window.matchMedia("(display-mode: standalone)"),
window.matchMedia("(display-mode: fullscreen)"),
window.matchMedia("(min-width: 1024px)"),
];
for (const query of mediaQueries) {
query.addEventListener("change", onChange);
}
return () => {
for (const query of mediaQueries) {
query.removeEventListener("change", onChange);
}
};
};
const noticeCopy: Record<PwaUpdateNoticeKind, { title: string; description: string }> = {
checking: {
title: "发现新版本",
description: "正在应用更新,稍后会自动刷新。",
},
updated: {
title: "EdgeEver 已更新",
description: "当前 PWA 已是最新版本。",
},
"reload-required": {
title: "新版本已就绪",
description: "刷新后即可使用最新功能。",
},
};
export const PwaUpdateNotice = () => {
const [displayMode, setDisplayMode] = useState(() => getDisplayMode());
const [notice, setNotice] = useState<Notice | null>(null);
useEffect(() => {
const syncDisplayMode = () => setDisplayMode(getDisplayMode());
return watchDisplayMode(syncDisplayMode);
}, []);
useEffect(() => {
if (consumePwaUpdateReloadPending()) {
setNotice({ id: Date.now(), kind: "updated" });
}
const handleNotice = (event: Event) => {
const { kind } = (event as PwaUpdateNoticeEvent).detail;
setNotice({ id: Date.now(), kind });
};
window.addEventListener(PWA_UPDATE_NOTICE_EVENT, handleNotice);
return () => window.removeEventListener(PWA_UPDATE_NOTICE_EVENT, handleNotice);
}, []);
useEffect(() => {
if (!notice) {
return;
}
const timeout = window.setTimeout(() => setNotice(null), 8_000);
return () => window.clearTimeout(timeout);
}, [notice]);
const copy = useMemo(() => (notice ? noticeCopy[notice.kind] : null), [notice]);
if (!displayMode.visible || !notice || !copy) {
return null;
}
const isReloadRequired = notice.kind === "reload-required";
return (
<div
className={cn(
"fixed z-[80] max-w-[calc(100vw-2rem)] rounded-lg border border-slate-200 bg-white/95 p-3 text-slate-900 shadow-[0_18px_48px_rgba(15,23,42,0.16)] backdrop-blur",
displayMode.isDesktop
? "right-5 top-5 w-[22rem]"
: "inset-x-4 bottom-[calc(4rem+env(safe-area-inset-bottom))]"
)}
role="status"
aria-live="polite"
>
<div className="flex items-start gap-3">
<div
className={cn(
"mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
notice.kind === "updated" ? "bg-emerald-50 text-emerald-600" : "bg-slate-100 text-slate-600"
)}
aria-hidden="true"
>
{notice.kind === "updated" ? <CheckCircle2 className="h-4 w-4" /> : <RefreshCw className="h-4 w-4" />}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold leading-5">{copy.title}</div>
<div className="mt-0.5 text-xs leading-5 text-slate-500">{copy.description}</div>
{isReloadRequired ? (
<Button className="mt-2" size="sm" variant="solid" onClick={() => window.location.reload()}>
立即刷新
</Button>
) : null}
</div>
<button
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
type="button"
aria-label="关闭更新通知"
onClick={() => setNotice(null)}
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
};
+33
View File
@@ -0,0 +1,33 @@
export const PWA_UPDATE_NOTICE_EVENT = "edgeever:pwa-update-notice";
const PWA_UPDATE_RELOADED_AT_KEY = "edgeever:pwa-update-reloaded-at";
export type PwaUpdateNoticeKind = "checking" | "updated" | "reload-required";
export type PwaUpdateNoticeDetail = {
kind: PwaUpdateNoticeKind;
};
export type PwaUpdateNoticeEvent = CustomEvent<PwaUpdateNoticeDetail>;
export const emitPwaUpdateNotice = (detail: PwaUpdateNoticeDetail) => {
window.dispatchEvent(new CustomEvent<PwaUpdateNoticeDetail>(PWA_UPDATE_NOTICE_EVENT, { detail }));
};
export const markPwaUpdateReloadPending = () => {
try {
window.sessionStorage.setItem(PWA_UPDATE_RELOADED_AT_KEY, String(Date.now()));
} catch {
// Storage can be unavailable in restricted browsing modes.
}
};
export const consumePwaUpdateReloadPending = () => {
try {
const value = window.sessionStorage.getItem(PWA_UPDATE_RELOADED_AT_KEY);
window.sessionStorage.removeItem(PWA_UPDATE_RELOADED_AT_KEY);
return Boolean(value);
} catch {
return false;
}
};
+34 -1
View File
@@ -4,9 +4,42 @@ import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import { registerSW } from "virtual:pwa-register";
import { App } from "./app/App";
import { emitPwaUpdateNotice, markPwaUpdateReloadPending } from "./lib/pwa-update-notice";
import "./styles/globals.css";
registerSW({ immediate: true });
const PWA_UPDATE_CHECK_INTERVAL_MS = 10 * 60 * 1_000;
let updateServiceWorker: ReturnType<typeof registerSW>;
updateServiceWorker = registerSW({
immediate: true,
onNeedRefresh() {
emitPwaUpdateNotice({ kind: "checking" });
void updateServiceWorker(true);
},
onNeedReload() {
markPwaUpdateReloadPending();
window.location.reload();
},
onRegisteredSW(_swScriptUrl, registration) {
if (!registration) {
return;
}
const checkForUpdate = () => {
if (document.visibilityState === "visible") {
void registration.update().catch(() => undefined);
}
};
const updateInterval = window.setInterval(checkForUpdate, PWA_UPDATE_CHECK_INTERVAL_MS);
window.addEventListener("beforeunload", () => window.clearInterval(updateInterval), { once: true });
document.addEventListener("visibilitychange", checkForUpdate);
},
onRegisterError(error) {
console.warn("PWA service worker registration failed", error);
},
});
const queryClient = new QueryClient({
defaultOptions: {