From ceccbda61d3350c60089731109ba0ab4491e47c3 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Mon, 5 Oct 2026 08:16:11 -0400 Subject: [PATCH 1/2] 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

+ + + +
+ ) +} -- 2.55.0 From 695003c5f72b88d5f3be497020f6f5df2f9a70a7 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Mon, 5 Oct 2026 08:25:06 -0400 Subject: [PATCH 2/2] Tighten iOS Safari detection and report install hint storage failures Detect Safari by its exact user agent tail instead of a "Safari/" token plus an exclusion list, so third-party iOS browsers such as MQQBrowser no longer get the hint. When storage cannot be read or written, keep the hint visible and show an error banner with Retry instead of treating the dismissal as saved. Co-Authored-By: Claude Opus 5.5 --- web/src/pwa/InstallHint.test.tsx | 50 +++++++++++++++++--- web/src/pwa/InstallHint.tsx | 81 ++++++++++++++++++++------------ 2 files changed, 95 insertions(+), 36 deletions(-) diff --git a/web/src/pwa/InstallHint.test.tsx b/web/src/pwa/InstallHint.test.tsx index bb5b2c1..628ad96 100644 --- a/web/src/pwa/InstallHint.test.tsx +++ b/web/src/pwa/InstallHint.test.tsx @@ -9,6 +9,14 @@ const IPAD_SAFARI = 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 = @@ -76,6 +84,10 @@ describe('InstallHint', () => { ['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() @@ -94,17 +106,41 @@ describe('InstallHint', () => { expect(screen.queryByRole('note')).toBeNull() }) - it('shows and can be dismissed when storage is unavailable', () => { + it('stays up and reports the error when the dismissal cannot be saved', () => { mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false }) - const blocked = () => { + 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') - } - 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() + 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 index f069f6a..92b30d2 100644 --- a/web/src/pwa/InstallHint.tsx +++ b/web/src/pwa/InstallHint.tsx @@ -1,6 +1,7 @@ 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' @@ -9,9 +10,11 @@ import styles from './InstallHint.module.css' // 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/ +// 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 @@ -19,7 +22,7 @@ const OTHER_IOS_BROWSERS = /CriOS|FxiOS|EdgiOS|OPiOS|OPT\/|GSA\/|YaBrowser|DuckD 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) + return ios && SAFARI_TAIL.test(ua) } // isStandalone reports whether the app is running from the Home Screen. @@ -30,45 +33,65 @@ export function isStandalone(): boolean { 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 reading it throws. The hint then counts as not dismissed. -function readDismissed(): boolean { +// which case using it throws. The failure is reported, never treated as a +// remembered dismissal. +function readDismissed(): HintState { try { - return window.localStorage.getItem(DISMISSED_KEY) === '1' - } catch { - return false + return { dismissed: window.localStorage.getItem(DISMISSED_KEY) === '1', failure: null } + } catch (err) { + return { dismissed: false, failure: { action: 'read', message: String(err) } } } } -// Without storage the dismissal lasts until the page is reloaded. -function writeDismissed() { +function writeDismissed(): HintState { try { window.localStorage.setItem(DISMISSED_KEY, '1') - } catch { - // Nothing to remember it in; the component state still hides the hint. + 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. Used by -// onboarding step 3 and the Notifications screen. +// 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 [visible, setVisible] = useState(() => isIosSafari(navigator) && !isStandalone() && !readDismissed()) - if (!visible) return null - - function dismiss() { - writeDismissed() - setVisible(false) - } + 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

- - - -
+ <> + + +

Tap Share, then Add to Home Screen to get notifications

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