diff --git a/web/src/pwa/InstallHint.module.css b/web/src/pwa/InstallHint.module.css new file mode 100644 index 0000000..74e9fc2 --- /dev/null +++ b/web/src/pwa/InstallHint.module.css @@ -0,0 +1,18 @@ +.hint { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.icon { + color: var(--color-accent); +} + +.message { + flex: 1; + margin: 0; + font-family: var(--font-body); + font-size: 15px; + font-weight: 600; + line-height: 1.35; +} diff --git a/web/src/pwa/InstallHint.test.tsx b/web/src/pwa/InstallHint.test.tsx new file mode 100644 index 0000000..628ad96 --- /dev/null +++ b/web/src/pwa/InstallHint.test.tsx @@ -0,0 +1,146 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DISMISSED_KEY, InstallHint } from './InstallHint.tsx' + +const IPHONE_SAFARI = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1' +const IPAD_SAFARI = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15' +const MAC_SAFARI = IPAD_SAFARI +const IPHONE_CHROME = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/138.0.7204.119 Mobile/15E148 Safari/604.1' +const IPHONE_FIREFOX = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/141.0 Mobile/15E148 Safari/605.1.15' +const IPHONE_EDGE = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 EdgiOS/138.3351.83 Mobile/15E148 Safari/605.1.15' +const IPHONE_OPERA = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1 OPT/5.4.0' +const IPHONE_MQQBROWSER = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 MQQBrowser/11.3.5 Mobile/15E148 Safari/604.1' +const ANDROID_CHROME = + 'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36' +const DESKTOP_CHROME = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36' + +const copy = 'Tap Share, then Add to Home Screen to get notifications' + +type Device = { + userAgent: string + maxTouchPoints?: number + // display-mode: standalone + displayStandalone?: boolean + // navigator.standalone (Safari only; undefined elsewhere) + navigatorStandalone?: boolean +} + +// mockDevice pretends to be a browser with the given user agent and +// display mode; jsdom has no matchMedia, navigator.maxTouchPoints or +// navigator.standalone. +function mockDevice({ userAgent, maxTouchPoints = 0, displayStandalone = false, navigatorStandalone }: Device) { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(userAgent) + Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: maxTouchPoints }) + Object.defineProperty(navigator, 'standalone', { configurable: true, value: navigatorStandalone }) + vi.stubGlobal('matchMedia', (query: string) => { + if (query !== '(display-mode: standalone)') throw new Error(`unexpected media query ${query}`) + return { matches: displayStandalone, media: query } as MediaQueryList + }) +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + Reflect.deleteProperty(navigator, 'maxTouchPoints') + Reflect.deleteProperty(navigator, 'standalone') + localStorage.clear() +}) + +describe('InstallHint', () => { + it('shows on iPhone Safari in a browser tab', () => { + mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false }) + render() + expect(screen.getByRole('note')).toHaveTextContent(copy) + }) + + it('shows on iPadOS Safari, which reports itself as a Mac with a touch screen', () => { + mockDevice({ userAgent: IPAD_SAFARI, maxTouchPoints: 5, navigatorStandalone: false }) + render() + expect(screen.getByRole('note')).toHaveTextContent(copy) + }) + + it('is hidden when navigator.standalone is true', () => { + mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: true }) + render() + expect(screen.queryByRole('note')).toBeNull() + }) + + it('is hidden when display-mode is standalone', () => { + mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, displayStandalone: true }) + render() + expect(screen.queryByRole('note')).toBeNull() + }) + + it.each([ + ['Android Chrome', { userAgent: ANDROID_CHROME, maxTouchPoints: 5 }], + ['desktop Chrome', { userAgent: DESKTOP_CHROME }], + ['desktop Safari on a Mac', { userAgent: MAC_SAFARI }], + ['Chrome on iPhone', { userAgent: IPHONE_CHROME, maxTouchPoints: 5 }], + ['Firefox on iPhone', { userAgent: IPHONE_FIREFOX, maxTouchPoints: 5 }], + ['Edge on iPhone', { userAgent: IPHONE_EDGE, maxTouchPoints: 5 }], + ['Opera on iPhone', { userAgent: IPHONE_OPERA, maxTouchPoints: 5 }], + ['MQQBrowser on iPhone', { userAgent: IPHONE_MQQBROWSER, maxTouchPoints: 5 }], + ])('is hidden on %s', (_name, device: Device) => { + mockDevice(device) + render() + expect(screen.queryByRole('note')).toBeNull() + }) + + it('stays dismissed on this device', () => { + mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false }) + const first = render() + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + expect(screen.queryByRole('note')).toBeNull() + expect(localStorage.getItem(DISMISSED_KEY)).toBe('1') + + first.unmount() + render() + expect(screen.queryByRole('note')).toBeNull() + }) + + it('stays up and reports the error when the dismissal cannot be saved', () => { + mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false }) + const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('quota exceeded', 'QuotaExceededError') + }) + + render() + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + expect(screen.getByRole('note')).toHaveTextContent(copy) + expect(screen.getByRole('alert')).toHaveTextContent( + 'Could not remember on this device that this hint was dismissed: QuotaExceededError: quota exceeded', + ) + + setItem.mockRestore() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(screen.queryByRole('note')).toBeNull() + expect(screen.queryByRole('alert')).toBeNull() + expect(localStorage.getItem(DISMISSED_KEY)).toBe('1') + }) + + it('shows and reports the error when storage cannot be read', () => { + mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false }) + const getItem = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new DOMException('storage blocked', 'SecurityError') + }) + + render() + expect(screen.getByRole('note')).toHaveTextContent(copy) + expect(screen.getByRole('alert')).toHaveTextContent( + 'Could not check whether this hint was dismissed on this device: SecurityError: storage blocked', + ) + + getItem.mockRestore() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(screen.getByRole('note')).toHaveTextContent(copy) + expect(screen.queryByRole('alert')).toBeNull() + }) +}) diff --git a/web/src/pwa/InstallHint.tsx b/web/src/pwa/InstallHint.tsx new file mode 100644 index 0000000..92b30d2 --- /dev/null +++ b/web/src/pwa/InstallHint.tsx @@ -0,0 +1,97 @@ +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/ Mobile/ +// Safari/" (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): 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(() => + eligible ? readDismissed() : { dismissed: false, failure: null }, + ) + if (!eligible || state.dismissed) return null + + const { failure } = state + return ( + <> + + +

Tap Share, then Add to Home Screen to get notifications

+ setState(writeDismissed())}> + + +
+ {failure && ( + setState(failure.action === 'read' ? readDismissed() : writeDismissed())}> + {failureText[failure.action]}: {failure.message} + + )} + + ) +}