287 lines
9.1 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";
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;
}
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 [notificationState, setNotificationState] =
useState<NotificationState>("checking");
const [notice, setNotice] = useState<string | null>(null);
useEffect(() => {
let active = true;
function handleBeforeInstallPrompt(event: Event) {
event.preventDefault();
if (!active) {
return;
}
setInstallPromptEvent(event as BeforeInstallPromptEvent);
}
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() {
const standalone =
window.matchMedia("(display-mode: standalone)").matches ||
Boolean((navigator as Navigator & { standalone?: boolean }).standalone);
setIsStandalone(standalone);
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);
void bootstrap();
return () => {
active = false;
window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
};
}, [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);
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">
{notificationState === "enabled"
? "消息提醒已开启"
: notificationState === "blocked"
? "通知权限已被拒绝"
: "消息提醒未开启"}
</span>
</div>
<div className="mt-5 flex flex-wrap gap-3">
<button
type="button"
onClick={() => {
void installApp();
}}
disabled={installing}
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>
{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>
);
}