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} + + )} + ) }