mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
Add close button to Chrome/Edge install prompt and respect dismissed state when determining if prompt can be shown. iOS version already had dismiss capability, so this brings feature parity across all supported browsers.
128 lines
3.8 KiB
TypeScript
128 lines
3.8 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
|
|
|
interface BeforeInstallPromptEvent extends Event {
|
|
prompt: () => Promise<void>
|
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
|
|
}
|
|
|
|
type InstallState = 'idle' | 'available' | 'installing' | 'installed'
|
|
|
|
const INSTALL_DISMISSED_KEY = 'pwa_install_dismissed'
|
|
|
|
function isIOSSafari(): boolean {
|
|
if (typeof window === 'undefined') return false
|
|
const ua = window.navigator.userAgent
|
|
const isIOS = /iPad|iPhone|iPod/.test(ua)
|
|
const isWebkit = /WebKit/.test(ua)
|
|
const isChrome = /CriOS/.test(ua)
|
|
const isFirefox = /FxiOS/.test(ua)
|
|
// iOS Safari is WebKit-based but not Chrome or Firefox
|
|
return isIOS && isWebkit && !isChrome && !isFirefox
|
|
}
|
|
|
|
function getInstallDismissed(): boolean {
|
|
try {
|
|
return localStorage.getItem(INSTALL_DISMISSED_KEY) === 'true'
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function setInstallDismissed(): void {
|
|
try {
|
|
localStorage.setItem(INSTALL_DISMISSED_KEY, 'true')
|
|
} catch {
|
|
// Ignore storage errors
|
|
}
|
|
}
|
|
|
|
export function usePWAInstall(): {
|
|
installState: InstallState
|
|
canInstall: boolean
|
|
canInstallIOS: boolean
|
|
isStandalone: boolean
|
|
isIOS: boolean
|
|
promptInstall: () => Promise<boolean>
|
|
dismissInstall: () => void
|
|
} {
|
|
const [installState, setInstallState] = useState<InstallState>('idle')
|
|
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
|
|
const [dismissed, setDismissed] = useState(() => getInstallDismissed())
|
|
|
|
const isIOS = typeof window !== 'undefined' && isIOSSafari()
|
|
|
|
const isStandalone =
|
|
typeof window !== 'undefined' &&
|
|
(window.matchMedia('(display-mode: standalone)').matches ||
|
|
(window.navigator as Navigator & { standalone?: boolean }).standalone === true)
|
|
|
|
useEffect(() => {
|
|
if (isStandalone) {
|
|
setInstallState('installed')
|
|
return
|
|
}
|
|
|
|
const handleBeforeInstallPrompt = (e: Event) => {
|
|
e.preventDefault()
|
|
setDeferredPrompt(e as BeforeInstallPromptEvent)
|
|
setInstallState('available')
|
|
}
|
|
|
|
const handleAppInstalled = () => {
|
|
setInstallState('installed')
|
|
setDeferredPrompt(null)
|
|
}
|
|
|
|
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
|
window.addEventListener('appinstalled', handleAppInstalled)
|
|
|
|
return () => {
|
|
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
|
window.removeEventListener('appinstalled', handleAppInstalled)
|
|
}
|
|
}, [isStandalone])
|
|
|
|
const promptInstall = useCallback(async (): Promise<boolean> => {
|
|
if (!deferredPrompt) {
|
|
return false
|
|
}
|
|
|
|
// Clear immediately to prevent re-entrancy while userChoice is pending
|
|
const prompt = deferredPrompt
|
|
setDeferredPrompt(null)
|
|
setInstallState('installing')
|
|
|
|
try {
|
|
await prompt.prompt()
|
|
const { outcome } = await prompt.userChoice
|
|
|
|
if (outcome === 'accepted') {
|
|
setInstallState('installed')
|
|
return true
|
|
} else {
|
|
// User dismissed, wait for a new beforeinstallprompt event
|
|
setInstallState('idle')
|
|
return false
|
|
}
|
|
} catch {
|
|
setInstallState('idle')
|
|
return false
|
|
}
|
|
}, [deferredPrompt])
|
|
|
|
const dismissInstall = useCallback(() => {
|
|
setDismissed(true)
|
|
setInstallDismissed()
|
|
}, [])
|
|
|
|
return {
|
|
installState,
|
|
canInstall: installState === 'available' && !dismissed,
|
|
canInstallIOS: isIOS && !isStandalone && !dismissed,
|
|
isStandalone,
|
|
isIOS,
|
|
promptInstall,
|
|
dismissInstall
|
|
}
|
|
}
|