diff --git a/packages/playground/personal-wp/src/components/ios-pwa-notice/index.tsx b/packages/playground/personal-wp/src/components/ios-pwa-notice/index.tsx new file mode 100644 index 00000000000..013a98c96e6 --- /dev/null +++ b/packages/playground/personal-wp/src/components/ios-pwa-notice/index.tsx @@ -0,0 +1,107 @@ +import { useState } from 'react'; +import { Notice, Button } from '@wordpress/components'; +import { isIOSSafari, isRunningAsPWA } from '../../lib/is-ios-safari'; +import css from './style.module.css'; + +const DISMISS_KEY = 'playground-ios-pwa-notice-dismissed'; + +function isDismissedInStorage(): boolean { + try { + return localStorage.getItem(DISMISS_KEY) === 'true'; + } catch { + return false; + } +} + +function persistDismissal(): void { + try { + localStorage.setItem(DISMISS_KEY, 'true'); + } catch { + // Storage unavailable — the notice will reappear on + // next visit, which is acceptable. + } +} + +/** + * A dismissible notice shown to iOS/iPadOS Safari users who have + * not installed the app as a PWA. It explains the risk of data + * loss due to Safari's Intelligent Tracking Prevention (ITP) + * which can wipe all script-writable storage after 7 days of + * inactivity, and encourages the user to add the app to their + * Home Screen. + */ +export function IosPwaNotice() { + const [dismissed, setDismissed] = useState(isDismissedInStorage); + + if (dismissed || !isIOSSafari() || isRunningAsPWA()) { + return null; + } + + const handleDismiss = () => { + persistDismissal(); + setDismissed(true); + }; + + return ( + +
+

+ + Your data may be erased by Safari after 7 days + +

+

+ Safari automatically clears website data after 7 days of + inactivity. To keep your WordPress data safe, install this + app to your Home Screen. +

+
+

+ Tap the{' '} + + Share button{' '} + + {/* Safari share icon (box with arrow) */} + + + {' '} + then choose{' '} + "Add to Home Screen". +

+
+
+ +
+
+
+ ); +} diff --git a/packages/playground/personal-wp/src/components/ios-pwa-notice/style.module.css b/packages/playground/personal-wp/src/components/ios-pwa-notice/style.module.css new file mode 100644 index 00000000000..12e5bebe8ef --- /dev/null +++ b/packages/playground/personal-wp/src/components/ios-pwa-notice/style.module.css @@ -0,0 +1,61 @@ +.iosPwaNotice { + margin: 0; + color: #1e1e1e; + font-size: inherit; + border-left-color: #dba617; + + .components-notice__content { + margin-right: 0; + } +} + +.content { + display: flex; + flex-direction: column; + gap: 6px; +} + +.headline { + margin: 0; + font-size: 13px; + line-height: 1.4; +} + +.body { + margin: 0; + font-size: 13px; + line-height: 1.5; +} + +.instructions { + margin: 4px 0 0; +} + +.step { + margin: 0; + font-size: 13px; + line-height: 1.5; +} + +.shareIcon { + display: inline-flex; + align-items: center; + vertical-align: middle; +} + +.shareIcon svg { + width: 14px; + height: 14px; + vertical-align: middle; + margin: 0 1px; +} + +.actions { + display: flex; + justify-content: flex-end; + margin-top: 4px; +} + +.dismissButton { + font-size: 12px; +} diff --git a/packages/playground/personal-wp/src/components/layout/index.tsx b/packages/playground/personal-wp/src/components/layout/index.tsx index 2cc78491e1d..f00ca9fc7f9 100644 --- a/packages/playground/personal-wp/src/components/layout/index.tsx +++ b/packages/playground/personal-wp/src/components/layout/index.tsx @@ -15,6 +15,7 @@ import { MissingSiteModal } from '../missing-site-modal'; import { modalSlugs } from '../../lib/state/redux/slice-ui'; import { SiteManager } from '../site-manager'; import { useAutoBackup } from '../../lib/hooks/use-auto-backup'; +import { IosPwaNotice } from '../ios-pwa-notice'; const displayMode = getDisplayModeFromQuery(); function getDisplayModeFromQuery(): DisplayMode { @@ -55,6 +56,7 @@ export function Layout() {
+
diff --git a/packages/playground/personal-wp/src/components/layout/style.module.css b/packages/playground/personal-wp/src/components/layout/style.module.css index 7fa6dfdb0ac..9f43799d7ed 100644 --- a/packages/playground/personal-wp/src/components/layout/style.module.css +++ b/packages/playground/personal-wp/src/components/layout/style.module.css @@ -37,6 +37,8 @@ body { .site-view { height: 100%; + display: flex; + flex-direction: column; } .site-manager-wrapper { @@ -127,6 +129,8 @@ body { overflow: hidden; transition: border-radius 300ms; height: 100%; + flex: 1 1 auto; + min-height: 0; } /* diff --git a/packages/playground/personal-wp/src/lib/is-ios-safari.spec.ts b/packages/playground/personal-wp/src/lib/is-ios-safari.spec.ts new file mode 100644 index 00000000000..c5a1a7b6221 --- /dev/null +++ b/packages/playground/personal-wp/src/lib/is-ios-safari.spec.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest'; +import { isIOS, isIOSSafari } from './is-ios-safari'; + +describe('isIOS', () => { + it('returns true for iPhone user agent', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + + 'Version/17.0 Mobile/15E148 Safari/604.1'; + expect(isIOS(ua, 'iPhone', 5)).toBe(true); + }); + + it('returns true for iPad user agent', () => { + const ua = + 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + + 'Version/17.0 Mobile/15E148 Safari/604.1'; + expect(isIOS(ua, 'iPad', 5)).toBe(true); + }); + + it('returns true for iPadOS 13+ (MacIntel with touch)', () => { + const ua = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + + 'Version/17.0 Safari/605.1.15'; + expect(isIOS(ua, 'MacIntel', 5)).toBe(true); + }); + + it('returns false for macOS desktop (no touch)', () => { + const ua = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + + 'Version/17.0 Safari/605.1.15'; + expect(isIOS(ua, 'MacIntel', 0)).toBe(false); + }); + + it('returns false for Android', () => { + const ua = + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) ' + + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/120.0.0.0 Mobile Safari/537.36'; + expect(isIOS(ua, 'Linux armv8l', 5)).toBe(false); + }); + + it('returns false for Windows', () => { + const ua = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/120.0.0.0 Safari/537.36'; + expect(isIOS(ua, 'Win32', 0)).toBe(false); + }); +}); + +describe('isIOSSafari', () => { + it('returns true for Safari on iPhone', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + + 'Version/17.0 Mobile/15E148 Safari/604.1'; + expect(isIOSSafari(ua, 'iPhone', 5)).toBe(true); + }); + + it('returns true for Safari on iPadOS 13+', () => { + const ua = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + + 'Version/17.0 Safari/605.1.15'; + expect(isIOSSafari(ua, 'MacIntel', 5)).toBe(true); + }); + + it('returns false for Chrome on iOS (CriOS)', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + + 'CriOS/120.0.6099.119 Mobile/15E148 Safari/604.1'; + expect(isIOSSafari(ua, 'iPhone', 5)).toBe(false); + }); + + it('returns false for Firefox on iOS (FxiOS)', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + + 'FxiOS/120.0 Mobile/15E148 Safari/604.1'; + expect(isIOSSafari(ua, 'iPhone', 5)).toBe(false); + }); + + it('returns false for WKWebView (no Version/)', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + + 'Mobile/15E148'; + expect(isIOSSafari(ua, 'iPhone', 5)).toBe(false); + }); + + it('returns false for macOS Safari (no touch)', () => { + const ua = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + + 'Version/17.0 Safari/605.1.15'; + expect(isIOSSafari(ua, 'MacIntel', 0)).toBe(false); + }); + + it('returns false for Android Chrome', () => { + const ua = + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) ' + + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/120.0.0.0 Mobile Safari/537.36'; + expect(isIOSSafari(ua, 'Linux armv8l', 5)).toBe(false); + }); +}); diff --git a/packages/playground/personal-wp/src/lib/is-ios-safari.ts b/packages/playground/personal-wp/src/lib/is-ios-safari.ts new file mode 100644 index 00000000000..d41c2dd0524 --- /dev/null +++ b/packages/playground/personal-wp/src/lib/is-ios-safari.ts @@ -0,0 +1,71 @@ +/** + * Detects whether the current environment is iOS/iPadOS Safari + * (not a WKWebView, not Chrome, not Firefox) and whether the app + * is running as an installed PWA (standalone display mode). + */ + +/** + * Returns true when the device is running iOS or iPadOS. + * + * iPads with iPadOS 13+ report as "MacIntel" in the platform + * string but expose multi-touch support, so we check both the + * legacy UA tokens and the modern platform + touchPoints + * combination. + */ +export function isIOS( + ua: string = navigator.userAgent, + platform: string = navigator.platform, + maxTouchPoints: number = navigator.maxTouchPoints +): boolean { + return ( + /iPad|iPhone|iPod/.test(ua) || + (platform === 'MacIntel' && maxTouchPoints > 1) + ); +} + +/** + * Returns true when the browser is Safari on iOS/iPadOS. + * + * Safari includes "Version/" in its user-agent string whereas + * WKWebViews (in-app browsers) do not. Chrome on iOS identifies + * itself with "CriOS" and Firefox with "FxiOS". + */ +export function isIOSSafari( + ua: string = navigator.userAgent, + platform: string = navigator.platform, + maxTouchPoints: number = navigator.maxTouchPoints +): boolean { + if (!isIOS(ua, platform, maxTouchPoints)) { + return false; + } + // Safari proper includes "Version/" + if (!/Version\//.test(ua)) { + return false; + } + // Exclude Chrome and Firefox on iOS + if (/CriOS\//.test(ua) || /FxiOS\//.test(ua)) { + return false; + } + return true; +} + +/** + * Returns true when the app is running as an installed PWA + * (standalone display mode). + */ +export function isRunningAsPWA(): boolean { + return window.matchMedia('(display-mode: standalone)').matches; +} + +/** + * Returns true when the user is on iOS Safari *and* the app is + * not installed as a PWA — i.e. the user is at risk of losing + * data due to Safari's ITP 7-day storage expiration policy. + */ +export function isIOSSafariWithoutPWA( + ua?: string, + platform?: string, + maxTouchPoints?: number +): boolean { + return isIOSSafari(ua, platform, maxTouchPoints) && !isRunningAsPWA(); +}