fix(mobile): detect touch devices with spoofed viewport width
Build and Deploy Verso / deploy (push) Successful in 10m17s
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:
@@ -28,6 +28,16 @@ const mainEditorLayoutModalsModules: Array<{
|
||||
|
||||
// Bootstrap md breakpoint — below this we stack panels vertically on mobile
|
||||
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() {
|
||||
const [resizing, setResizing] = useState(false)
|
||||
@@ -42,14 +52,17 @@ export default function MainLayout() {
|
||||
} = usePdfPane()
|
||||
const { view, pdfLayout } = useLayoutContext()
|
||||
|
||||
const [isMobile, setIsMobile] = useState(
|
||||
() => window.matchMedia(MOBILE_MQ).matches
|
||||
)
|
||||
const [isMobile, setIsMobile] = useState(detectMobile)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(MOBILE_MQ)
|
||||
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
const handler = () => setIsMobile(detectMobile())
|
||||
const mq1 = window.matchMedia(MOBILE_MQ)
|
||||
const mq2 = window.matchMedia(TOUCH_MQ)
|
||||
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
|
||||
|
||||
@@ -86,16 +86,24 @@ function setLayoutInLocalStorage(pdfLayout: IdeLayout) {
|
||||
}
|
||||
|
||||
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 {
|
||||
const stored = localStorage.getItem('pdf.layout')
|
||||
const isMobile = window.matchMedia(MOBILE_MQ).matches
|
||||
// On mobile, always start in verticalSplit (editor above, PDF below).
|
||||
// We must check isMobile first: a stale 'flat' in localStorage (written by
|
||||
// a previous autoSave race or a desktop session) would otherwise short-circuit
|
||||
// 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'
|
||||
// Check mobile first — must come before the stored-'flat' check so that a
|
||||
// stale 'flat' (written by an old autoSave race) doesn't block the mobile
|
||||
// default. isMobileDevice() also catches spoofed-viewport browsers.
|
||||
if (isMobileDevice()) return 'verticalSplit'
|
||||
if (stored === 'flat') return 'flat'
|
||||
if (stored === 'split') return 'sideBySide'
|
||||
if (stored === 'vertical') return 'verticalSplit'
|
||||
|
||||
Reference in New Issue
Block a user