iOS install hint component #56
@@ -9,6 +9,14 @@ const IPAD_SAFARI =
|
|||||||
const MAC_SAFARI = IPAD_SAFARI
|
const MAC_SAFARI = IPAD_SAFARI
|
||||||
const IPHONE_CHROME =
|
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'
|
'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 =
|
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'
|
'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 =
|
const DESKTOP_CHROME =
|
||||||
@@ -76,6 +84,10 @@ describe('InstallHint', () => {
|
|||||||
['desktop Chrome', { userAgent: DESKTOP_CHROME }],
|
['desktop Chrome', { userAgent: DESKTOP_CHROME }],
|
||||||
['desktop Safari on a Mac', { userAgent: MAC_SAFARI }],
|
['desktop Safari on a Mac', { userAgent: MAC_SAFARI }],
|
||||||
['Chrome on iPhone', { userAgent: IPHONE_CHROME, maxTouchPoints: 5 }],
|
['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) => {
|
])('is hidden on %s', (_name, device: Device) => {
|
||||||
mockDevice(device)
|
mockDevice(device)
|
||||||
render(<InstallHint />)
|
render(<InstallHint />)
|
||||||
@@ -94,17 +106,41 @@ describe('InstallHint', () => {
|
|||||||
expect(screen.queryByRole('note')).toBeNull()
|
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 })
|
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')
|
throw new DOMException('storage blocked', 'SecurityError')
|
||||||
}
|
})
|
||||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(blocked)
|
|
||||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(blocked)
|
|
||||||
|
|
||||||
render(<InstallHint />)
|
render(<InstallHint />)
|
||||||
expect(screen.getByRole('note')).toHaveTextContent(copy)
|
expect(screen.getByRole('note')).toHaveTextContent(copy)
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
|
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||||
expect(screen.queryByRole('note')).toBeNull()
|
'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 { useState } from 'react'
|
||||||
import { Share, X } from 'lucide-react'
|
import { Share, X } from 'lucide-react'
|
||||||
import { Card } from '../ui/Card.tsx'
|
import { Card } from '../ui/Card.tsx'
|
||||||
|
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||||
import { Icon } from '../ui/Icon.tsx'
|
import { Icon } from '../ui/Icon.tsx'
|
||||||
import { IconButton } from '../ui/IconButton.tsx'
|
import { IconButton } from '../ui/IconButton.tsx'
|
||||||
import styles from './InstallHint.module.css'
|
import styles from './InstallHint.module.css'
|
||||||
@@ -9,9 +10,11 @@ import styles from './InstallHint.module.css'
|
|||||||
// household dismissed the hint.
|
// household dismissed the hint.
|
||||||
export const DISMISSED_KEY = 'mealquest.installHint.dismissed'
|
export const DISMISSED_KEY = 'mealquest.installHint.dismissed'
|
||||||
|
|
||||||
// iOS browsers other than Safari name themselves in the user agent; Safari
|
// Safari's user agent ends "(KHTML, like Gecko) Version/<v> Mobile/<build>
|
||||||
// does not, and in-app web views leave out the Safari/ token.
|
// Safari/<v>" (iPadOS leaves out Mobile/). Every other iOS browser and in-app
|
||||||
const OTHER_IOS_BROWSERS = /CriOS|FxiOS|EdgiOS|OPiOS|OPT\/|GSA\/|YaBrowser|DuckDuckGo|Brave/
|
// 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
|
// 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
|
// 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 {
|
export function isIosSafari(nav: Pick<Navigator, 'userAgent' | 'maxTouchPoints'>): boolean {
|
||||||
const ua = nav.userAgent
|
const ua = nav.userAgent
|
||||||
const ios = /iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && nav.maxTouchPoints > 1)
|
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.
|
// 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
|
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
|
// Storage can be unavailable (blocked by the browser or privacy settings), in
|
||||||
// which case reading it throws. The hint then counts as not dismissed.
|
// which case using it throws. The failure is reported, never treated as a
|
||||||
function readDismissed(): boolean {
|
// remembered dismissal.
|
||||||
|
function readDismissed(): HintState {
|
||||||
try {
|
try {
|
||||||
return window.localStorage.getItem(DISMISSED_KEY) === '1'
|
return { dismissed: window.localStorage.getItem(DISMISSED_KEY) === '1', failure: null }
|
||||||
} catch {
|
} catch (err) {
|
||||||
return false
|
return { dismissed: false, failure: { action: 'read', message: String(err) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Without storage the dismissal lasts until the page is reloaded.
|
function writeDismissed(): HintState {
|
||||||
function writeDismissed() {
|
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(DISMISSED_KEY, '1')
|
window.localStorage.setItem(DISMISSED_KEY, '1')
|
||||||
} catch {
|
return { dismissed: true, failure: null }
|
||||||
// Nothing to remember it in; the component state still hides the hint.
|
} 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
|
// 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
|
// 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
|
// tab, never once installed, and stays dismissed on this device. If storage
|
||||||
// onboarding step 3 and the Notifications screen.
|
// fails, the hint stays up with an error saying so. Used by onboarding step 3
|
||||||
|
// and the Notifications screen.
|
||||||
export function InstallHint() {
|
export function InstallHint() {
|
||||||
const [visible, setVisible] = useState(() => isIosSafari(navigator) && !isStandalone() && !readDismissed())
|
const [eligible] = useState(() => isIosSafari(navigator) && !isStandalone())
|
||||||
if (!visible) return null
|
const [state, setState] = useState<HintState>(() =>
|
||||||
|
eligible ? readDismissed() : { dismissed: false, failure: null },
|
||||||
function dismiss() {
|
)
|
||||||
writeDismissed()
|
if (!eligible || state.dismissed) return null
|
||||||
setVisible(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const { failure } = state
|
||||||
return (
|
return (
|
||||||
<Card tone="muted" role="note" className={styles.hint}>
|
<>
|
||||||
<Icon icon={Share} className={styles.icon} />
|
<Card tone="muted" role="note" className={styles.hint}>
|
||||||
<p className={styles.message}>Tap Share, then Add to Home Screen to get notifications</p>
|
<Icon icon={Share} className={styles.icon} />
|
||||||
<IconButton label="Dismiss" onClick={dismiss}>
|
<p className={styles.message}>Tap Share, then Add to Home Screen to get notifications</p>
|
||||||
<Icon icon={X} />
|
<IconButton label="Dismiss" onClick={() => setState(writeDismissed())}>
|
||||||
</IconButton>
|
<Icon icon={X} />
|
||||||
</Card>
|
</IconButton>
|
||||||
|
</Card>
|
||||||
|
{failure && (
|
||||||
|
<ErrorBanner onRetry={() => setState(failure.action === 'read' ? readDismissed() : writeDismissed())}>
|
||||||
|
{failureText[failure.action]}: {failure.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user