fix(mobile): detect touch devices with spoofed viewport width
Build and Deploy Verso / deploy (push) Successful in 10m17s

Tor Browser and Firefox with privacy.resistFingerprinting report a
desktop-sized viewport (~980px) even on a real Android phone. This made
window.matchMedia('(max-width: 767px)') return false, so isMobile was
always false on Tor, leaving the editor in side-by-side (horizontal)
layout instead of the expected vertical stack.

Fix: add a secondary check using `(pointer: coarse) and (max-width:
1024px)`. Touch hardware is not spoofed by fingerprinting resistance,
so this reliably catches phones and tablets regardless of the reported
viewport width. Applied to both getInitialLayout() and the live
isMobile state in main-layout.tsx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-18 14:31:56 +00:00
parent 8fc71d677c
commit 51d0314c1c
2 changed files with 35 additions and 14 deletions
@@ -28,6 +28,16 @@ const mainEditorLayoutModalsModules: Array<{
// Bootstrap md breakpoint — below this we stack panels vertically on mobile // Bootstrap md breakpoint — below this we stack panels vertically on mobile
const MOBILE_MQ = '(max-width: 767px)' const MOBILE_MQ = '(max-width: 767px)'
// Secondary check: browsers that spoof viewport width (e.g. Tor Browser) still
// expose `pointer: coarse` for real touch hardware.
const TOUCH_MQ = '(pointer: coarse) and (max-width: 1024px)'
function detectMobile() {
return (
window.matchMedia(MOBILE_MQ).matches ||
window.matchMedia(TOUCH_MQ).matches
)
}
export default function MainLayout() { export default function MainLayout() {
const [resizing, setResizing] = useState(false) const [resizing, setResizing] = useState(false)
@@ -42,14 +52,17 @@ export default function MainLayout() {
} = usePdfPane() } = usePdfPane()
const { view, pdfLayout } = useLayoutContext() const { view, pdfLayout } = useLayoutContext()
const [isMobile, setIsMobile] = useState( const [isMobile, setIsMobile] = useState(detectMobile)
() => window.matchMedia(MOBILE_MQ).matches
)
useEffect(() => { useEffect(() => {
const mq = window.matchMedia(MOBILE_MQ) const handler = () => setIsMobile(detectMobile())
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches) const mq1 = window.matchMedia(MOBILE_MQ)
mq.addEventListener('change', handler) const mq2 = window.matchMedia(TOUCH_MQ)
return () => mq.removeEventListener('change', handler) mq1.addEventListener('change', handler)
mq2.addEventListener('change', handler)
return () => {
mq1.removeEventListener('change', handler)
mq2.removeEventListener('change', handler)
}
}, []) }, [])
// verticalSplit is always vertical; sideBySide becomes vertical on mobile // verticalSplit is always vertical; sideBySide becomes vertical on mobile
@@ -86,16 +86,24 @@ function setLayoutInLocalStorage(pdfLayout: IdeLayout) {
} }
const MOBILE_MQ = '(max-width: 767px)' const MOBILE_MQ = '(max-width: 767px)'
// Secondary touch check: catches browsers that spoof viewport width (e.g. Tor
// Browser's fingerprinting resistance reports ~980px on an Android phone).
// `pointer: coarse` reflects real hardware and is not spoofed.
const TOUCH_MQ = '(pointer: coarse) and (max-width: 1024px)'
function isMobileDevice(): boolean {
return (
window.matchMedia(MOBILE_MQ).matches ||
window.matchMedia(TOUCH_MQ).matches
)
}
function getInitialLayout(): IdeLayout { function getInitialLayout(): IdeLayout {
const stored = localStorage.getItem('pdf.layout') const stored = localStorage.getItem('pdf.layout')
const isMobile = window.matchMedia(MOBILE_MQ).matches // Check mobile first — must come before the stored-'flat' check so that a
// On mobile, always start in verticalSplit (editor above, PDF below). // stale 'flat' (written by an old autoSave race) doesn't block the mobile
// We must check isMobile first: a stale 'flat' in localStorage (written by // default. isMobileDevice() also catches spoofed-viewport browsers.
// a previous autoSave race or a desktop session) would otherwise short-circuit if (isMobileDevice()) return 'verticalSplit'
// the mobile check and leave the user stuck in a flat/editor-only layout.
// The user can still collapse to flat during a session, but it does not persist.
if (isMobile) return 'verticalSplit'
if (stored === 'flat') return 'flat' if (stored === 'flat') return 'flat'
if (stored === 'split') return 'sideBySide' if (stored === 'split') return 'sideBySide'
if (stored === 'vertical') return 'verticalSplit' if (stored === 'vertical') return 'verticalSplit'