From 81782087f1d19f679a18bd09385ad57f2016f992 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 17:33:43 -0400 Subject: [PATCH 1/5] fix(ua): detect iPad with macOS user agent as mobileOrTablet --- src/app/utils/user-agent.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/app/utils/user-agent.ts b/src/app/utils/user-agent.ts index e1a2e0a61..2a0ce0e73 100644 --- a/src/app/utils/user-agent.ts +++ b/src/app/utils/user-agent.ts @@ -6,6 +6,11 @@ const isMobileOrTablet = (() => { const { os, device } = result; if (device.type === 'mobile' || device.type === 'tablet') return true; if (os.name === 'Android' || os.name === 'iOS') return true; + // iPad on iOS 13+ sends a macOS Safari user agent by default ("Request Desktop Website"). + // ua-parser-js therefore reports os.name === 'Mac OS' with no device.type. + // Real Macs never have maxTouchPoints > 1 (Magic Trackpad reports 1 at most in browsers), + // so this safely identifies iPads masquerading as desktop Safari. + if (os.name === 'Mac OS' && navigator.maxTouchPoints > 1) return true; return false; })(); From 74f13e4152eb075bb2280b64b8af35106fa93a74 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 20:12:12 -0400 Subject: [PATCH 2/5] fix(layout): open rooms on iPad using 'Request Desktop Website' iPadOS 13+ Safari reports a macOS user-agent by default, so ua-parser-js sees os.name === 'Mac OS' and leaves device.type unset. mobileOrTablet() already handles this via maxTouchPoints > 1, but three layout gates only checked screenSize === ScreenSize.Mobile (which is width-based, so a full-width iPad reads as Desktop or Tablet): - MobileFriendlyPageNav: hides the sidebar when a room is active - MobileFriendlyClientNav: hides the app nav bar when deep in a route - createRouter: adds a WelcomePage index route instead of an empty outlet - PageRoot: suppresses the vertical divider line between nav and content All four now also check mobileOrTablet() so iPads in desktop-UA mode get the correct single-panel navigation behaviour. --- src/app/components/page/Page.tsx | 3 ++- src/app/pages/MobileFriendly.tsx | 5 +++-- src/app/pages/Router.tsx | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index bcad116d3..53ee0144d 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -3,6 +3,7 @@ import { Box, Header, Line, Scroll, Text, as } from 'folds'; import classNames from 'classnames'; import { ContainerColor } from '$styles/ContainerColor.css'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { mobileOrTablet } from '$utils/user-agent'; import * as css from './style.css'; type PageRootProps = { @@ -16,7 +17,7 @@ export function PageRoot({ nav, children }: PageRootProps) { return ( {nav} - {screenSize !== ScreenSize.Mobile && ( + {screenSize !== ScreenSize.Mobile && !mobileOrTablet() && ( )} {children} diff --git a/src/app/pages/MobileFriendly.tsx b/src/app/pages/MobileFriendly.tsx index 83009cda5..c98b93269 100644 --- a/src/app/pages/MobileFriendly.tsx +++ b/src/app/pages/MobileFriendly.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import { useMatch } from 'react-router-dom'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { mobileOrTablet } from '$utils/user-agent'; import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths'; type MobileFriendlyClientNavProps = { @@ -15,7 +16,7 @@ export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavPro const inboxMatch = useMatch({ path: INBOX_PATH, caseSensitive: true, end: true }); if ( - screenSize === ScreenSize.Mobile && + (screenSize === ScreenSize.Mobile || mobileOrTablet()) && !(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch) ) { return null; @@ -36,7 +37,7 @@ export function MobileFriendlyPageNav({ path, children }: MobileFriendlyPageNavP end: true, }); - if (screenSize === ScreenSize.Mobile && !exactPath) { + if ((screenSize === ScreenSize.Mobile || mobileOrTablet()) && !exactPath) { return null; } diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index aec62cc86..1014b0863 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -16,6 +16,7 @@ import { Room } from '$features/room'; import { Lobby } from '$features/lobby'; import { PageRoot } from '$components/page'; import { ScreenSize } from '$hooks/useScreenSize'; +import { mobileOrTablet } from '$utils/user-agent'; import { ReceiveSelfDeviceVerification } from '$components/DeviceVerification'; import { AutoRestoreBackupOnVerification } from '$components/BackupRestore'; import { RoomSettingsRenderer } from '$features/room-settings'; @@ -101,7 +102,7 @@ const getFirstSession = () => { export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { const { hashRouter } = clientConfig; - const mobile = screenSize === ScreenSize.Mobile; + const mobile = screenSize === ScreenSize.Mobile || mobileOrTablet(); const routes = createRoutesFromElements( From 28b524b6917e7b07e2c4ca16023bfeba3ed89315 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 20:45:22 -0400 Subject: [PATCH 3/5] fix(layout): show desktop sidebar layout on iPad in 'Request Desktop Website' mode --- src/app/components/page/Page.tsx | 4 ++-- src/app/pages/MobileFriendly.tsx | 6 +++--- src/app/pages/Router.tsx | 4 ++-- src/app/utils/user-agent.ts | 15 +++++++++++++++ 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index 53ee0144d..1bbdde74a 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -3,7 +3,7 @@ import { Box, Header, Line, Scroll, Text, as } from 'folds'; import classNames from 'classnames'; import { ContainerColor } from '$styles/ContainerColor.css'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; -import { mobileOrTablet } from '$utils/user-agent'; +import { mobileOrTabletLayout } from '$utils/user-agent'; import * as css from './style.css'; type PageRootProps = { @@ -17,7 +17,7 @@ export function PageRoot({ nav, children }: PageRootProps) { return ( {nav} - {screenSize !== ScreenSize.Mobile && !mobileOrTablet() && ( + {screenSize !== ScreenSize.Mobile && !mobileOrTabletLayout() && ( )} {children} diff --git a/src/app/pages/MobileFriendly.tsx b/src/app/pages/MobileFriendly.tsx index c98b93269..5fb203286 100644 --- a/src/app/pages/MobileFriendly.tsx +++ b/src/app/pages/MobileFriendly.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import { useMatch } from 'react-router-dom'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; -import { mobileOrTablet } from '$utils/user-agent'; +import { mobileOrTabletLayout } from '$utils/user-agent'; import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths'; type MobileFriendlyClientNavProps = { @@ -16,7 +16,7 @@ export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavPro const inboxMatch = useMatch({ path: INBOX_PATH, caseSensitive: true, end: true }); if ( - (screenSize === ScreenSize.Mobile || mobileOrTablet()) && + (screenSize === ScreenSize.Mobile || mobileOrTabletLayout()) && !(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch) ) { return null; @@ -37,7 +37,7 @@ export function MobileFriendlyPageNav({ path, children }: MobileFriendlyPageNavP end: true, }); - if ((screenSize === ScreenSize.Mobile || mobileOrTablet()) && !exactPath) { + if ((screenSize === ScreenSize.Mobile || mobileOrTabletLayout()) && !exactPath) { return null; } diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 1014b0863..fa1037b5f 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -16,7 +16,7 @@ import { Room } from '$features/room'; import { Lobby } from '$features/lobby'; import { PageRoot } from '$components/page'; import { ScreenSize } from '$hooks/useScreenSize'; -import { mobileOrTablet } from '$utils/user-agent'; +import { mobileOrTabletLayout } from '$utils/user-agent'; import { ReceiveSelfDeviceVerification } from '$components/DeviceVerification'; import { AutoRestoreBackupOnVerification } from '$components/BackupRestore'; import { RoomSettingsRenderer } from '$features/room-settings'; @@ -102,7 +102,7 @@ const getFirstSession = () => { export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => { const { hashRouter } = clientConfig; - const mobile = screenSize === ScreenSize.Mobile || mobileOrTablet(); + const mobile = screenSize === ScreenSize.Mobile || mobileOrTabletLayout(); const routes = createRoutesFromElements( diff --git a/src/app/utils/user-agent.ts b/src/app/utils/user-agent.ts index 2a0ce0e73..7f78d60fd 100644 --- a/src/app/utils/user-agent.ts +++ b/src/app/utils/user-agent.ts @@ -20,11 +20,26 @@ const normalizeMacName = (os?: string) => { return os; }; +// True for layout purposes: phones and tablets with native touch UA. +// Intentionally excludes iPads in "Request Desktop Website" mode (macOS UA + +// maxTouchPoints > 1) because those users explicitly want the desktop layout. +const isMobileOrTabletLayout = (() => { + const { os, device } = result; + if (device.type === 'mobile' || device.type === 'tablet') return true; + if (os.name === 'Android' || os.name === 'iOS') return true; + return false; +})(); + const isMac = result.os.name === 'Mac OS'; export const ua = () => result; export const isMacOS = () => isMac; export const mobileOrTablet = () => isMobileOrTablet; +/** + * Like `mobileOrTablet` but excludes iPads using "Request Desktop Website". + * Use this for layout/nav decisions; use `mobileOrTablet` for touch/keyboard behaviour. + */ +export const mobileOrTabletLayout = () => isMobileOrTabletLayout; export const deviceDisplayName = (): string => { const browser = result.browser.name; From afec2646e3c34edaae1594f170c6e3794991ccfb Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 21:12:56 -0400 Subject: [PATCH 4/5] fix(layout): tablets always use desktop layout; only phones force mobile layout --- src/app/utils/user-agent.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/app/utils/user-agent.ts b/src/app/utils/user-agent.ts index 7f78d60fd..5e4932ad9 100644 --- a/src/app/utils/user-agent.ts +++ b/src/app/utils/user-agent.ts @@ -20,15 +20,10 @@ const normalizeMacName = (os?: string) => { return os; }; -// True for layout purposes: phones and tablets with native touch UA. -// Intentionally excludes iPads in "Request Desktop Website" mode (macOS UA + -// maxTouchPoints > 1) because those users explicitly want the desktop layout. -const isMobileOrTabletLayout = (() => { - const { os, device } = result; - if (device.type === 'mobile' || device.type === 'tablet') return true; - if (os.name === 'Android' || os.name === 'iOS') return true; - return false; -})(); +// True only for phone-form-factor devices for layout/nav decisions. +// Tablets (native iPadOS UA or "Request Desktop Website") always get the desktop +// two-panel layout; only phones collapse to the single-panel mobile layout. +const isMobileOrTabletLayout = result.device.type === 'mobile'; const isMac = result.os.name === 'Mac OS'; @@ -36,8 +31,10 @@ export const ua = () => result; export const isMacOS = () => isMac; export const mobileOrTablet = () => isMobileOrTablet; /** - * Like `mobileOrTablet` but excludes iPads using "Request Desktop Website". - * Use this for layout/nav decisions; use `mobileOrTablet` for touch/keyboard behaviour. + * True only for phones. Use this for layout/nav decisions (sidebars, route registration). + * Tablets — whether using native iPadOS UA or iPad "Request Desktop Website" — return false, + * so they always get the full desktop two-panel layout. + * Use `mobileOrTablet` for touch/keyboard/scroll-lock behaviour instead. */ export const mobileOrTabletLayout = () => isMobileOrTabletLayout; From 1d11af90584ce1cc4a6387baccf4655d246e0283 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 13 May 2026 21:27:29 -0400 Subject: [PATCH 5/5] fix(layout): use mobileOrTabletLayout() in page components for isMobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces mobileOrTablet() with mobileOrTabletLayout() for all layout decisions in Home, Direct, Space, Explore, and Inbox page components. mobileOrTablet() returns true for all tablets (including iPad with native UA or 'Request Desktop Website'), so isMobile was true on iPad, causing the room list nav to render at width 100% with no resize handle — giving a broken half-layout where the sidebar took the full width. mobileOrTabletLayout() is true only for phone-form-factor devices, so tablets always get the fixed-width desktop sidebar and SidebarResizer. Space.tsx retains mobileOrTablet() for the swipe-to-room touch gesture, which is appropriate for all touch devices including tablets. --- src/app/pages/client/direct/Direct.tsx | 6 +++--- src/app/pages/client/explore/Explore.tsx | 6 +++--- src/app/pages/client/home/Home.tsx | 6 +++--- src/app/pages/client/inbox/Inbox.tsx | 6 +++--- src/app/pages/client/space/Space.tsx | 6 +++--- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index a8ba93f2b..e0fb1df81 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -54,7 +54,7 @@ import { import { useDirectCreateSelected } from '$hooks/router/useDirectSelected'; import { useDirectRooms } from './useDirectRooms'; import { SidebarResizer } from '$pages/client/sidebar/SidebarResizer'; -import { mobileOrTablet } from '$utils/user-agent'; +import { mobileOrTabletLayout } from '$utils/user-agent'; import { useScreenSizeContext, ScreenSize } from '$hooks/useScreenSize'; type DirectMenuProps = { @@ -254,7 +254,7 @@ export function Direct() { ); const screenSize = useScreenSizeContext(); - const isMobile = mobileOrTablet() || screenSize === ScreenSize.Mobile; + const isMobile = mobileOrTabletLayout() || screenSize === ScreenSize.Mobile; const hideText = curWidth <= 80 && !isMobile; return ( @@ -364,7 +364,7 @@ export function Direct() { )} - {!mobileOrTablet() && ( + {!mobileOrTabletLayout() && ( - {!mobileOrTablet() && ( + {!mobileOrTabletLayout() && ( )} - {!mobileOrTablet() && ( + {!mobileOrTabletLayout() && ( - {!mobileOrTablet() && ( + {!mobileOrTabletLayout() && ( - {!mobileOrTablet() && ( + {!mobileOrTabletLayout() && (