feat(03-07): InstallPrompt — iOS walkthrough banner + Android beforeinstallprompt
- Implement isIOSSafariNonStandalone(): iOS UA + navigator.standalone detection - Implement useAndroidInstallPrompt(): captures beforeinstallprompt, exposes canInstall/triggerInstall - InstallPrompt renders nothing when display-mode:standalone or navigator.standalone (already installed) - iOS branch: dismissible banner with 'Install FamilySync' heading, 'How to install' link opens 5-step walkthrough sheet (exact UI-SPEC copy, orange #F5A623 step number annotation) - Android branch: banner with 'Install' button shown only when canInstall=true - localStorage.installPromptDismissed persists banner dismissal - role="banner", dismiss aria-label="Dismiss install prompt", 44px touch targets - Mount <InstallPrompt /> in CalendarShell (phone: below AppNav; desktop: top of content area) - InstallPrompt.test.tsx GREEN (5 behavior tests); full PWA suite 44 tests green; tsc clean
This commit is contained in:
@@ -48,6 +48,7 @@ import { EventDetailPopover } from './EventDetailPopover.js'
|
|||||||
import { AppNav } from './AppNav.js'
|
import { AppNav } from './AppNav.js'
|
||||||
import { ColorLegend } from './ColorLegend.js'
|
import { ColorLegend } from './ColorLegend.js'
|
||||||
import { SkeletonCalendar } from './SkeletonCalendar.js'
|
import { SkeletonCalendar } from './SkeletonCalendar.js'
|
||||||
|
import { InstallPrompt } from './InstallPrompt.js'
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -314,6 +315,7 @@ export function CalendarShell() {
|
|||||||
currentUserColor={meQuery.data?.user.color}
|
currentUserColor={meQuery.data?.user.color}
|
||||||
currentUserName={meQuery.data?.user.displayName ?? undefined}
|
currentUserName={meQuery.data?.user.displayName ?? undefined}
|
||||||
/>
|
/>
|
||||||
|
<InstallPrompt />
|
||||||
<CalendarContent />
|
<CalendarContent />
|
||||||
<EventDetailPopover />
|
<EventDetailPopover />
|
||||||
</div>
|
</div>
|
||||||
@@ -341,7 +343,10 @@ export function CalendarShell() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
<InstallPrompt />
|
||||||
<CalendarContent />
|
<CalendarContent />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* EventDetailPopover — standalone mode driven by Zustand openEventId */}
|
{/* EventDetailPopover — standalone mode driven by Zustand openEventId */}
|
||||||
<EventDetailPopover />
|
<EventDetailPopover />
|
||||||
|
|||||||
@@ -0,0 +1,476 @@
|
|||||||
|
/**
|
||||||
|
* InstallPrompt — PWA install guidance for iOS and Android (PWA-01, PWA-02).
|
||||||
|
*
|
||||||
|
* Behaviors:
|
||||||
|
* - Renders nothing when the app is already installed (display-mode: standalone
|
||||||
|
* or navigator.standalone — iOS PWA).
|
||||||
|
* - iOS Safari non-standalone: shows a dismissible banner with a "How to install"
|
||||||
|
* link that opens a full-screen 5-step walkthrough sheet.
|
||||||
|
* - Android (Chrome/Edge): shows a banner with an "Install" button only when the
|
||||||
|
* `beforeinstallprompt` event has fired and canInstall is true.
|
||||||
|
* - Neither surface shows when the app is already installed.
|
||||||
|
*
|
||||||
|
* localStorage key: `installPromptDismissed` — persists banner dismissal.
|
||||||
|
*
|
||||||
|
* Security: T-02d-01 — all text is plain-text JSX children; no dangerouslySetInnerHTML.
|
||||||
|
* Accessibility: role="banner", dismiss aria-label="Dismiss install prompt", 44px touch targets.
|
||||||
|
*
|
||||||
|
* CLAUDE.md PWA constraints:
|
||||||
|
* - iOS 16.4+ minimum; Home-Screen install required for push.
|
||||||
|
* - pushManager.subscribe() must be in a tap handler (enforced in Phase 5).
|
||||||
|
* - EU DMA iOS 17.4+ caveat: PWA may open in Safari tabs; install guide addresses this.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Smartphone, X } from 'lucide-react'
|
||||||
|
|
||||||
|
// ── iOS detection ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the browser is iOS Safari in non-standalone (browser-tab) mode.
|
||||||
|
* Returns false when already installed (navigator.standalone === true) or on non-iOS.
|
||||||
|
*
|
||||||
|
* Detection logic:
|
||||||
|
* 1. Check for iPad/iPhone/iPod in UA (standard iOS Safari UA; excludes Android Chrome
|
||||||
|
* which also contains "Safari").
|
||||||
|
* 2. Exclude IE/Edge for IE (MSStream property) — belt-and-suspenders.
|
||||||
|
* 3. Check navigator.standalone is NOT true (standalone = already installed).
|
||||||
|
*/
|
||||||
|
export function isIOSSafariNonStandalone(): boolean {
|
||||||
|
const ua = navigator.userAgent
|
||||||
|
const isIOS =
|
||||||
|
/iPad|iPhone|iPod/.test(ua) &&
|
||||||
|
!(window as unknown as { MSStream?: unknown }).MSStream
|
||||||
|
const isStandalone = (navigator as unknown as { standalone?: boolean }).standalone === true
|
||||||
|
return isIOS && !isStandalone
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Installed state check ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the app is running in standalone mode (already installed).
|
||||||
|
* Covers both Android (display-mode media query) and iOS (navigator.standalone).
|
||||||
|
*/
|
||||||
|
function isInstalled(): boolean {
|
||||||
|
return (
|
||||||
|
window.matchMedia('(display-mode: standalone)').matches ||
|
||||||
|
(navigator as unknown as { standalone?: boolean }).standalone === true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Android beforeinstallprompt hook ─────────────────────────────────────
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt(): Promise<void>
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures the browser's `beforeinstallprompt` event and exposes it as a hook.
|
||||||
|
* Only fires on Chrome/Edge on Android — never on iOS.
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* canInstall: true when the event has been captured and install is available
|
||||||
|
* triggerInstall: function that calls prompt() on the deferred event
|
||||||
|
*/
|
||||||
|
export function useAndroidInstallPrompt() {
|
||||||
|
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDeferredPrompt(e as BeforeInstallPromptEvent)
|
||||||
|
}
|
||||||
|
const installedHandler = () => setDeferredPrompt(null)
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', handler)
|
||||||
|
window.addEventListener('appinstalled', installedHandler)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeinstallprompt', handler)
|
||||||
|
window.removeEventListener('appinstalled', installedHandler)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const triggerInstall = async () => {
|
||||||
|
if (!deferredPrompt) return
|
||||||
|
await deferredPrompt.prompt()
|
||||||
|
const { outcome } = await deferredPrompt.userChoice
|
||||||
|
if (outcome === 'accepted') {
|
||||||
|
setDeferredPrompt(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { canInstall: deferredPrompt !== null, triggerInstall }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── iOS Walkthrough Sheet ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const IOS_STEPS = [
|
||||||
|
'Open FamilySync in Safari',
|
||||||
|
'Tap the Share button',
|
||||||
|
"Scroll down and tap 'Add to Home Screen'",
|
||||||
|
"Tap 'Add' in the top right",
|
||||||
|
'Open FamilySync from your Home Screen — it opens without the browser bar',
|
||||||
|
]
|
||||||
|
|
||||||
|
interface WalkthroughSheetProps {
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function WalkthroughSheet({ onClose }: WalkthroughSheetProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Add to Home Screen walkthrough"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'var(--color-overlay, rgba(0,0,0,0.5))',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
// Close on backdrop click
|
||||||
|
if (e.target === e.currentTarget) onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-surface, #ffffff)',
|
||||||
|
borderRadius: '12px 12px 0 0',
|
||||||
|
padding: 'var(--space-6, 24px)',
|
||||||
|
maxHeight: '90dvh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 'var(--space-4, 16px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: 'var(--text-heading-size, 18px)',
|
||||||
|
fontWeight: 'var(--text-heading-weight, 600)',
|
||||||
|
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
||||||
|
color: 'var(--color-text-primary, #111318)',
|
||||||
|
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add to Home Screen
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close walkthrough"
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
minWidth: '44px',
|
||||||
|
minHeight: '44px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--color-text-secondary, #5c6472)',
|
||||||
|
borderRadius: 'var(--space-1, 4px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={20} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Steps */}
|
||||||
|
<ol
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
listStyle: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 'var(--space-4, 16px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{IOS_STEPS.map((step, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 'var(--space-3, 12px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Step number with accent color annotation (#F5A623 — --color-member-2) */}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#F5A623',
|
||||||
|
color: '#ffffff',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-body-size, 15px)',
|
||||||
|
lineHeight: 'var(--text-body-line-height, 1.5)',
|
||||||
|
color: 'var(--color-text-primary, #111318)',
|
||||||
|
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
|
||||||
|
paddingTop: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{step}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{/* Done button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-text-primary, #111318)',
|
||||||
|
color: '#ffffff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--space-1, 4px)',
|
||||||
|
minHeight: '44px',
|
||||||
|
padding: '0 var(--space-4, 16px)',
|
||||||
|
fontSize: 'var(--text-label-size, 13px)',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── InstallPrompt ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InstallPrompt renders the appropriate install surface based on platform:
|
||||||
|
* - iOS Safari non-standalone: dismissible banner + optional walkthrough sheet
|
||||||
|
* - Android (beforeinstallprompt available): install banner with native prompt button
|
||||||
|
* - Already installed / desktop: nothing
|
||||||
|
*
|
||||||
|
* Mount this at the top level of CalendarShell, below the nav bar.
|
||||||
|
*/
|
||||||
|
export function InstallPrompt() {
|
||||||
|
const [dismissed, setDismissed] = useState<boolean>(
|
||||||
|
() => localStorage.getItem('installPromptDismissed') === '1',
|
||||||
|
)
|
||||||
|
const [walkthroughOpen, setWalkthroughOpen] = useState(false)
|
||||||
|
const { canInstall, triggerInstall } = useAndroidInstallPrompt()
|
||||||
|
|
||||||
|
// Re-check isInstalled on mount — matchMedia is only available in the browser
|
||||||
|
const [installed, setInstalled] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
setInstalled(isInstalled())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Nothing to show when already installed
|
||||||
|
if (installed) return null
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
localStorage.setItem('installPromptDismissed', '1')
|
||||||
|
setDismissed(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── iOS banner ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (isIOSSafariNonStandalone() && !dismissed) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
role="banner"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-3, 12px)',
|
||||||
|
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||||
|
background: 'var(--color-surface-raised, #ffffff)',
|
||||||
|
borderBottom: '1px solid var(--color-border, #e2e4e9)',
|
||||||
|
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<Smartphone
|
||||||
|
size={24}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ color: 'var(--color-text-secondary, #5c6472)', flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Text content */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-primary, #111318)',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Install FamilySync
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'var(--color-text-secondary, #5c6472)',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add to your Home Screen for the best experience.{' '}
|
||||||
|
<button
|
||||||
|
onClick={() => setWalkthroughOpen(true)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'var(--color-focus-ring, #4A90D9)',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
How to install
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dismiss */}
|
||||||
|
<button
|
||||||
|
onClick={dismiss}
|
||||||
|
aria-label="Dismiss install prompt"
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
minWidth: '44px',
|
||||||
|
minHeight: '44px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--color-text-secondary, #5c6472)',
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 'var(--space-1, 4px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{walkthroughOpen && <WalkthroughSheet onClose={() => setWalkthroughOpen(false)} />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Android banner ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (canInstall && !dismissed) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="banner"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'var(--space-3, 12px)',
|
||||||
|
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||||||
|
background: 'var(--color-surface-raised, #ffffff)',
|
||||||
|
borderBottom: '1px solid var(--color-border, #e2e4e9)',
|
||||||
|
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<Smartphone
|
||||||
|
size={24}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ color: 'var(--color-text-secondary, #5c6472)', flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Text content */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '13px',
|
||||||
|
color: 'var(--color-text-secondary, #5c6472)',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Install FamilySync to your Home Screen for the best experience.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Install CTA */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
void triggerInstall().then(() => dismiss())
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: 'var(--color-text-primary, #111318)',
|
||||||
|
color: '#ffffff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--space-1, 4px)',
|
||||||
|
minHeight: '44px',
|
||||||
|
padding: '0 var(--space-4, 16px)',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
flexShrink: 0,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Install
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dismiss */}
|
||||||
|
<button
|
||||||
|
onClick={dismiss}
|
||||||
|
aria-label="Dismiss install prompt"
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
minWidth: '44px',
|
||||||
|
minHeight: '44px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--color-text-secondary, #5c6472)',
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 'var(--space-1, 4px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing applicable — desktop, already installed, or dismissed
|
||||||
|
return null
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user