|
|
@@ -1,6 +1,6 @@
|
|
|
'use client'
|
|
|
|
|
|
-import { useEffect, useState, useCallback } from 'react'
|
|
|
+import { useCallback, useEffect, useReducer, useState } from 'react'
|
|
|
import Image from 'next/image'
|
|
|
import { Download, X } from 'lucide-react'
|
|
|
import { Button } from '@/components/ui/button'
|
|
|
@@ -20,22 +20,37 @@ interface BeforeInstallPromptEvent extends Event {
|
|
|
const DISMISS_KEY = 'pwa-install-dismissed'
|
|
|
const DISMISS_DURATION = 30 * 24 * 60 * 60 * 1000 // 30 days
|
|
|
|
|
|
-function isDismissed(): boolean {
|
|
|
- try {
|
|
|
- const val = localStorage.getItem(DISMISS_KEY)
|
|
|
- if (!val) return false
|
|
|
- return Date.now() - Number(val) < DISMISS_DURATION
|
|
|
- } catch {
|
|
|
- return false
|
|
|
- }
|
|
|
+/**
|
|
|
+ * The install state lives outside React. Chrome fires `beforeinstallprompt`
|
|
|
+ * again on later navigations, so a per-component listener would flip the
|
|
|
+ * banner back on after the user dismissed it (the dismissal was only checked
|
|
|
+ * on mount, which is why it took a hard refresh to stay gone). Keeping the
|
|
|
+ * captured event and the subscriber set at module scope also means the banner,
|
|
|
+ * the sidebar entry and the settings page all see the same state and update
|
|
|
+ * together.
|
|
|
+ */
|
|
|
+let promptEvent: BeforeInstallPromptEvent | null = null
|
|
|
+let installed = false
|
|
|
+let listenersBound = false
|
|
|
+const subscribers = new Set<() => void>()
|
|
|
+
|
|
|
+function emit() {
|
|
|
+ for (const notify of subscribers) notify()
|
|
|
}
|
|
|
|
|
|
-function dismiss() {
|
|
|
- try {
|
|
|
- localStorage.setItem(DISMISS_KEY, String(Date.now()))
|
|
|
- } catch {
|
|
|
- // localStorage unavailable
|
|
|
- }
|
|
|
+function bindListeners() {
|
|
|
+ if (listenersBound || typeof window === 'undefined') return
|
|
|
+ listenersBound = true
|
|
|
+ window.addEventListener('beforeinstallprompt', (e) => {
|
|
|
+ e.preventDefault()
|
|
|
+ promptEvent = e as BeforeInstallPromptEvent
|
|
|
+ emit()
|
|
|
+ })
|
|
|
+ window.addEventListener('appinstalled', () => {
|
|
|
+ promptEvent = null
|
|
|
+ installed = true
|
|
|
+ emit()
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
function isStandalone(): boolean {
|
|
|
@@ -50,54 +65,81 @@ function isIOSSafari(): boolean {
|
|
|
return /iP(hone|od|ad)/.test(ua) && /Safari/.test(ua) && !/CriOS|FxiOS|EdgiOS/.test(ua)
|
|
|
}
|
|
|
|
|
|
-function useInstallPrompt() {
|
|
|
- const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
|
|
|
- const [isIOS, setIsIOS] = useState(false)
|
|
|
- const [canShow, setCanShow] = useState(false)
|
|
|
+function dismissedUntil(): number {
|
|
|
+ try {
|
|
|
+ return Number(localStorage.getItem(DISMISS_KEY)) || 0
|
|
|
+ } catch {
|
|
|
+ return 0
|
|
|
+ }
|
|
|
+}
|
|
|
|
|
|
- useEffect(() => {
|
|
|
- if (isStandalone() || isDismissed()) return
|
|
|
- if (process.env.NODE_ENV === 'development') return
|
|
|
+export function useInstallPrompt() {
|
|
|
+ const [, rerender] = useReducer((n: number) => n + 1, 0)
|
|
|
+ // Nothing about the device is known during SSR or the first client render,
|
|
|
+ // so everything below stays false until after hydration.
|
|
|
+ const [ready, setReady] = useState(false)
|
|
|
|
|
|
- if (isIOSSafari()) {
|
|
|
- setIsIOS(true)
|
|
|
- setCanShow(true)
|
|
|
- return
|
|
|
+ useEffect(() => {
|
|
|
+ bindListeners()
|
|
|
+ subscribers.add(rerender)
|
|
|
+ setReady(true)
|
|
|
+ return () => {
|
|
|
+ subscribers.delete(rerender)
|
|
|
}
|
|
|
+ }, [])
|
|
|
|
|
|
- const handler = (e: Event) => {
|
|
|
- e.preventDefault()
|
|
|
- setDeferredPrompt(e as BeforeInstallPromptEvent)
|
|
|
- setCanShow(true)
|
|
|
- }
|
|
|
+ const standalone = ready && isStandalone()
|
|
|
+ const isIOS = ready && !standalone && isIOSSafari()
|
|
|
|
|
|
- window.addEventListener('beforeinstallprompt', handler)
|
|
|
- return () => window.removeEventListener('beforeinstallprompt', handler)
|
|
|
+ const install = useCallback(async () => {
|
|
|
+ if (!promptEvent) return false
|
|
|
+ await promptEvent.prompt()
|
|
|
+ const { outcome } = await promptEvent.userChoice
|
|
|
+ promptEvent = null
|
|
|
+ if (outcome === 'accepted') installed = true
|
|
|
+ emit()
|
|
|
+ return outcome === 'accepted'
|
|
|
}, [])
|
|
|
|
|
|
- const install = useCallback(async () => {
|
|
|
- if (!deferredPrompt) return
|
|
|
- await deferredPrompt.prompt()
|
|
|
- const { outcome } = await deferredPrompt.userChoice
|
|
|
- if (outcome === 'accepted') {
|
|
|
- setCanShow(false)
|
|
|
+ const dismiss = useCallback(() => {
|
|
|
+ try {
|
|
|
+ localStorage.setItem(DISMISS_KEY, String(Date.now()))
|
|
|
+ } catch {
|
|
|
+ // localStorage unavailable; the in-memory flag below still hides it
|
|
|
}
|
|
|
- setDeferredPrompt(null)
|
|
|
- }, [deferredPrompt])
|
|
|
+ emit()
|
|
|
+ }, [])
|
|
|
|
|
|
- const hide = useCallback(() => {
|
|
|
- dismiss()
|
|
|
- setCanShow(false)
|
|
|
+ const restore = useCallback(() => {
|
|
|
+ try {
|
|
|
+ localStorage.removeItem(DISMISS_KEY)
|
|
|
+ } catch {
|
|
|
+ // localStorage unavailable
|
|
|
+ }
|
|
|
+ emit()
|
|
|
}, [])
|
|
|
|
|
|
- return { canShow, isIOS, install, hide }
|
|
|
+ return {
|
|
|
+ /** The browser offered an install prompt, or this is iOS Safari. */
|
|
|
+ canInstall: ready && !standalone && !installed && (promptEvent !== null || isIOS),
|
|
|
+ /** Already running as an installed app, or installed during this session. */
|
|
|
+ installed: standalone || (ready && installed),
|
|
|
+ isIOS,
|
|
|
+ /** True while the user's "not now" is still in effect (30 days). */
|
|
|
+ dismissed: ready && Date.now() - dismissedUntil() < DISMISS_DURATION,
|
|
|
+ install,
|
|
|
+ dismiss,
|
|
|
+ restore,
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
export function InstallBanner() {
|
|
|
const t = useTranslations('common.shared')
|
|
|
- const { canShow, isIOS, install, hide } = useInstallPrompt()
|
|
|
+ const { canInstall, dismissed, isIOS, install, dismiss } = useInstallPrompt()
|
|
|
|
|
|
- if (!canShow) return null
|
|
|
+ // The banner is noise while developing; the settings page still works there.
|
|
|
+ if (process.env.NODE_ENV === 'development') return null
|
|
|
+ if (!canInstall || dismissed) return null
|
|
|
|
|
|
return (
|
|
|
<div className="fixed bottom-0 left-0 right-0 z-50 animate-in slide-in-from-bottom duration-300">
|
|
|
@@ -113,9 +155,7 @@ export function InstallBanner() {
|
|
|
<div className="min-w-0 flex-1">
|
|
|
<p className="text-sm font-medium">{t("installTorqvoice")}</p>
|
|
|
<p className="truncate text-xs text-muted-foreground">
|
|
|
- {isIOS
|
|
|
- ? 'Tap Share then "Add to Home Screen"'
|
|
|
- : 'Add to your home screen for quick access'}
|
|
|
+ {isIOS ? t("iosInstallHint") : t("installDescription")}
|
|
|
</p>
|
|
|
</div>
|
|
|
{!isIOS && (
|
|
|
@@ -124,7 +164,7 @@ export function InstallBanner() {
|
|
|
</Button>
|
|
|
)}
|
|
|
<button
|
|
|
- onClick={hide}
|
|
|
+ onClick={dismiss}
|
|
|
className="shrink-0 rounded-full p-1 text-muted-foreground hover:text-foreground"
|
|
|
aria-label={t("dismiss")}
|
|
|
>
|
|
|
@@ -138,16 +178,16 @@ export function InstallBanner() {
|
|
|
|
|
|
export function SidebarInstallButton() {
|
|
|
const t = useTranslations('common.shared')
|
|
|
- const { canShow, isIOS, install, hide } = useInstallPrompt()
|
|
|
+ const { canInstall, dismissed, isIOS, install, dismiss } = useInstallPrompt()
|
|
|
|
|
|
- if (!canShow) return null
|
|
|
+ if (!canInstall || dismissed) return null
|
|
|
|
|
|
return (
|
|
|
<SidebarGroup>
|
|
|
<SidebarMenu>
|
|
|
<SidebarMenuItem>
|
|
|
<SidebarMenuButton
|
|
|
- onClick={isIOS ? hide : install}
|
|
|
+ onClick={isIOS ? dismiss : install}
|
|
|
tooltip={isIOS ? t("iosInstallHint") : t("installApp")}
|
|
|
>
|
|
|
<Download className="size-4" />
|