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