From 04b2b6d755ef2747ed217c76d44c33b84ff734cd Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 1 Aug 2026 15:38:38 +0200 Subject: [PATCH 1/2] 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 31d8d34f3..1855d1ad0 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 678f9cdad..ee051bf1e 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 2/2] 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 51b1620d2..1105e123c 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({ @@ -141,6 +142,7 @@ export function ResponsiveMenu({ mobile = 'sheet', surfaceColor, mobileZIndex, + overlayDragHandle = false, }: ResponsiveMenuProps) { // Null outside a provider, where desktop is the safe assumption. const isMobile = useScreenSizeOptionally() === ScreenSize.Mobile; @@ -198,6 +200,7 @@ export function ResponsiveMenu({ requestClose={requestClose} sheetStyle={sheetStyle} zIndex={mobileZIndex} + overlayDragHandle={overlayDragHandle} > {() => ( window.innerHeight / 2 ? 'End' : 'Start'} returnFocusOnDeactivate surfaceColor={surfaceColor} + overlayDragHandle menu={