Adds a surface=desktop URL param to embedded session chat links Uses the surface override to classify embedded chat as desktop Covers the new URL parameter in tests
394 lines
12 KiB
TypeScript
394 lines
12 KiB
TypeScript
import React from 'react';
|
|
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
|
import { isCapacitorApp } from '@/lib/platform';
|
|
|
|
type DeviceType = 'desktop' | 'mobile' | 'tablet';
|
|
|
|
export interface DeviceInfo {
|
|
isMobile: boolean;
|
|
isTablet: boolean;
|
|
isDesktop: boolean;
|
|
deviceType: DeviceType;
|
|
screenWidth: number;
|
|
breakpoint: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
|
hasTouchInput: boolean;
|
|
hasTouchOnlyPointer: boolean;
|
|
}
|
|
|
|
const BREAKPOINTS = {
|
|
xs: 0,
|
|
sm: 640,
|
|
md: 768,
|
|
lg: 1024,
|
|
xl: 1280,
|
|
'2xl': 1536,
|
|
} as const;
|
|
|
|
const DEFAULT_DEVICE_INFO: DeviceInfo = {
|
|
isMobile: false,
|
|
isTablet: false,
|
|
isDesktop: true,
|
|
deviceType: 'desktop',
|
|
screenWidth: 1024,
|
|
breakpoint: 'lg',
|
|
hasTouchInput: false,
|
|
hasTouchOnlyPointer: false,
|
|
};
|
|
|
|
const hasDesktopSurfaceOverride = (): boolean => {
|
|
if (typeof window === 'undefined') return false;
|
|
return new URLSearchParams(window.location.search).get('surface') === 'desktop';
|
|
};
|
|
|
|
const getNavigatorDeviceHints = (maxTouchPoints: number) => {
|
|
if (typeof navigator === 'undefined') {
|
|
return { isExplicitTablet: false };
|
|
}
|
|
|
|
const userAgent = navigator.userAgent || '';
|
|
const platform = navigator.platform || '';
|
|
const isIPad = /iPad/i.test(userAgent)
|
|
|| ((/Macintosh|MacIntel/i.test(userAgent) || /MacIntel/i.test(platform)) && maxTouchPoints > 1);
|
|
const isAndroidTablet = /Android/i.test(userAgent) && !/Mobile/i.test(userAgent);
|
|
const isGenericTablet = /Tablet/i.test(userAgent);
|
|
|
|
return { isExplicitTablet: isIPad || isAndroidTablet || isGenericTablet };
|
|
};
|
|
|
|
const setRootDeviceAttributes = (
|
|
isDesktopShellRuntime: boolean,
|
|
deviceType: DeviceType,
|
|
hasTouchInput: boolean,
|
|
) => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
const root = document.documentElement;
|
|
const isMobile = deviceType === 'mobile';
|
|
const isTablet = deviceType === 'tablet';
|
|
|
|
root.classList.remove('device-mobile', 'device-tablet', 'device-desktop');
|
|
root.classList.add(
|
|
deviceType === 'mobile'
|
|
? 'device-mobile'
|
|
: deviceType === 'tablet'
|
|
? 'device-tablet'
|
|
: 'device-desktop'
|
|
);
|
|
|
|
if (isDesktopShellRuntime) {
|
|
root.classList.add('desktop-runtime');
|
|
root.style.setProperty('--is-mobile', '0');
|
|
root.style.setProperty('--device-type', 'desktop');
|
|
root.style.setProperty('--font-scale', '1');
|
|
root.style.setProperty('--has-coarse-pointer', '0');
|
|
root.style.setProperty('--has-touch-input', '0');
|
|
root.classList.remove('mobile-pointer');
|
|
} else {
|
|
root.classList.remove('desktop-runtime');
|
|
root.style.setProperty('--is-mobile', isMobile ? '1' : '0');
|
|
root.style.setProperty('--device-type', deviceType);
|
|
root.style.setProperty('--font-scale', isMobile ? '0.9' : isTablet ? '0.95' : '1');
|
|
root.style.setProperty('--has-coarse-pointer', hasTouchInput ? '1' : '0');
|
|
root.style.setProperty('--has-touch-input', hasTouchInput ? '1' : '0');
|
|
if (hasTouchInput) {
|
|
root.classList.add('mobile-pointer');
|
|
} else {
|
|
root.classList.remove('mobile-pointer');
|
|
}
|
|
}
|
|
};
|
|
|
|
export function getDeviceInfo(): DeviceInfo {
|
|
const width = window.innerWidth;
|
|
const supportsMatchMedia = typeof window.matchMedia === 'function';
|
|
const pointerQuery = supportsMatchMedia ? window.matchMedia('(pointer: coarse)') : null;
|
|
const hoverQuery = supportsMatchMedia ? window.matchMedia('(hover: none)') : null;
|
|
const prefersCoarsePointer = pointerQuery?.matches ?? false;
|
|
const noHover = hoverQuery?.matches ?? false;
|
|
const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0;
|
|
// Desktop panels are desktop surfaces even when their viewport is narrow.
|
|
const isDesktopShellRuntime = isDesktopShell() || isVSCodeRuntime() || hasDesktopSurfaceOverride();
|
|
const { isExplicitTablet } = getNavigatorDeviceHints(maxTouchPoints);
|
|
|
|
const hasTouchInput = prefersCoarsePointer || noHover || maxTouchPoints > 0;
|
|
const hasTouchOnlyPointer = prefersCoarsePointer || noHover;
|
|
|
|
const isTabletWidth = width > BREAKPOINTS.md && width <= BREAKPOINTS.lg;
|
|
const isMobileWidth = width <= BREAKPOINTS.md;
|
|
|
|
let isMobile = hasTouchInput && isMobileWidth;
|
|
let isTablet = hasTouchInput && !isMobile && (isTabletWidth || isExplicitTablet);
|
|
let isDesktop = !hasTouchInput || (!isTablet && width > BREAKPOINTS.lg);
|
|
let deviceType: DeviceType = 'desktop';
|
|
|
|
if (isDesktopShellRuntime) {
|
|
isMobile = false;
|
|
isTablet = false;
|
|
isDesktop = true;
|
|
deviceType = 'desktop';
|
|
} else if (isCapacitorApp()) {
|
|
// The Capacitor shell IS the phone UI: every surface in that bundle is
|
|
// built mobile-first, so wide devices (iPad, Android tablets) must not
|
|
// fall into tablet/desktop branches scattered across shared components.
|
|
// iPad-specific layout upgrades gate on isIPadApp()/orientation instead.
|
|
isMobile = true;
|
|
isTablet = false;
|
|
isDesktop = false;
|
|
deviceType = 'mobile';
|
|
} else if (isMobile) {
|
|
deviceType = 'mobile';
|
|
} else if (isTablet) {
|
|
deviceType = 'tablet';
|
|
} else {
|
|
isDesktop = true;
|
|
deviceType = 'desktop';
|
|
}
|
|
|
|
setRootDeviceAttributes(isDesktopShellRuntime, deviceType, hasTouchInput);
|
|
|
|
let breakpoint: keyof typeof BREAKPOINTS = 'xs';
|
|
for (const [key, value] of Object.entries(BREAKPOINTS)) {
|
|
if (width >= value) {
|
|
breakpoint = key as keyof typeof BREAKPOINTS;
|
|
}
|
|
}
|
|
|
|
return {
|
|
isMobile,
|
|
isTablet,
|
|
isDesktop,
|
|
deviceType,
|
|
screenWidth: width,
|
|
breakpoint,
|
|
hasTouchInput,
|
|
hasTouchOnlyPointer,
|
|
};
|
|
}
|
|
|
|
const isSameDeviceInfo = (left: DeviceInfo, right: DeviceInfo): boolean => (
|
|
left.isMobile === right.isMobile
|
|
&& left.isTablet === right.isTablet
|
|
&& left.isDesktop === right.isDesktop
|
|
&& left.deviceType === right.deviceType
|
|
&& left.screenWidth === right.screenWidth
|
|
&& left.breakpoint === right.breakpoint
|
|
&& left.hasTouchInput === right.hasTouchInput
|
|
&& left.hasTouchOnlyPointer === right.hasTouchOnlyPointer
|
|
);
|
|
|
|
const deviceInfoSubscribers = new Set<() => void>();
|
|
let deviceInfoSnapshot: DeviceInfo | null = null;
|
|
let deviceInfoFrameId: number | undefined;
|
|
let cleanupDeviceInfoSource: (() => void) | null = null;
|
|
|
|
const readDeviceInfoSnapshot = (): DeviceInfo => {
|
|
if (typeof window === 'undefined') {
|
|
return DEFAULT_DEVICE_INFO;
|
|
}
|
|
|
|
if (!deviceInfoSnapshot) {
|
|
deviceInfoSnapshot = getDeviceInfo();
|
|
}
|
|
|
|
return deviceInfoSnapshot;
|
|
};
|
|
|
|
const notifyDeviceInfoSubscribers = () => {
|
|
for (const listener of deviceInfoSubscribers) {
|
|
listener();
|
|
}
|
|
};
|
|
|
|
const updateDeviceInfoSnapshot = () => {
|
|
deviceInfoFrameId = undefined;
|
|
const next = getDeviceInfo();
|
|
if (deviceInfoSnapshot && isSameDeviceInfo(deviceInfoSnapshot, next)) {
|
|
return;
|
|
}
|
|
|
|
deviceInfoSnapshot = next;
|
|
notifyDeviceInfoSubscribers();
|
|
};
|
|
|
|
const scheduleDeviceInfoUpdate = () => {
|
|
if (typeof window === 'undefined' || deviceInfoFrameId !== undefined) {
|
|
return;
|
|
}
|
|
|
|
deviceInfoFrameId = window.requestAnimationFrame(updateDeviceInfoSnapshot);
|
|
};
|
|
|
|
const attachMediaQueryListener = (query: MediaQueryList | null, listener: () => void): (() => void) => {
|
|
if (!query) {
|
|
return () => {};
|
|
}
|
|
|
|
if (typeof query.addEventListener === 'function') {
|
|
query.addEventListener('change', listener);
|
|
return () => query.removeEventListener('change', listener);
|
|
}
|
|
|
|
if (typeof query.addListener === 'function') {
|
|
query.addListener(listener);
|
|
return () => query.removeListener(listener);
|
|
}
|
|
|
|
return () => {};
|
|
};
|
|
|
|
const startDeviceInfoSource = (): (() => void) => {
|
|
if (typeof window === 'undefined') {
|
|
return () => {};
|
|
}
|
|
|
|
deviceInfoSnapshot = getDeviceInfo();
|
|
window.addEventListener('resize', scheduleDeviceInfoUpdate);
|
|
|
|
const pointerQuery = typeof window.matchMedia === 'function'
|
|
? window.matchMedia('(pointer: coarse)')
|
|
: null;
|
|
const hoverQuery = typeof window.matchMedia === 'function'
|
|
? window.matchMedia('(hover: none)')
|
|
: null;
|
|
const cleanupPointer = attachMediaQueryListener(pointerQuery, scheduleDeviceInfoUpdate);
|
|
const cleanupHover = attachMediaQueryListener(hoverQuery, scheduleDeviceInfoUpdate);
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', scheduleDeviceInfoUpdate);
|
|
cleanupPointer();
|
|
cleanupHover();
|
|
if (deviceInfoFrameId !== undefined) {
|
|
window.cancelAnimationFrame(deviceInfoFrameId);
|
|
deviceInfoFrameId = undefined;
|
|
}
|
|
};
|
|
};
|
|
|
|
const subscribeDeviceInfo = (listener: () => void): (() => void) => {
|
|
if (typeof window === 'undefined') {
|
|
return () => {};
|
|
}
|
|
|
|
deviceInfoSubscribers.add(listener);
|
|
if (!cleanupDeviceInfoSource) {
|
|
cleanupDeviceInfoSource = startDeviceInfoSource();
|
|
}
|
|
|
|
return () => {
|
|
deviceInfoSubscribers.delete(listener);
|
|
if (deviceInfoSubscribers.size === 0 && cleanupDeviceInfoSource) {
|
|
cleanupDeviceInfoSource();
|
|
cleanupDeviceInfoSource = null;
|
|
deviceInfoSnapshot = null;
|
|
}
|
|
};
|
|
};
|
|
|
|
export function isMobileDeviceViaCSS(): boolean {
|
|
if (typeof window === 'undefined') return false;
|
|
|
|
if (typeof window !== 'undefined' && isDesktopShell()) {
|
|
return false;
|
|
}
|
|
|
|
const root = document.documentElement;
|
|
const isMobileValue = root.style.getPropertyValue('--is-mobile') ||
|
|
getComputedStyle(root).getPropertyValue('--is-mobile');
|
|
|
|
return isMobileValue === '1' || isMobileValue === 'true';
|
|
}
|
|
|
|
const isStandalonePwaRuntime = (): boolean => {
|
|
if (typeof window === 'undefined') return false;
|
|
|
|
const standaloneNavigator = navigator as Navigator & { standalone?: boolean };
|
|
return Boolean(
|
|
standaloneNavigator.standalone === true
|
|
|| window.matchMedia?.('(display-mode: standalone)')?.matches
|
|
|| window.matchMedia?.('(display-mode: fullscreen)')?.matches
|
|
);
|
|
};
|
|
|
|
const isTabletStandalonePwaRuntime = (): boolean => {
|
|
if (typeof window === 'undefined' || isDesktopShell()) return false;
|
|
|
|
const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0;
|
|
return isStandalonePwaRuntime() && maxTouchPoints > 0 && window.innerWidth > BREAKPOINTS.md;
|
|
};
|
|
|
|
export function useTabletStandalonePwaRuntime(): boolean {
|
|
const [value, setValue] = React.useState<boolean>(() => isTabletStandalonePwaRuntime());
|
|
|
|
React.useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
const update = () => setValue(isTabletStandalonePwaRuntime());
|
|
const standaloneQuery = window.matchMedia?.('(display-mode: standalone)');
|
|
const fullscreenQuery = window.matchMedia?.('(display-mode: fullscreen)');
|
|
|
|
update();
|
|
window.addEventListener('resize', update);
|
|
window.addEventListener('focus', update);
|
|
|
|
const addQueryListener = (query: MediaQueryList | undefined) => {
|
|
if (!query) return;
|
|
if (typeof query.addEventListener === 'function') {
|
|
query.addEventListener('change', update);
|
|
} else if (typeof query.addListener === 'function') {
|
|
query.addListener(update);
|
|
}
|
|
};
|
|
const removeQueryListener = (query: MediaQueryList | undefined) => {
|
|
if (!query) return;
|
|
if (typeof query.removeEventListener === 'function') {
|
|
query.removeEventListener('change', update);
|
|
} else if (typeof query.removeListener === 'function') {
|
|
query.removeListener(update);
|
|
}
|
|
};
|
|
|
|
addQueryListener(standaloneQuery);
|
|
addQueryListener(fullscreenQuery);
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', update);
|
|
window.removeEventListener('focus', update);
|
|
removeQueryListener(standaloneQuery);
|
|
removeQueryListener(fullscreenQuery);
|
|
};
|
|
}, []);
|
|
|
|
return value;
|
|
}
|
|
|
|
export type Orientation = 'portrait' | 'landscape';
|
|
|
|
const getOrientation = (): Orientation => {
|
|
if (typeof window === 'undefined') return 'portrait';
|
|
return window.matchMedia?.('(orientation: landscape)')?.matches ? 'landscape' : 'portrait';
|
|
};
|
|
|
|
export function useOrientation(): Orientation {
|
|
const [orientation, setOrientation] = React.useState<Orientation>(getOrientation);
|
|
|
|
React.useEffect(() => {
|
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
|
const query = window.matchMedia('(orientation: landscape)');
|
|
const update = () => setOrientation(query.matches ? 'landscape' : 'portrait');
|
|
update();
|
|
return attachMediaQueryListener(query, update);
|
|
}, []);
|
|
|
|
return orientation;
|
|
}
|
|
|
|
export function useDeviceInfo(): DeviceInfo {
|
|
return React.useSyncExternalStore(
|
|
subscribeDeviceInfo,
|
|
readDeviceInfoSnapshot,
|
|
() => DEFAULT_DEVICE_INFO,
|
|
);
|
|
}
|