Files
MealQuest/web/src/pwa/InstallHint.tsx
T
dev_agent 81c5085f9a
CI / ci (push) Successful in 1m40s
Feature 06: PWA shell and offline (#58)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-05 15:22:37 -04:00

98 lines
4.0 KiB
TypeScript

import { useState } from 'react'
import { Share, X } from 'lucide-react'
import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import styles from './InstallHint.module.css'
// DISMISSED_KEY is the localStorage key remembering, on this device, that the
// household dismissed the hint.
export const DISMISSED_KEY = 'mealquest.installHint.dismissed'
// Safari's user agent ends "(KHTML, like Gecko) Version/<v> Mobile/<build>
// Safari/<v>" (iPadOS leaves out Mobile/). Every other iOS browser and in-app
// web view adds its own token (CriOS, FxiOS, EdgiOS, OPiOS, MQQBrowser, GSA,
// ...) or drops Version/ or Safari/, so only this exact tail is Safari.
const SAFARI_TAIL = /\(KHTML, like Gecko\) Version\/[\d.]+ (?:Mobile\/\w+ )?Safari\/[\d.]+$/
// isIosSafari reports whether the page is open in Safari on iPhone, iPod or
// iPad. iPadOS 13+ asks for the desktop site and reports itself as a Mac, so
// a "Macintosh" with a touch screen is an iPad.
export function isIosSafari(nav: Pick<Navigator, 'userAgent' | 'maxTouchPoints'>): boolean {
const ua = nav.userAgent
const ios = /iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && nav.maxTouchPoints > 1)
return ios && SAFARI_TAIL.test(ua)
}
// isStandalone reports whether the app is running from the Home Screen.
// Safari sets navigator.standalone; other browsers answer the display-mode
// media query.
export function isStandalone(): boolean {
const legacy = (navigator as Navigator & { standalone?: boolean }).standalone
return legacy === true || window.matchMedia('(display-mode: standalone)').matches
}
// HintState is whether the household dismissed the hint on this device, and
// the storage failure, if any, that stopped us reading or saving that.
type HintState = {
dismissed: boolean
failure: { action: 'read' | 'write'; message: string } | null
}
// Storage can be unavailable (blocked by the browser or privacy settings), in
// which case using it throws. The failure is reported, never treated as a
// remembered dismissal.
function readDismissed(): HintState {
try {
return { dismissed: window.localStorage.getItem(DISMISSED_KEY) === '1', failure: null }
} catch (err) {
return { dismissed: false, failure: { action: 'read', message: String(err) } }
}
}
function writeDismissed(): HintState {
try {
window.localStorage.setItem(DISMISSED_KEY, '1')
return { dismissed: true, failure: null }
} catch (err) {
return { dismissed: false, failure: { action: 'write', message: String(err) } }
}
}
const failureText = {
read: 'Could not check whether this hint was dismissed on this device',
write: 'Could not remember on this device that this hint was dismissed',
}
// InstallHint tells iOS and iPadOS Safari users how to install the app, which
// iOS requires before it delivers Web Push. It shows only in a Safari browser
// tab, never once installed, and stays dismissed on this device. If storage
// fails, the hint stays up with an error saying so. Used by onboarding step 3
// and the Notifications screen.
export function InstallHint() {
const [eligible] = useState(() => isIosSafari(navigator) && !isStandalone())
const [state, setState] = useState<HintState>(() =>
eligible ? readDismissed() : { dismissed: false, failure: null },
)
if (!eligible || state.dismissed) return null
const { failure } = state
return (
<>
<Card tone="muted" role="note" className={styles.hint}>
<Icon icon={Share} className={styles.icon} />
<p className={styles.message}>Tap Share, then Add to Home Screen to get notifications</p>
<IconButton label="Dismiss" onClick={() => setState(writeDismissed())}>
<Icon icon={X} />
</IconButton>
</Card>
{failure && (
<ErrorBanner onRetry={() => setState(failure.action === 'read' ? readDismissed() : writeDismissed())}>
{failureText[failure.action]}: {failure.message}
</ErrorBanner>
)}
</>
)
}