386 lines
12 KiB
TypeScript
386 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);
|
||
|
||
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>
|
||
);
|
||
} |