The mobile-vs-desktop surface is stamped once at boot, so a browser window narrowed past the phone threshold kept the desktop shell (and its legacy squeezed layout) until a manual reload. A viewport watcher now reloads into the other shell once the resize settles — the same mechanism the old Settings toggle used. Fixed shells (Capacitor, desktop, VS Code) and ?surface= overrides never switch. With the new mobile app reachable this way, the old/new mobile layout preference is gone: phones always get the mobile app.
118 lines
4.2 KiB
TypeScript
118 lines
4.2 KiB
TypeScript
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
|
|
import { isCapacitorApp } from '@/lib/platform';
|
|
|
|
export type HostedSurface = 'desktop' | 'mobile';
|
|
|
|
declare global {
|
|
interface Window {
|
|
__OPENCHAMBER_SURFACE__?: HostedSurface;
|
|
}
|
|
}
|
|
|
|
const MOBILE_SURFACE_MAX_WIDTH = 768;
|
|
const SURFACE_SWITCH_DEBOUNCE_MS = 800;
|
|
|
|
const isTouchOrCoarsePointer = (): boolean => {
|
|
if (typeof window === 'undefined') return false;
|
|
|
|
const coarsePointer = typeof window.matchMedia === 'function'
|
|
? window.matchMedia('(pointer: coarse)').matches || window.matchMedia('(hover: none)').matches
|
|
: false;
|
|
const touchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0;
|
|
return coarsePointer || touchPoints > 0;
|
|
};
|
|
|
|
const hasSurfaceUrlOverride = (): boolean => {
|
|
if (typeof window === 'undefined') return false;
|
|
const override = new URLSearchParams(window.location.search).get('surface');
|
|
return override === 'mobile' || override === 'desktop';
|
|
};
|
|
|
|
/** Viewport half of the surface decision; re-evaluated on resize by the watcher. */
|
|
const isPhoneViewport = (): boolean => {
|
|
if (typeof window === 'undefined') return false;
|
|
const width = Math.min(
|
|
window.innerWidth || Number.POSITIVE_INFINITY,
|
|
window.screen?.width || Number.POSITIVE_INFINITY,
|
|
);
|
|
return Number.isFinite(width)
|
|
&& width <= MOBILE_SURFACE_MAX_WIDTH
|
|
&& isTouchOrCoarsePointer();
|
|
};
|
|
|
|
/**
|
|
* Single authority for the mobile-vs-desktop surface decision.
|
|
*
|
|
* Priority: explicit stamp (set once at boot) → URL override → Capacitor
|
|
* shell (always the mobile surface) → desktop shells → phone heuristic.
|
|
*/
|
|
const detectHostedSurface = (): HostedSurface => {
|
|
if (typeof window === 'undefined') return 'desktop';
|
|
|
|
const explicitSurface = window.__OPENCHAMBER_SURFACE__;
|
|
if (explicitSurface === 'mobile' || explicitSurface === 'desktop') {
|
|
return explicitSurface;
|
|
}
|
|
|
|
const override = new URLSearchParams(window.location.search).get('surface');
|
|
if (override === 'mobile' || override === 'desktop') {
|
|
return override;
|
|
}
|
|
|
|
if (isCapacitorApp()) return 'mobile';
|
|
if (isDesktopShell() || isVSCodeRuntime()) return 'desktop';
|
|
|
|
return isPhoneViewport() ? 'mobile' : 'desktop';
|
|
};
|
|
|
|
/**
|
|
* Decides the surface once and stamps it on `window` so every later
|
|
* `isMobileSurfaceRuntime()` call (perf tuning, sync paging, device info)
|
|
* reads the same stable answer instead of re-running viewport heuristics.
|
|
*/
|
|
export const resolveHostedSurface = (): HostedSurface => {
|
|
const surface = detectHostedSurface();
|
|
if (typeof window !== 'undefined') {
|
|
window.__OPENCHAMBER_SURFACE__ = surface;
|
|
}
|
|
return surface;
|
|
};
|
|
|
|
export const isMobileSurfaceRuntime = (): boolean => detectHostedSurface() === 'mobile';
|
|
|
|
/**
|
|
* The surface is stamped once at boot, so a browser window that crosses the
|
|
* phone threshold after load would otherwise keep the wrong app shell (the
|
|
* app trees, stores, and sync bootstrap differ, so an in-place switch is not
|
|
* safe). Watch for the viewport heuristic disagreeing with the stamp and
|
|
* reload — the same mechanism a surface change has always used — once the
|
|
* resize settles. Fixed shells never switch: Capacitor is always mobile,
|
|
* desktop/VS Code shells are always desktop, and an explicit ?surface=
|
|
* override wins over the heuristic.
|
|
*/
|
|
export const watchHostedSurfaceViewport = (): (() => void) => {
|
|
if (typeof window === 'undefined') return () => {};
|
|
if (isCapacitorApp() || isDesktopShell() || isVSCodeRuntime()) return () => {};
|
|
if (hasSurfaceUrlOverride()) return () => {};
|
|
|
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
const handleResize = () => {
|
|
if (timeout) clearTimeout(timeout);
|
|
timeout = setTimeout(() => {
|
|
timeout = null;
|
|
const stamped = window.__OPENCHAMBER_SURFACE__;
|
|
const desired: HostedSurface = isPhoneViewport() ? 'mobile' : 'desktop';
|
|
if (stamped && stamped !== desired) {
|
|
window.__OPENCHAMBER_SURFACE__ = undefined;
|
|
window.location.reload();
|
|
}
|
|
}, SURFACE_SWITCH_DEBOUNCE_MS);
|
|
};
|
|
|
|
window.addEventListener('resize', handleResize);
|
|
return () => {
|
|
if (timeout) clearTimeout(timeout);
|
|
window.removeEventListener('resize', handleResize);
|
|
};
|
|
};
|