iOS install hint component #56
@@ -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(<InstallHint />)
|
||||
@@ -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(<InstallHint />)
|
||||
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(<InstallHint />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
+52
-29
@@ -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/<v> Mobile/<build>
|
||||
// Safari/<v>" (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<Navigator, 'userAgent' | 'maxTouchPoints'>): 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<HintState>(() =>
|
||||
eligible ? readDismissed() : { dismissed: false, failure: null },
|
||||
)
|
||||
if (!eligible || state.dismissed) return null
|
||||
|
||||
const { failure } = state
|
||||
return (
|
||||
<Card tone="muted" role="note" className={styles.hint}>
|
||||
<Icon icon={Share} className={styles.icon} />
|
||||
<p className={styles.message}>Tap Share, then Add to Home Screen to get notifications</p>
|
||||
<IconButton label="Dismiss" onClick={dismiss}>
|
||||
<Icon icon={X} />
|
||||
</IconButton>
|
||||
</Card>
|
||||
<>
|
||||
<Card tone="muted" role="note" className={styles.hint}>
|
||||
<Icon icon={Share} className={styles.icon} />
|
||||
<p className={styles.message}>Tap Share, then Add to Home Screen to get notifications</p>
|
||||
<IconButton label="Dismiss" onClick={() => setState(writeDismissed())}>
|
||||
<Icon icon={X} />
|
||||
</IconButton>
|
||||
</Card>
|
||||
{failure && (
|
||||
<ErrorBanner onRetry={() => setState(failure.action === 'read' ? readDismissed() : writeDismissed())}>
|
||||
{failureText[failure.action]}: {failure.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user