diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 84f788c4..39a70cbe 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -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 = () => ( - - } /> - } /> - } /> - } /> - + <> + + } /> + } /> + } /> + } /> + + + ); diff --git a/apps/web/src/components/PwaUpdateNotice.tsx b/apps/web/src/components/PwaUpdateNotice.tsx new file mode 100644 index 00000000..761a1322 --- /dev/null +++ b/apps/web/src/components/PwaUpdateNotice.tsx @@ -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 = { + checking: { + title: "发现新版本", + description: "正在应用更新,稍后会自动刷新。", + }, + updated: { + title: "EdgeEver 已更新", + description: "当前 PWA 已是最新版本。", + }, + "reload-required": { + title: "新版本已就绪", + description: "刷新后即可使用最新功能。", + }, +}; + +export const PwaUpdateNotice = () => { + const [displayMode, setDisplayMode] = useState(() => getDisplayMode()); + const [notice, setNotice] = useState(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 ( +
+
+ +
+
{copy.title}
+
{copy.description}
+ {isReloadRequired ? ( + + ) : null} +
+ +
+
+ ); +}; diff --git a/apps/web/src/lib/pwa-update-notice.ts b/apps/web/src/lib/pwa-update-notice.ts new file mode 100644 index 00000000..6a5dba15 --- /dev/null +++ b/apps/web/src/lib/pwa-update-notice.ts @@ -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; + +export const emitPwaUpdateNotice = (detail: PwaUpdateNoticeDetail) => { + window.dispatchEvent(new CustomEvent(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; + } +}; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index de4250d9..2ecbc3a2 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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; + +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: {