iOS install hint component (#56)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #56.
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,146 @@
|
|||||||
|
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 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 =
|
||||||
|
'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 }],
|
||||||
|
['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 />)
|
||||||
|
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('stays up and reports the error when the dismissal cannot be saved', () => {
|
||||||
|
mockDevice({ userAgent: IPHONE_SAFARI, maxTouchPoints: 5, navigatorStandalone: false })
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<InstallHint />)
|
||||||
|
expect(screen.getByRole('note')).toHaveTextContent(copy)
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
// DISMISSED_KEY is the localStorage key remembering, on this device, that the
|
||||||
|
// household dismissed the hint.
|
||||||
|
export const DISMISSED_KEY = 'mealquest.installHint.dismissed'
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 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_TAIL.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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 using it throws. The failure is reported, never treated as a
|
||||||
|
// remembered dismissal.
|
||||||
|
function readDismissed(): HintState {
|
||||||
|
try {
|
||||||
|
return { dismissed: window.localStorage.getItem(DISMISSED_KEY) === '1', failure: null }
|
||||||
|
} catch (err) {
|
||||||
|
return { dismissed: false, failure: { action: 'read', message: String(err) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeDismissed(): HintState {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(DISMISSED_KEY, '1')
|
||||||
|
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. 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 [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={() => 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