Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 (
<Notice
status="warning"
isDismissible={false}
className={css.iosPwaNotice}
>
<div className={css.content}>
<p className={css.headline}>
<strong>
Your data may be erased by Safari after 7 days
</strong>
</p>
<p className={css.body}>
Safari automatically clears website data after 7 days of
inactivity. To keep your WordPress data safe, install this
app to your Home Screen.
</p>
<div className={css.instructions}>
<p className={css.step}>
Tap the{' '}
<strong>
Share button{' '}
<span
className={css.shareIcon}
role="img"
aria-label="share"
Comment on lines +69 to +70
>
{/* Safari share icon (box with arrow) */}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
<polyline points="16 6 12 2 8 6" />
<line x1="12" y1="2" x2="12" y2="15" />
</svg>
</span>
</strong>{' '}
then choose{' '}
<strong>&quot;Add to Home Screen&quot;</strong>.
</p>
</div>
<div className={css.actions}>
<Button
variant="link"
onClick={handleDismiss}
className={css.dismissButton}
>
Dismiss
</Button>
</div>
</div>
</Notice>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
.iosPwaNotice {
margin: 0;
color: #1e1e1e;
font-size: inherit;
border-left-color: #dba617;
Comment on lines +3 to +5

.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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -55,6 +56,7 @@ export function Layout() {
</div>
</CSSTransition>
<div className={css.siteView}>
<IosPwaNotice />
<div className={css.siteViewContent}>
<PlaygroundViewport displayMode={displayMode} />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ body {

.site-view {
height: 100%;
display: flex;
flex-direction: column;
}

.site-manager-wrapper {
Expand Down Expand Up @@ -127,6 +129,8 @@ body {
overflow: hidden;
transition: border-radius 300ms;
height: 100%;
flex: 1 1 auto;
min-height: 0;
}

/*
Expand Down
110 changes: 110 additions & 0 deletions packages/playground/personal-wp/src/lib/is-ios-safari.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
71 changes: 71 additions & 0 deletions packages/playground/personal-wp/src/lib/is-ios-safari.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +55 to +57
}

/**
* 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();
}
Comment on lines +65 to +71
Loading