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
147 changes: 147 additions & 0 deletions dotcom-rendering/src/client/deviceDetection/iPadDetection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { getCookie, removeCookie } from '@guardian/libs';
import { isIPad, setDeviceClassCookie } from './iPadDetection';

const DEVICE_CLASS_COOKIE = 'device_class';

describe('iPadDetection', () => {
const originalPlatform = navigator.platform;
const originalMaxTouchPoints = navigator.maxTouchPoints;

afterEach(() => {
Object.defineProperty(navigator, 'platform', {
value: originalPlatform,
configurable: true,
});
Object.defineProperty(navigator, 'maxTouchPoints', {
value: originalMaxTouchPoints,
configurable: true,
});
removeCookie({ name: DEVICE_CLASS_COOKIE });
});

describe('isIPad', () => {
it('returns true for newer iPad (iPadOS 13+, MacIntel with touch support)', () => {
Object.defineProperty(navigator, 'platform', {
value: 'MacIntel',
configurable: true,
});
Object.defineProperty(navigator, 'maxTouchPoints', {
value: 5,
configurable: true,
});
expect(isIPad()).toBe(true);
});

it('returns true for older iPad (iOS 12 and earlier)', () => {
Object.defineProperty(navigator, 'platform', {
value: 'iPad',
configurable: true,
});
Object.defineProperty(navigator, 'maxTouchPoints', {
value: 5,
configurable: true,
});
expect(isIPad()).toBe(true);
});

it('returns false for Mac desktop (MacIntel without touch)', () => {
Object.defineProperty(navigator, 'platform', {
value: 'MacIntel',
configurable: true,
});
Object.defineProperty(navigator, 'maxTouchPoints', {
value: 0,
configurable: true,
});
expect(isIPad()).toBe(false);
});

it('returns false for iPhone', () => {
Object.defineProperty(navigator, 'platform', {
value: 'iPhone',
configurable: true,
});
Object.defineProperty(navigator, 'maxTouchPoints', {
value: 5,
configurable: true,
});
expect(isIPad()).toBe(false);
});

it('returns false for Windows', () => {
Object.defineProperty(navigator, 'platform', {
value: 'Win32',
configurable: true,
});
Object.defineProperty(navigator, 'maxTouchPoints', {
value: 0,
configurable: true,
});
expect(isIPad()).toBe(false);
});

it('returns false for Android tablet', () => {
Object.defineProperty(navigator, 'platform', {
value: 'Linux armv8l',
configurable: true,
});
Object.defineProperty(navigator, 'maxTouchPoints', {
value: 5,
configurable: true,
});
expect(isIPad()).toBe(false);
});
});

describe('setDeviceClassCookie', () => {
it('sets device_class cookie to tablet on iPadOS', () => {
Object.defineProperty(navigator, 'platform', {
value: 'MacIntel',
configurable: true,
});
Object.defineProperty(navigator, 'maxTouchPoints', {
value: 5,
configurable: true,
});

setDeviceClassCookie();

expect(getCookie({ name: DEVICE_CLASS_COOKIE })).toBe('tablet');
});

it('does not set cookie on Mac desktop', () => {
Object.defineProperty(navigator, 'platform', {
value: 'MacIntel',
configurable: true,
});
Object.defineProperty(navigator, 'maxTouchPoints', {
value: 0,
configurable: true,
});

setDeviceClassCookie();

expect(getCookie({ name: DEVICE_CLASS_COOKIE })).toBeNull();
});

it('does not overwrite existing tablet cookie', () => {
Object.defineProperty(navigator, 'platform', {
value: 'MacIntel',
configurable: true,
});
Object.defineProperty(navigator, 'maxTouchPoints', {
value: 5,
configurable: true,
});

setDeviceClassCookie();
const firstCookieValue = getCookie({ name: DEVICE_CLASS_COOKIE });

setDeviceClassCookie();
const secondCookieValue = getCookie({ name: DEVICE_CLASS_COOKIE });

expect(firstCookieValue).toBe('tablet');
expect(secondCookieValue).toBe('tablet');
});
});
});
39 changes: 39 additions & 0 deletions dotcom-rendering/src/client/deviceDetection/iPadDetection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { getCookie, setCookie } from '@guardian/libs';

const DEVICE_CLASS_COOKIE = 'device_class';

/**
* Detects iPad devices using feature detection.
*
* - Older iPads (iOS 12 and earlier) report 'iPad' in navigator.platform
* - Newer iPads (iPadOS 13+) report as 'MacIntel' but have touch support
* (navigator.maxTouchPoints > 1, while Macs have 0)
*
* @see https://stackoverflow.com/questions/9038625/detect-if-device-is-ios/9039885#9039885
*/
export const isIPad = (): boolean => {
const isOlderIPad = navigator.platform.includes('iPad');

const isNewerIPad =
navigator.platform === 'MacIntel' &&
typeof navigator.maxTouchPoints === 'number' &&
navigator.maxTouchPoints > 1;

return isOlderIPad || isNewerIPad;
};

/**
* Sets the device_class cookie if the device is detected as iPadOS.
* This cookie will be sent to the backend (SDC) for proper targeting.
*/
export const setDeviceClassCookie = (): void => {
const existingCookie = getCookie({ name: DEVICE_CLASS_COOKIE });

if (isIPad() && existingCookie !== 'tablet') {
setCookie({
name: DEVICE_CLASS_COOKIE,
value: 'tablet',
daysToLive: 365,
});
}
};
4 changes: 4 additions & 0 deletions dotcom-rendering/src/client/main.web.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import './webpackPublicPath';
import { adaptSite, shouldAdapt } from './adaptiveSite';
import { setDeviceClassCookie } from './deviceDetection/iPadDetection';
import { startup } from './startup';
import { maybeSIndicatorCapiKey } from './userFeatures/cookies/sIndicatorCapiKey';

void (async () => {
// Set device class cookie for iPadOS detection (runs immediately, before any SDC requests)
setDeviceClassCookie();
Comment on lines +8 to +9
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this is the best place to call this function.


if (await shouldAdapt()) {
adaptSite('Web');
}
Expand Down
Loading