2026-04-17 13:55:43 +08:00

386 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState } from "react";
const PWA_INSTALL_MARKER_KEY = "dh-pwa-installed";
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>;
userChoice: Promise<{
outcome: "accepted" | "dismissed";
platform: string;
}>;
};
type NotificationState =
| "checking"
| "idle"
| "pending"
| "enabled"
| "blocked"
| "unsupported";
function urlBase64ToUint8Array(base64String: string) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let index = 0; index < rawData.length; index += 1) {
outputArray[index] = rawData.charCodeAt(index);
}
return outputArray;
}
function isStandaloneDisplayMode() {
const navigatorWithStandalone = navigator as Navigator & {
standalone?: boolean;
};
return (
window.matchMedia("(display-mode: standalone)").matches ||
window.matchMedia("(display-mode: fullscreen)").matches ||
window.matchMedia("(display-mode: minimal-ui)").matches ||
Boolean(navigatorWithStandalone.standalone) ||
document.referrer.startsWith("android-app://")
);
}
function readInstalledMarker() {
try {
return window.localStorage.getItem(PWA_INSTALL_MARKER_KEY) === "1";
} catch {
return false;
}
}
function writeInstalledMarker() {
try {
window.localStorage.setItem(PWA_INSTALL_MARKER_KEY, "1");
} catch {
return;
}
}
export function PwaControls() {
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || "";
const [installPromptEvent, setInstallPromptEvent] =
useState<BeforeInstallPromptEvent | null>(null);
const [installing, setInstalling] = useState(false);
const [isStandalone, setIsStandalone] = useState(false);
const [hasInstalledHint, setHasInstalledHint] = useState(false);
const [notificationState, setNotificationState] =
useState<NotificationState>("checking");
const [notice, setNotice] = useState<string | null>(null);
useEffect(() => {
let active = true;
let delayedSyncTimer: number | null = null;
function syncInstallState() {
const standalone = isStandaloneDisplayMode();
const installed = standalone || readInstalledMarker();
if (standalone) {
writeInstalledMarker();
}
if (!active) {
return standalone;
}
setIsStandalone(standalone);
setHasInstalledHint(installed);
return standalone;
}
function handleBeforeInstallPrompt(event: Event) {
event.preventDefault();
if (!active) {
return;
}
setInstallPromptEvent(event as BeforeInstallPromptEvent);
}
function handleAppInstalled() {
writeInstalledMarker();
if (!active) {
return;
}
setHasInstalledHint(true);
}
function handleVisibilityChange() {
if (!document.hidden) {
syncInstallState();
}
}
function handlePageShow() {
syncInstallState();
}
function handleFocus() {
syncInstallState();
}
async function syncExistingSubscription(subscription: PushSubscription) {
await fetch("/api/push/subscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
subscription: subscription.toJSON(),
}),
});
}
async function bootstrap() {
syncInstallState();
if (
!("serviceWorker" in navigator) ||
!("PushManager" in window) ||
typeof Notification === "undefined"
) {
setNotificationState("unsupported");
return;
}
try {
await navigator.serviceWorker.register("/sw.js");
const registration = await navigator.serviceWorker.ready;
const existingSubscription = await registration.pushManager.getSubscription();
if (!active) {
return;
}
if (existingSubscription) {
if (vapidPublicKey) {
await syncExistingSubscription(existingSubscription);
}
setNotificationState("enabled");
return;
}
setNotificationState(
Notification.permission === "denied" ? "blocked" : "idle",
);
} catch {
if (active) {
setNotificationState("unsupported");
}
}
}
window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
window.addEventListener("appinstalled", handleAppInstalled);
window.addEventListener("pageshow", handlePageShow);
window.addEventListener("focus", handleFocus);
document.addEventListener("visibilitychange", handleVisibilityChange);
void bootstrap();
delayedSyncTimer = window.setTimeout(() => {
syncInstallState();
}, 350);
return () => {
active = false;
window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
window.removeEventListener("appinstalled", handleAppInstalled);
window.removeEventListener("pageshow", handlePageShow);
window.removeEventListener("focus", handleFocus);
document.removeEventListener("visibilitychange", handleVisibilityChange);
if (delayedSyncTimer) {
window.clearTimeout(delayedSyncTimer);
}
};
}, [vapidPublicKey]);
async function enableNotifications() {
if (!vapidPublicKey) {
setNotice("当前还没有配置推送服务,先将应用安装到桌面即可。\n");
return;
}
setNotificationState("pending");
setNotice(null);
try {
const permission = await Notification.requestPermission();
if (permission !== "granted") {
setNotificationState(permission === "denied" ? "blocked" : "idle");
setNotice(
permission === "denied"
? "浏览器已经拒绝消息提醒,请在系统设置里重新打开通知权限。\n"
: "这次没有打开通知权限,稍后仍可再次开启。\n",
);
return;
}
const registration = await navigator.serviceWorker.ready;
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
}
await fetch("/api/push/subscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
subscription: subscription.toJSON(),
}),
});
setNotificationState("enabled");
setNotice("已开启消息提醒,长辈的新留言会直接推送到您的设备。\n");
} catch {
setNotificationState("idle");
setNotice("打开消息提醒失败,请稍后再试。\n");
}
}
async function disableNotifications() {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await fetch("/api/push/unsubscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
endpoint: subscription.endpoint,
}),
});
await subscription.unsubscribe();
}
setNotificationState("idle");
setNotice("这台设备的消息提醒已关闭。\n");
} catch {
setNotice("关闭消息提醒失败,请稍后再试。\n");
}
}
async function installApp() {
if (!installPromptEvent) {
setNotice("如果浏览器没有弹出安装按钮,可以从菜单里选择“安装应用”或“添加到主屏幕”。\n");
return;
}
setInstalling(true);
setNotice(null);
try {
await installPromptEvent.prompt();
const choice = await installPromptEvent.userChoice;
if (choice.outcome === "accepted") {
setInstallPromptEvent(null);
setHasInstalledHint(true);
setNotice("安装提示已经确认,完成后就能像应用一样直接打开。\n");
} else {
setNotice("这次先关闭了安装提示,之后仍可从浏览器菜单安装。\n");
}
} finally {
setInstalling(false);
}
}
return (
<section className="rounded-[28px] border border-white/80 bg-white/88 p-5 shadow-[0_22px_55px_rgba(53,84,141,0.12)]">
<p className="text-sm font-medium tracking-[0.18em] text-[var(--copper)] uppercase">
</p>
<h2 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
</h2>
<p className="mt-3 text-sm leading-7 text-[var(--muted)]">
</p>
<div className="mt-4 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
{isStandalone ? "当前正以桌面应用方式运行" : "当前正以浏览器方式运行"}
</span>
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
{hasInstalledHint ? "这台设备已有主屏幕安装记录" : "暂未检测到主屏幕安装记录"}
</span>
<span className="rounded-full border border-[var(--line)] bg-[var(--paper-soft)] px-4 py-2">
{notificationState === "enabled"
? "消息提醒已开启"
: notificationState === "blocked"
? "通知权限已被拒绝"
: "消息提醒未开启"}
</span>
</div>
<div className="mt-5 flex flex-wrap gap-3">
<button
type="button"
onClick={() => {
void installApp();
}}
disabled={installing || isStandalone}
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--ink)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--ink-soft)] disabled:cursor-not-allowed disabled:opacity-60"
>
{isStandalone
? "已从桌面版打开"
: installing
? "正在呼起安装"
: hasInstalledHint
? "查看安装说明"
: "安装到桌面"}
</button>
{notificationState === "enabled" ? (
<button
type="button"
onClick={() => {
void disableNotifications();
}}
className="inline-flex h-12 items-center justify-center rounded-full border border-[var(--line)] bg-white px-5 text-sm font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
>
</button>
) : (
<button
type="button"
onClick={() => {
void enableNotifications();
}}
disabled={notificationState === "pending" || notificationState === "unsupported"}
className="inline-flex h-12 items-center justify-center rounded-full bg-[var(--olive)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--olive-deep)] disabled:cursor-not-allowed disabled:opacity-60"
>
{notificationState === "pending" ? "正在开启提醒" : "打开消息提醒"}
</button>
)}
</div>
{notice ? (
<p className="mt-4 whitespace-pre-line text-sm leading-7 text-[var(--muted)]">
{notice}
</p>
) : null}
</section>
);
}