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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(<InstallHint />)
|
||||||
|
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(<InstallHint />)
|
||||||
|
expect(screen.getByRole('note')).toHaveTextContent(copy)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is hidden when navigator.standalone is true', () => {
|
||||||
|
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: true })
|
||||||
|
render(<InstallHint />)
|
||||||
|
expect(screen.queryByRole('note')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is hidden when display-mode is standalone', () => {
|
||||||
|
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, displayStandalone: true })
|
||||||
|
render(<InstallHint />)
|
||||||
|
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(<InstallHint />)
|
||||||
|
expect(screen.queryByRole('note')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays dismissed on this device', () => {
|
||||||
|
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
|
||||||
|
const first = render(<InstallHint />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
|
||||||
|
expect(screen.queryByRole('note')).toBeNull()
|
||||||
|
expect(localStorage.getItem(DISMISSED_KEY)).toBe('1')
|
||||||
|
|
||||||
|
first.unmount()
|
||||||
|
render(<InstallHint />)
|
||||||
|
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(<InstallHint />)
|
||||||
|
expect(screen.getByRole('note')).toHaveTextContent(copy)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
|
||||||
|
expect(screen.queryByRole('note')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user