From ceccbda61d3350c60089731109ba0ab4491e47c3 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Mon, 5 Oct 2026 08:16:11 -0400 Subject: [PATCH] Add iOS install hint component InstallHint shows "Tap Share, then Add to Home Screen to get notifications" only in iOS and iPadOS Safari browser tabs (not when display-mode is standalone or navigator.standalone is true), since iOS delivers Web Push only to installed apps. Dismissing it is remembered per device in localStorage; when storage is unavailable the hint still shows and dismisses for the page's lifetime. Exported from src/pwa/InstallHint.tsx for onboarding step 3 and the Notifications screen. Maestro task: ad6b3d80-9f41-40ac-8e2a-f786e1fa9777 Co-Authored-By: Claude Opus 5.5 --- web/src/pwa/InstallHint.module.css | 18 +++++ web/src/pwa/InstallHint.test.tsx | 110 +++++++++++++++++++++++++++++ web/src/pwa/InstallHint.tsx | 74 +++++++++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 web/src/pwa/InstallHint.module.css create mode 100644 web/src/pwa/InstallHint.test.tsx create mode 100644 web/src/pwa/InstallHint.tsx 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..bb5b2c1 --- /dev/null +++ b/web/src/pwa/InstallHint.test.tsx @@ -0,0 +1,110 @@ +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 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 }], + ])('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('shows and can be dismissed when storage is unavailable', () => { + mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false }) + const blocked = () => { + throw new DOMException('storage blocked', 'SecurityError') + } + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(blocked) + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(blocked) + + render() + expect(screen.getByRole('note')).toHaveTextContent(copy) + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + expect(screen.queryByRole('note')).toBeNull() + }) +}) diff --git a/web/src/pwa/InstallHint.tsx b/web/src/pwa/InstallHint.tsx new file mode 100644 index 0000000..f069f6a --- /dev/null +++ b/web/src/pwa/InstallHint.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react' +import { Share, X } from 'lucide-react' +import { Card } from '../ui/Card.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' + +// iOS browsers other than Safari name themselves in the user agent; Safari +// does not, and in-app web views leave out the Safari/ token. +const OTHER_IOS_BROWSERS = /CriOS|FxiOS|EdgiOS|OPiOS|OPT\/|GSA\/|YaBrowser|DuckDuckGo|Brave/ + +// 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\//.test(ua) && !OTHER_IOS_BROWSERS.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 +} + +// Storage can be unavailable (blocked by the browser or privacy settings), in +// which case reading it throws. The hint then counts as not dismissed. +function readDismissed(): boolean { + try { + return window.localStorage.getItem(DISMISSED_KEY) === '1' + } catch { + return false + } +} + +// Without storage the dismissal lasts until the page is reloaded. +function writeDismissed() { + try { + window.localStorage.setItem(DISMISSED_KEY, '1') + } catch { + // Nothing to remember it in; the component state still hides the hint. + } +} + +// 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. Used by +// onboarding step 3 and the Notifications screen. +export function InstallHint() { + const [visible, setVisible] = useState(() => isIosSafari(navigator) && !isStandalone() && !readDismissed()) + if (!visible) return null + + function dismiss() { + writeDismissed() + setVisible(false) + } + + return ( + + +

Tap Share, then Add to Home Screen to get notifications

+ + + +
+ ) +}