Add PWA update notice
This commit is contained in:
1 parent
c809aceff0
commit
e53fc2cab2
4 files changed
+224
-7
No files matched your search
@@ -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 />
|
||||
</>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user