From 3196728c9498a8b98cdcfaca7c1ffe62ed391d50 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 1 Aug 2026 11:22:18 +0200 Subject: [PATCH 1/4] fix(mobile): surface native picker failures instead of swallowing them --- .../features/room/nativeFilePicker.test.ts | 9 ++++++- src/app/features/room/nativeFilePicker.ts | 25 ++++++++++++++++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/app/features/room/nativeFilePicker.test.ts b/src/app/features/room/nativeFilePicker.test.ts index b1038c8b0..46938f7d6 100644 --- a/src/app/features/room/nativeFilePicker.test.ts +++ b/src/app/features/room/nativeFilePicker.test.ts @@ -118,7 +118,14 @@ describe('pickNativeFile', () => { mocks.open.mockRejectedValue(error); await expect(pickNativeFile('media')).rejects.toBe(error); - expect(readFile).not.toHaveBeenCalled(); + expect(mocks.readFile).not.toHaveBeenCalled(); + }); + + it('throws instead of silently dropping entries that are not paths', async () => { + mocks.open.mockResolvedValue([{ relative: 'file:///photos/img.HEIC' }] as never); + + await expect(pickNativeFile('media')).rejects.toThrow('unusable entries'); + expect(mocks.readFile).not.toHaveBeenCalled(); }); it('opens the native document picker for documents', async () => { diff --git a/src/app/features/room/nativeFilePicker.ts b/src/app/features/room/nativeFilePicker.ts index 07e3223c6..93ba65176 100644 --- a/src/app/features/room/nativeFilePicker.ts +++ b/src/app/features/room/nativeFilePicker.ts @@ -1,6 +1,9 @@ +import { createLogger } from '$utils/debug'; import { FALLBACK_MIMETYPE, TGS_MIMETYPE } from '$utils/mimeTypes'; import { isAndroidTauri } from '$utils/platform'; +const log = createLogger('nativeFilePicker'); + const MIME_TYPES_BY_EXTENSION: Record = { aac: 'audio/aac', apng: 'image/apng', @@ -38,8 +41,19 @@ const MEDIA_MIME_TYPES = ['image/*', 'video/*']; export type NativePickerMode = 'media' | 'document'; export type NativeFileFailureHandler = (source: string, error: unknown) => void; -const normalizeSelectedPaths = (selected: string | string[] | null | undefined): string[] => - (typeof selected === 'string' ? [selected] : (selected ?? [])).filter((path) => path.length > 0); +const normalizeSelectedPaths = (selected: unknown): string[] => { + if (selected === null || selected === undefined) return []; + + const values = Array.isArray(selected) ? (selected as unknown[]) : [selected]; + const paths = values.filter( + (value): value is string => typeof value === 'string' && value.length > 0 + ); + if (paths.length !== values.length) { + throw new Error(`Native picker returned unusable entries: ${JSON.stringify(selected)}`); + } + + return paths; +}; const getFileName = (path: string, index: number): string => { const pathName = path.split(/[\\/]/).pop(); @@ -103,8 +117,13 @@ const pickIosFiles = async ( ): Promise => { const { open } = await import('@tauri-apps/plugin-dialog'); const selected = await open({ pickerMode, multiple: true }); + log.log('picker returned', pickerMode, typeof selected, selected); + const paths = normalizeSelectedPaths(selected); - if (paths.length === 0) return []; + if (paths.length === 0) { + log.warn('picker resolved without any path (cancelled, or a swallowed native failure)'); + return []; + } const { readFile, remove } = await import('@tauri-apps/plugin-fs'); const files = await Promise.all( From 6cb29628df4e39b7f285469dad317010c5306b09 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 1 Aug 2026 16:09:52 +0200 Subject: [PATCH 2/4] fix(tauri): keep desktop message menus as popouts --- src/app/utils/platform.test.ts | 22 +++++++++++++++++++++- src/app/utils/platform.ts | 1 + 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/app/utils/platform.test.ts b/src/app/utils/platform.test.ts index 263793cee..1ba9f3351 100644 --- a/src/app/utils/platform.test.ts +++ b/src/app/utils/platform.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { getAppOrigin, getWindowOrigin } from './platform'; +import { getAppOrigin, getWindowOrigin, isMobileOrTablet, ua } from './platform'; import { isTauri } from '@tauri-apps/api/core'; +import { type as osType } from '@tauri-apps/plugin-os'; vi.mock('@tauri-apps/api/core', () => ({ isTauri: vi.fn<() => boolean>(), @@ -45,6 +46,25 @@ describe('getAppOrigin', () => { }); }); +describe('isMobileOrTablet', () => { + const originalDeviceType = ua.device.type; + const originalOsName = ua.os.name; + + afterEach(() => { + ua.device.type = originalDeviceType; + ua.os.name = originalOsName; + }); + + it('uses the desktop Tauri OS instead of a mobile-looking WebView user agent', () => { + vi.mocked(isTauri).mockReturnValue(true); + vi.mocked(osType).mockReturnValue('windows'); + ua.device.type = 'mobile'; + ua.os.name = 'Android'; + + expect(isMobileOrTablet()).toBe(false); + }); +}); + describe('getWindowOrigin', () => { afterEach(() => { vi.unstubAllGlobals(); diff --git a/src/app/utils/platform.ts b/src/app/utils/platform.ts index 9c278c885..30ac40b51 100644 --- a/src/app/utils/platform.ts +++ b/src/app/utils/platform.ts @@ -27,6 +27,7 @@ const DESKTOP_TAURI_OS = ['linux', 'macos', 'windows'] as const; export function isMobileOrTablet(): boolean { const tauriOS = getTauriOS(); if (tauriOS && (MOBILE_TAURI_OS as readonly string[]).includes(tauriOS)) return true; + if (tauriOS && (DESKTOP_TAURI_OS as readonly string[]).includes(tauriOS)) return false; const { os, device } = ua; if (device.type === 'mobile' || device.type === 'tablet') return true; From 0b1da6bd6c0d2c542c9336543ac3f45d59831071 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 1 Aug 2026 15:38:38 +0200 Subject: [PATCH 3/4] fix(mobile): restore sheet scrolling --- src/app/components/MobileSwipeDownModal.test.tsx | 12 ++++++++---- src/app/components/MobileSwipeDownModal.tsx | 16 +++++++++------- src/app/components/ResponsiveMenu.css.ts | 9 ++++++++- src/app/components/user-profile/PowerChip.tsx | 4 ++++ src/app/components/user-profile/UserChips.tsx | 13 +++++++++++-- .../components/user-profile/UserRoomProfile.tsx | 11 ++++++++++- .../features/room/room-pin-menu/RoomPinMenu.tsx | 1 - 7 files changed, 50 insertions(+), 16 deletions(-) diff --git a/src/app/components/MobileSwipeDownModal.test.tsx b/src/app/components/MobileSwipeDownModal.test.tsx index b9dee4051..ab1e1acc4 100644 --- a/src/app/components/MobileSwipeDownModal.test.tsx +++ b/src/app/components/MobileSwipeDownModal.test.tsx @@ -468,7 +468,7 @@ describe('MobileSwipeDownModal', () => { } }); - it('leaves a marked scroll container to native scrolling at its top', async () => { + it('leaves the gesture to an outer list scrolled away from the top', async () => { const requestClose = vi.fn<() => void>(); const restore = stubElementAnimations(); @@ -476,15 +476,19 @@ describe('MobileSwipeDownModal', () => { render( {() => ( -
-
+
+
+
+
)} ); await act(async () => {}); + makeScrollable(screen.getByTestId('outer-scroller'), 120); + makeScrollable(screen.getByTestId('inner-scroller'), 0); - dragDown(screen.getByTestId('marked-scroll-row'), 100, 240); + dragDown(screen.getByTestId('scroll-row'), 100, 240); expect(requestClose).not.toHaveBeenCalled(); } finally { diff --git a/src/app/components/MobileSwipeDownModal.tsx b/src/app/components/MobileSwipeDownModal.tsx index 0b5126f81..04c92e578 100644 --- a/src/app/components/MobileSwipeDownModal.tsx +++ b/src/app/components/MobileSwipeDownModal.tsx @@ -52,20 +52,21 @@ function getKeyboardOverlap(): number { return Math.max(0, window.innerHeight - viewport.offsetTop - viewport.height); } -/** Nearest ancestor between `from` and the sheet that can actually scroll vertically. */ -function findScroller(from: HTMLElement | null, boundary: HTMLElement): HTMLElement | null { +/** Whether any scrollable ancestor is scrolled away from its top edge. */ +function hasScrolledAncestor(from: HTMLElement | null, boundary: HTMLElement): boolean { let element = from; while (element && element !== boundary) { const { overflowY } = window.getComputedStyle(element); if ( (overflowY === 'auto' || overflowY === 'scroll') && - element.scrollHeight > element.clientHeight + element.scrollHeight > element.clientHeight && + element.scrollTop > 0 ) { - return element; + return true; } element = element.parentElement; } - return null; + return false; } export function useMobileSheetClose() { @@ -240,12 +241,13 @@ export function MobileSwipeDownModal({ const from = target instanceof HTMLElement ? target : null; // The handle is not over any content, so it always drags. if (from?.closest(`[${HANDLE_ATTRIBUTE}]`)) return true; + // Mobile menu actions activate on pointer-up in Android WebView, so dismissing + // from one could also invoke the action. if (from?.closest(`[${NO_DRAG_ATTRIBUTE}]`)) return false; if (window.getSelection()?.toString()) return false; if (Date.now() - dragBlockedAtRef.current < DRAG_BLOCKED_COOLDOWN_MS) return false; - const scroller = findScroller(from, sheet); - if (scroller && scroller.scrollTop > 0) { + if (hasScrolledAncestor(from, sheet)) { dragBlockedAtRef.current = Date.now(); return false; } diff --git a/src/app/components/ResponsiveMenu.css.ts b/src/app/components/ResponsiveMenu.css.ts index 19f4479ba..8c77ffd5c 100644 --- a/src/app/components/ResponsiveMenu.css.ts +++ b/src/app/components/ResponsiveMenu.css.ts @@ -32,9 +32,10 @@ export const SheetContent = style({ // drag handle. globalStyle(`${SheetContent} > *:last-child`, { // !important beats the inline maxWidth/width the callers set for their desktop popout. + // Do not override maxHeight: callers use it to give nested Scroll elements a + // definite scrollport. width: '100% !important', maxWidth: 'none !important', - maxHeight: '100%', position: 'relative', display: 'flex', flexDirection: 'column', @@ -45,3 +46,9 @@ globalStyle(`${SheetContent} > *:last-child`, { paddingTop: '0 !important', paddingBottom: `${config.space.S400} !important`, }); + +// Desktop menus often constrain direct content groups instead of the menu itself. +// Let those groups stretch with the mobile sheet while preserving their desktop width. +globalStyle(`${SheetContent} > *:last-child > *`, { + maxWidth: 'none !important', +}); diff --git a/src/app/components/user-profile/PowerChip.tsx b/src/app/components/user-profile/PowerChip.tsx index cb9a9a8a2..060fa0521 100644 --- a/src/app/components/user-profile/PowerChip.tsx +++ b/src/app/components/user-profile/PowerChip.tsx @@ -219,6 +219,10 @@ export function PowerChip({ style={{ padding: config.space.S100, maxWidth: toRem(200), + maxHeight: 'calc(80vh - 5rem)', + overflowY: 'auto', + overscrollBehavior: 'contain', + touchAction: 'pan-y', backgroundColor: innerColor, }} > diff --git a/src/app/components/user-profile/UserChips.tsx b/src/app/components/user-profile/UserChips.tsx index 5b4051f86..5df6af1c2 100644 --- a/src/app/components/user-profile/UserChips.tsx +++ b/src/app/components/user-profile/UserChips.tsx @@ -481,8 +481,17 @@ export function MutualRoomsChip({ backgroundColor: innerColor, }} > - - + + + ( Date: Sat, 1 Aug 2026 15:59:09 +0200 Subject: [PATCH 4/4] fix(mobile): overlay profile sheet handle --- src/app/components/MobileSwipeDownModal.test.tsx | 16 ++++++++++++++++ src/app/components/MobileSwipeDownModal.tsx | 9 ++++++++- src/app/components/ResponsiveMenu.tsx | 3 +++ src/app/components/UserRoomProfileRenderer.tsx | 1 + 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/app/components/MobileSwipeDownModal.test.tsx b/src/app/components/MobileSwipeDownModal.test.tsx index ab1e1acc4..32ae25282 100644 --- a/src/app/components/MobileSwipeDownModal.test.tsx +++ b/src/app/components/MobileSwipeDownModal.test.tsx @@ -162,6 +162,22 @@ describe('MobileSwipeDownModal', () => { expect(panel).toHaveStyle({ backgroundColor: '#663399' }); }); + it('can overlay the drag handle without reserving a header row', () => { + render( + void>()} overlayDragHandle> + {() =>
} + + ); + + expect(screen.getByTestId('mobile-sheet-drag-handle')).toHaveStyle({ + position: 'absolute', + top: '0px', + }); + expect(screen.getByTestId('under-handle-content').parentElement?.parentElement).toHaveStyle({ + overflow: 'hidden', + }); + }); + it('lifts the sheet clear of the keyboard with a transition, without resizing it', () => { const viewport = mockVisualViewport(800); let contentRenderCount = 0; diff --git a/src/app/components/MobileSwipeDownModal.tsx b/src/app/components/MobileSwipeDownModal.tsx index 04c92e578..80cc1fdc7 100644 --- a/src/app/components/MobileSwipeDownModal.tsx +++ b/src/app/components/MobileSwipeDownModal.tsx @@ -29,6 +29,7 @@ interface MobileSwipeDownModalProps { sheetStyle?: CSSProperties; keyboardAware?: boolean; zIndex?: number; + overlayDragHandle?: boolean; } type FocusTrapOptions = ComponentProps['focusTrapOptions']; @@ -105,6 +106,7 @@ export function MobileSwipeDownModal({ sheetStyle, keyboardAware = false, zIndex, + overlayDragHandle = false, }: MobileSwipeDownModalProps) { const sheetRef = useRef(null); const backdropTouchRef = useRef(false); @@ -310,6 +312,7 @@ export function MobileSwipeDownModal({ className={css.MessageMobileDragHandle} data-gestures="ignore" data-testid="mobile-sheet-drag-handle" + style={overlayDragHandle ? { position: 'absolute', top: 0, right: 0, left: 0 } : undefined} {...{ [HANDLE_ATTRIBUTE]: '' }} >
@@ -383,7 +386,11 @@ export function MobileSwipeDownModal({ className={`${css.MessageMobileOptionsContainer} ${sheetClassName ?? ''} ${ portalRef ? css.MessageMobileOptionsContainerContained : '' } ${animationCss.SheetEntrance}`} - style={{ ...(shouldReduceMotion ? { animation: 'none' } : undefined), ...sheetStyle }} + style={{ + ...(overlayDragHandle ? { overflow: 'hidden' } : undefined), + ...(shouldReduceMotion ? { animation: 'none' } : undefined), + ...sheetStyle, + }} onPointerDown={(e: React.PointerEvent) => e.stopPropagation()} onPointerMove={(e: React.PointerEvent) => e.stopPropagation()} onPointerUp={(e: React.PointerEvent) => e.stopPropagation()} diff --git a/src/app/components/ResponsiveMenu.tsx b/src/app/components/ResponsiveMenu.tsx index d430685df..670df7af0 100644 --- a/src/app/components/ResponsiveMenu.tsx +++ b/src/app/components/ResponsiveMenu.tsx @@ -33,6 +33,7 @@ type ResponsiveMenuProps = { surfaceColor?: string; /** Raises a mobile sheet above a parent fullscreen overlay. */ mobileZIndex?: number; + overlayDragHandle?: boolean; }; function MenuDialog({ @@ -139,6 +140,7 @@ export function ResponsiveMenu({ mobile = 'sheet', surfaceColor, mobileZIndex, + overlayDragHandle = false, }: ResponsiveMenuProps) { const isMobile = useCompactLayout(); @@ -195,6 +197,7 @@ export function ResponsiveMenu({ requestClose={requestClose} sheetStyle={sheetStyle} zIndex={mobileZIndex} + overlayDragHandle={overlayDragHandle} > {() => ( window.innerHeight / 2 ? 'End' : 'Start'} returnFocusOnDeactivate surfaceColor={surfaceColor} + overlayDragHandle menu={