74 lines
2.7 KiB
TypeScript
74 lines
2.7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
export interface BeforeInstallPromptEvent extends Event {
|
|
prompt: () => Promise<void>;
|
|
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
|
}
|
|
|
|
function isStandalone(): boolean {
|
|
if (typeof window === "undefined") return false;
|
|
return (
|
|
window.matchMedia("(display-mode: standalone)").matches ||
|
|
// iOS Safari's own (non-standard, but the only signal it exposes).
|
|
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
|
);
|
|
}
|
|
|
|
function isIos(): boolean {
|
|
if (typeof navigator === "undefined") return false;
|
|
return /iphone|ipad|ipod/i.test(navigator.userAgent);
|
|
}
|
|
|
|
/**
|
|
* Shared `beforeinstallprompt` capture + standalone/iOS detection — the
|
|
* one PWA install implementation both `InstallAppButton` (Settings, every
|
|
* role) and `WelcomeInstallCard` (the unauthenticated Welcome page) use.
|
|
* See the brief's explicit "do not create a competing second installer".
|
|
* All detection lives in `useEffect`, never a `useState` lazy
|
|
* initializer — reading `window`/`navigator` during the initializer would
|
|
* run during SSR too and mismatch the client's first render (the same
|
|
* hydration bug this app's push-subscribe-toggle.tsx fixed in Phase 7).
|
|
*/
|
|
export function useInstallPrompt() {
|
|
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
|
const [installed, setInstalled] = useState(false);
|
|
const [ios, setIos] = useState(false);
|
|
|
|
useEffect(() => {
|
|
// Async chain, not a direct setState call in the effect body — same
|
|
// pattern push-subscribe-toggle.tsx uses (see that file's own
|
|
// comment) to satisfy react-hooks/set-state-in-effect while still
|
|
// reading browser-only APIs that can't run during SSR.
|
|
void Promise.resolve().then(() => {
|
|
setInstalled(isStandalone());
|
|
setIos(isIos());
|
|
});
|
|
|
|
const onBeforeInstallPrompt = (event: Event) => {
|
|
event.preventDefault();
|
|
setDeferredPrompt(event as BeforeInstallPromptEvent);
|
|
};
|
|
const onInstalled = () => {
|
|
setInstalled(true);
|
|
setDeferredPrompt(null);
|
|
};
|
|
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
|
window.addEventListener("appinstalled", onInstalled);
|
|
return () => {
|
|
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
|
window.removeEventListener("appinstalled", onInstalled);
|
|
};
|
|
}, []);
|
|
|
|
const promptInstall = async (): Promise<void> => {
|
|
if (!deferredPrompt) return;
|
|
await deferredPrompt.prompt();
|
|
await deferredPrompt.userChoice;
|
|
setDeferredPrompt(null);
|
|
};
|
|
|
|
return { installed, ios, canPromptNatively: !!deferredPrompt, promptInstall };
|
|
}
|