2025-12-07 19:32:53 +02:00
|
|
|
import React from 'react';
|
2026-06-23 16:32:30 +08:00
|
|
|
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
2026-08-02 16:25:16 +03:00
|
|
|
import { isIPadApp } from '@/lib/platform';
|
2026-08-01 21:16:36 +03:00
|
|
|
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
2026-06-26 19:27:53 +03:00
|
|
|
type DeviceType = 'desktop' | 'mobile' | 'tablet';
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
export interface DeviceInfo {
|
|
|
|
|
isMobile: boolean;
|
|
|
|
|
isTablet: boolean;
|
|
|
|
|
isDesktop: boolean;
|
|
|
|
|
deviceType: DeviceType;
|
|
|
|
|
screenWidth: number;
|
|
|
|
|
breakpoint: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
|
|
|
|
hasTouchInput: boolean;
|
2026-05-05 16:26:34 +07:00
|
|
|
hasTouchOnlyPointer: boolean;
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-26 19:27:53 +03:00
|
|
|
const BREAKPOINTS = {
|
2025-12-07 19:32:53 +02:00
|
|
|
xs: 0,
|
|
|
|
|
sm: 640,
|
|
|
|
|
md: 768,
|
|
|
|
|
lg: 1024,
|
|
|
|
|
xl: 1280,
|
|
|
|
|
'2xl': 1536,
|
|
|
|
|
} as const;
|
|
|
|
|
|
2026-06-05 13:31:52 +03:00
|
|
|
const DEFAULT_DEVICE_INFO: DeviceInfo = {
|
|
|
|
|
isMobile: false,
|
|
|
|
|
isTablet: false,
|
|
|
|
|
isDesktop: true,
|
|
|
|
|
deviceType: 'desktop',
|
|
|
|
|
screenWidth: 1024,
|
|
|
|
|
breakpoint: 'lg',
|
|
|
|
|
hasTouchInput: false,
|
|
|
|
|
hasTouchOnlyPointer: false,
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-11 10:48:20 +03:00
|
|
|
const hasDesktopSurfaceOverride = (): boolean => {
|
2026-07-11 09:40:35 +03:00
|
|
|
if (typeof window === 'undefined') return false;
|
2026-07-11 10:48:20 +03:00
|
|
|
return new URLSearchParams(window.location.search).get('surface') === 'desktop';
|
2026-07-11 09:40:35 +03:00
|
|
|
};
|
|
|
|
|
|
2026-05-04 00:04:29 +03:00
|
|
|
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 };
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
const setRootDeviceAttributes = (
|
2026-06-03 02:42:00 +03:00
|
|
|
isDesktopShellRuntime: boolean,
|
2025-12-07 19:32:53 +02:00
|
|
|
deviceType: DeviceType,
|
|
|
|
|
hasTouchInput: boolean,
|
|
|
|
|
) => {
|
|
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const root = document.documentElement;
|
|
|
|
|
|
2026-04-26 17:44:33 +03:00
|
|
|
root.classList.remove('device-mobile', 'device-tablet', 'device-desktop');
|
2025-12-18 11:20:37 +02:00
|
|
|
root.classList.add(
|
|
|
|
|
deviceType === 'mobile'
|
|
|
|
|
? 'device-mobile'
|
|
|
|
|
: deviceType === 'tablet'
|
|
|
|
|
? 'device-tablet'
|
|
|
|
|
: 'device-desktop'
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-03 02:42:00 +03:00
|
|
|
if (isDesktopShellRuntime) {
|
2025-12-07 19:32:53 +02:00
|
|
|
root.classList.add('desktop-runtime');
|
|
|
|
|
root.classList.remove('mobile-pointer');
|
|
|
|
|
} else {
|
|
|
|
|
root.classList.remove('desktop-runtime');
|
|
|
|
|
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;
|
2026-07-11 09:40:35 +03:00
|
|
|
// Desktop panels are desktop surfaces even when their viewport is narrow.
|
2026-07-11 10:48:20 +03:00
|
|
|
const isDesktopShellRuntime = isDesktopShell() || isVSCodeRuntime() || hasDesktopSurfaceOverride();
|
2026-05-04 00:04:29 +03:00
|
|
|
const { isExplicitTablet } = getNavigatorDeviceHints(maxTouchPoints);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
const hasTouchInput = prefersCoarsePointer || noHover || maxTouchPoints > 0;
|
2026-05-05 16:26:34 +07:00
|
|
|
const hasTouchOnlyPointer = prefersCoarsePointer || noHover;
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
const isTabletWidth = width > BREAKPOINTS.md && width <= BREAKPOINTS.lg;
|
|
|
|
|
const isMobileWidth = width <= BREAKPOINTS.md;
|
|
|
|
|
|
|
|
|
|
let isMobile = hasTouchInput && isMobileWidth;
|
2026-05-04 00:04:29 +03:00
|
|
|
let isTablet = hasTouchInput && !isMobile && (isTabletWidth || isExplicitTablet);
|
|
|
|
|
let isDesktop = !hasTouchInput || (!isTablet && width > BREAKPOINTS.lg);
|
2025-12-07 19:32:53 +02:00
|
|
|
let deviceType: DeviceType = 'desktop';
|
|
|
|
|
|
2026-04-20 15:41:15 +03:00
|
|
|
if (isDesktopShellRuntime) {
|
2025-12-07 19:32:53 +02:00
|
|
|
isMobile = false;
|
|
|
|
|
isTablet = false;
|
|
|
|
|
isDesktop = true;
|
|
|
|
|
deviceType = 'desktop';
|
2026-08-01 21:16:36 +03:00
|
|
|
} else if (isMobileSurfaceRuntime()) {
|
|
|
|
|
// The mobile surface (Capacitor shell or hosted MobileApp) IS the phone
|
|
|
|
|
// UI: every component in that tree is built mobile-first, so wide devices
|
|
|
|
|
// (iPad, Android tablets, rotated phones) must not fall into
|
|
|
|
|
// tablet/desktop branches scattered across shared components.
|
2026-08-02 16:25:16 +03:00
|
|
|
// Tablet layout upgrades gate on useTabletLayout() (a size class) instead.
|
2026-07-08 21:52:18 +03:00
|
|
|
isMobile = true;
|
|
|
|
|
isTablet = false;
|
|
|
|
|
isDesktop = false;
|
|
|
|
|
deviceType = 'mobile';
|
2025-12-07 19:32:53 +02:00
|
|
|
} else if (isMobile) {
|
|
|
|
|
deviceType = 'mobile';
|
|
|
|
|
} else if (isTablet) {
|
|
|
|
|
deviceType = 'tablet';
|
|
|
|
|
} else {
|
|
|
|
|
isDesktop = true;
|
|
|
|
|
deviceType = 'desktop';
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 17:44:33 +03:00
|
|
|
setRootDeviceAttributes(isDesktopShellRuntime, deviceType, hasTouchInput);
|
2025-12-07 19:32:53 +02:00
|
|
|
|
|
|
|
|
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,
|
2026-05-05 16:26:34 +07:00
|
|
|
hasTouchOnlyPointer,
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 13:31:52 +03:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
export function isMobileDeviceViaCSS(): boolean {
|
|
|
|
|
if (typeof window === 'undefined') return false;
|
2026-08-01 21:16:36 +03:00
|
|
|
return readDeviceInfoSnapshot().isMobile;
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-26 19:27:53 +03:00
|
|
|
const isStandalonePwaRuntime = (): boolean => {
|
2026-05-03 21:39:30 +03:00
|
|
|
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
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-26 19:27:53 +03:00
|
|
|
const isTabletStandalonePwaRuntime = (): boolean => {
|
2026-05-03 21:39:30 +03:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 21:52:18 +03:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 16:25:16 +03:00
|
|
|
/**
|
|
|
|
|
* Smallest viewport side that earns the tablet layout, following Android's
|
|
|
|
|
* long-standing `sw600dp` size class. The SHORT side is what makes this a size
|
|
|
|
|
* question rather than a device question: a phone reports ~360-430 whichever
|
|
|
|
|
* way it is held, a 7"+ tablet or an unfolded book foldable reports ~600+, and
|
|
|
|
|
* a foldable folded shut drops back under it. So a fold is just a resize, and
|
|
|
|
|
* nothing here has to know what a foldable is.
|
|
|
|
|
*/
|
|
|
|
|
const TABLET_LAYOUT_MIN_SHORT_SIDE_PX = 600;
|
|
|
|
|
/**
|
|
|
|
|
* Width below which the workspace cannot become a side panel: the sessions
|
|
|
|
|
* sidebar (~320) plus the panel (~380) plus a chat column that is still worth
|
|
|
|
|
* reading. Unfolded book foldables land under this even "landscape", so they
|
|
|
|
|
* keep the full-cover drawer in both orientations — which is the whole point,
|
|
|
|
|
* their wide side is barely wider than a tablet's narrow one.
|
|
|
|
|
*/
|
|
|
|
|
const WORKSPACE_PANEL_MIN_WIDTH_PX = 1000;
|
|
|
|
|
|
|
|
|
|
export interface TabletLayout {
|
|
|
|
|
/** Sessions become a persistent sidebar, dropdowns become anchored popovers. */
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
/** There is room for the workspace beside the chat instead of over it. */
|
|
|
|
|
roomyForPanels: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const readTabletLayout = (): TabletLayout => {
|
|
|
|
|
if (typeof window === 'undefined') return { enabled: false, roomyForPanels: false };
|
|
|
|
|
const width = window.innerWidth;
|
|
|
|
|
const height = window.innerHeight;
|
|
|
|
|
// iPads answer this on identity too: iPadOS reports odd viewports in Slide
|
|
|
|
|
// Over / Split View, and a device we KNOW is a tablet should not flip to the
|
|
|
|
|
// phone layout because it was given a narrow slice.
|
|
|
|
|
const enabled = isIPadApp() || Math.min(width, height) >= TABLET_LAYOUT_MIN_SHORT_SIDE_PX;
|
|
|
|
|
return {
|
|
|
|
|
enabled,
|
|
|
|
|
roomyForPanels: enabled && width > height && width >= WORKSPACE_PANEL_MIN_WIDTH_PX,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The tablet layout decision, live.
|
|
|
|
|
*
|
|
|
|
|
* Deliberately a hook over a one-shot check: foldables change size class while
|
|
|
|
|
* the app runs, and the Android shell keeps the WebView alive across the fold
|
|
|
|
|
* (`configChanges` covers screenSize), so every consumer has to re-decide
|
|
|
|
|
* rather than remember what it saw at mount.
|
|
|
|
|
*/
|
|
|
|
|
export function useTabletLayout(): TabletLayout {
|
|
|
|
|
const [layout, setLayout] = React.useState<TabletLayout>(readTabletLayout);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (typeof window === 'undefined') return;
|
|
|
|
|
let frame: number | undefined;
|
|
|
|
|
const update = () => {
|
|
|
|
|
frame = undefined;
|
|
|
|
|
const next = readTabletLayout();
|
|
|
|
|
setLayout((current) => (
|
|
|
|
|
current.enabled === next.enabled && current.roomyForPanels === next.roomyForPanels
|
|
|
|
|
? current
|
|
|
|
|
: next
|
|
|
|
|
));
|
|
|
|
|
};
|
|
|
|
|
const schedule = () => {
|
|
|
|
|
if (frame !== undefined) return;
|
|
|
|
|
frame = window.requestAnimationFrame(update);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
update();
|
|
|
|
|
window.addEventListener('resize', schedule);
|
|
|
|
|
const orientationQuery = window.matchMedia?.('(orientation: landscape)') ?? null;
|
|
|
|
|
const detachOrientation = attachMediaQueryListener(orientationQuery, schedule);
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener('resize', schedule);
|
|
|
|
|
detachOrientation();
|
|
|
|
|
if (frame !== undefined) window.cancelAnimationFrame(frame);
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
return layout;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-07 19:32:53 +02:00
|
|
|
export function useDeviceInfo(): DeviceInfo {
|
2026-06-05 13:31:52 +03:00
|
|
|
return React.useSyncExternalStore(
|
|
|
|
|
subscribeDeviceInfo,
|
|
|
|
|
readDeviceInfoSnapshot,
|
|
|
|
|
() => DEFAULT_DEVICE_INFO,
|
|
|
|
|
);
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|