401 lines
12 KiB
TypeScript
401 lines
12 KiB
TypeScript
"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);
|
|
const isInstalled = hasInstalledHint || isStandalone;
|
|
const notificationLabel =
|
|
notificationState === "enabled"
|
|
? "消息提醒已开启"
|
|
: notificationState === "blocked"
|
|
? "通知权限已被拒绝"
|
|
: "消息提醒未开启";
|
|
|
|
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">
|
|
{isInstalled ? "桌面版" : "快捷设置"}
|
|
</p>
|
|
<h2 className="mt-2 font-[family-name:var(--font-display)] text-2xl text-[var(--ink)]">
|
|
{isInstalled
|
|
? isStandalone
|
|
? "已从桌面版打开"
|
|
: "这台设备已添加到主屏幕"
|
|
: "安装到桌面,随时收到长辈的消息"}
|
|
</h2>
|
|
<p className="mt-3 text-sm leading-7 text-[var(--muted)]">
|
|
{isInstalled
|
|
? notificationState === "enabled"
|
|
? "新留言会直接推送到这台设备。"
|
|
: "如需新留言提醒,可在下方开启通知。"
|
|
: "安装后打开更快,开启提醒后,长辈每次留言都能立刻收到通知。"}
|
|
</p>
|
|
|
|
<div className="mt-4 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
|
|
{isInstalled ? (
|
|
<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">
|
|
{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">
|
|
{notificationLabel}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-5 flex flex-wrap gap-3">
|
|
{!isInstalled ? (
|
|
<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"
|
|
>
|
|
{installing ? "正在呼起安装" : "安装到桌面"}
|
|
</button>
|
|
) : null}
|
|
|
|
{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>
|
|
);
|
|
} |