From cd915c849238364d4592c80652d672f60a35305b Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Tue, 21 Jul 2026 13:18:27 +0200 Subject: [PATCH] fix(mobile-drawer): fix double tap on room switch --- .changeset/fix-double-tap.md | 5 + src/app/components/page/MobileNavDrawer.tsx | 236 ++++++------------ src/app/features/room-nav/RoomNavItem.tsx | 37 ++- src/app/generated/tauri/events.ts | 2 - src/app/generated/tauri/types.ts | 1 - src/app/hooks/router/useSelectedRoom.ts | 17 +- src/app/pages/client/direct/Direct.tsx | 4 +- src/app/pages/client/home/Home.tsx | 4 +- .../pages/client/sidebar/DirectDMsList.tsx | 4 +- src/app/pages/client/space/Space.tsx | 4 +- 10 files changed, 139 insertions(+), 175 deletions(-) create mode 100644 .changeset/fix-double-tap.md diff --git a/.changeset/fix-double-tap.md b/.changeset/fix-double-tap.md new file mode 100644 index 0000000000..06f3e1a519 --- /dev/null +++ b/.changeset/fix-double-tap.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fixed needs to double tap a room on android diff --git a/src/app/components/page/MobileNavDrawer.tsx b/src/app/components/page/MobileNavDrawer.tsx index 4ce825939a..0fa0e48777 100644 --- a/src/app/components/page/MobileNavDrawer.tsx +++ b/src/app/components/page/MobileNavDrawer.tsx @@ -1,7 +1,6 @@ import type { ReactNode } from 'react'; -import { startTransition, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { motion, useMotionValue, useReducedMotion } from 'framer-motion'; -import { useDrag } from '@use-gesture/react'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { animate, motion, useDragControls, useMotionValue, useReducedMotion } from 'framer-motion'; import { useAtomValue, useSetAtom } from 'jotai'; import { matchPath, useLocation, useNavigate } from 'react-router-dom'; import { useSetting } from '$state/hooks/settings'; @@ -18,15 +17,13 @@ import { SPACE_ROOM_PATH, } from '$pages/paths'; import { resolveSection } from '$pages/pathUtils'; -import { haptic } from '$utils/haptics'; import { isRoomAlias, isRoomId } from '$utils/matrix'; import { PersistentRoomHost } from './PersistentRoomHost'; -const SLIDE_MS = 300; -const SLIDE_EASE = 'cubic-bezier(0.32, 0.72, 0, 1)'; +const SETTLE_STIFFNESS = 1000; +const SETTLE_DAMPING = 63; const OPEN_FRACTION = 0.35; -const VELOCITY_THRESHOLD = 0.4; -const DIRECTION_DEADZONE = 10; +const VELOCITY_THRESHOLD = 400; type MobileNavDrawerProps = { nav: ReactNode; @@ -35,9 +32,6 @@ type MobileNavDrawerProps = { children: ReactNode; }; -const clamp = (value: number, min: number, max: number): number => - Math.min(Math.max(value, min), max); - /** Sliding mobile drawer: the list and active room are adjacent panels; dragging * reveals the list and commits the route on release. */ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDrawerProps) { @@ -73,12 +67,11 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra const contentOpen = !listView; const viewportRef = useRef(null); - const sliderRef = useRef(null); const navPanelRef = useRef(null); const contentPanelRef = useRef(null); const [width, setWidth] = useState(0); const x = useMotionValue(0); - const draggingRef = useRef(false); + const dragControls = useDragControls(); const initialIntent = contentOpen ? 1 : 0; const [panelIntent, setPanelIntent] = useState(initialIntent); @@ -97,36 +90,22 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra return () => cic(handle as number); }, [isRoomRoute, roomArmed]); - const applyTransition = useCallback((animated: boolean) => { - const el = sliderRef.current; - if (el) el.style.transition = animated ? `transform ${SLIDE_MS}ms ${SLIDE_EASE}` : 'none'; - }, []); - const settle = useCallback( (target: number) => { if (reduceMotion) { - applyTransition(false); x.jump(target); return; } - applyTransition(true); - x.set(target); + animate(x, target, { + type: 'spring', + stiffness: SETTLE_STIFFNESS, + damping: SETTLE_DAMPING, + velocity: 0, + }); }, - [reduceMotion, x, applyTransition] + [reduceMotion, x] ); - const readX = useCallback((): number => { - const el = sliderRef.current; - if (!el) return x.get(); - const t = getComputedStyle(el).transform; - if (!t || t === 'none') return 0; - try { - return new DOMMatrix(t).m41; - } catch { - return x.get(); - } - }, [x]); - useLayoutEffect(() => { const el = viewportRef.current; if (!el) return undefined; @@ -143,7 +122,6 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra }, [panelIntent]); useLayoutEffect(() => { - if (draggingRef.current) return; const routePanel = contentOpen ? 1 : 0; if (routePanel !== panelIntentRef.current) { panelIntentRef.current = routePanel; @@ -152,137 +130,26 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra if (width > 0 && !reduceMotion) { settle(target); } else { - applyTransition(false); x.jump(target); } } - }, [contentOpen, width, x, settle, applyTransition, reduceMotion]); + }, [contentOpen, width, x, settle, reduceMotion]); useLayoutEffect(() => { - if (draggingRef.current) return; const target = panelIntentRef.current === 1 ? -width : 0; - applyTransition(false); x.jump(target); - }, [width, x, applyTransition]); - - const bind = useDrag( - ({ - first, - active, - canceled, - movement: [mx], - offset: [ox], - velocity: [vx], - direction: [dx], - event, - cancel, - }) => { - if (canceled) { - if (draggingRef.current) { - draggingRef.current = false; - settle(panelIntentRef.current === 1 ? -width : 0); - } - return; - } - if (!mobileGestures || width === 0) return; - - const target = event?.target; - if (first && target instanceof HTMLElement && target.closest('[data-gestures="ignore"]')) { - cancel(); - return; - } - - if (panelIntentRef.current === 1) { - if (mx < -DIRECTION_DEADZONE) { - if (draggingRef.current) { - draggingRef.current = false; - settle(-width); - } - cancel(); - return; - } - if (active) { - if (first) { - x.stop(); - applyTransition(false); - } - draggingRef.current = true; - x.set(clamp(ox, -width, 0)); - return; - } - draggingRef.current = false; - const opened = width + ox > width * OPEN_FRACTION || (vx > VELOCITY_THRESHOLD && dx > 0); - if (opened) { - haptic('light'); - panelIntentRef.current = 0; - setPanelIntent(0); - settle(0); - const section = resolveSection(location.pathname); - if (section?.getRoomPath && matchedRoomId && isRoomRoute) { - setLastRoom((prev) => ({ ...prev, [section.key]: matchedRoomId })); - } - if (section) startTransition(() => navigate(section.listPath)); - } else { - settle(-width); - } - return; - } - - if (mx > DIRECTION_DEADZONE) { - cancel(); - return; - } - if (!canOpenRoom && mx < -DIRECTION_DEADZONE) { - cancel(); - return; - } - if (active) { - if (first) { - x.stop(); - applyTransition(false); - if (!roomArmed) setRoomArmed(true); - } - draggingRef.current = true; - x.set(clamp(ox, -width, 0)); - return; - } - draggingRef.current = false; - const wantRoom = -ox > width * OPEN_FRACTION || (vx > VELOCITY_THRESHOLD && dx < 0); - if (wantRoom) { - const section = resolveSection(location.pathname); - if (section?.getRoomPath) { - const lastRoomId = lastRoom?.[section.key]; - if (lastRoomId) { - const roomPath = section.getRoomPath(lastRoomId); - haptic('light'); - panelIntentRef.current = 1; - setPanelIntent(1); - settle(-width); - startTransition(() => navigate(roomPath)); - } else { - settle(0); - } - } else { - settle(0); - } - } else { - settle(0); - } - }, - // axis:'x' + touch-action:pan-y lets the browser keep native vertical scroll. - { - axis: 'x', - filterTaps: true, - tapsThreshold: DIRECTION_DEADZONE, - pointer: { capture: false }, - eventOptions: { passive: true }, - from: () => [readX(), 0], - } - ); + }, [width, x]); return (
{ + if (!mobileGestures || width === 0) return; + const target = event.target as HTMLElement | null; + if (target?.closest('[data-gestures="ignore"]')) return; + // On the list panel, only start drag tracking when a room is available to open. + if (panelIntentRef.current === 0 && !canOpenRoom) return; + dragControls.start(event); + }} ref={viewportRef} style={{ position: 'relative', @@ -291,11 +158,64 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra flexGrow: 1, height: '100%', width: '100%', - touchAction: 'pan-y', + touchAction: 'manipulation', }} > { + x.stop(); + if (panelIntentRef.current === 0 && !roomArmed) setRoomArmed(true); + }} + onDragEnd={(_event, info) => { + const { offset, velocity } = info; + + if (panelIntentRef.current === 1) { + // Room panel → swipe right to reveal the list. + const opened = offset.x > width * OPEN_FRACTION || velocity.x > VELOCITY_THRESHOLD; + if (opened) { + panelIntentRef.current = 0; + setPanelIntent(0); + settle(0); + const section = resolveSection(location.pathname); + if (section?.getRoomPath && matchedRoomId && isRoomRoute) { + setLastRoom((prev) => ({ ...prev, [section.key]: matchedRoomId })); + } + if (section) navigate(section.listPath); + } else { + settle(-width); + } + return; + } + + // List panel → swipe left to open the last visited room. + const wantRoom = -offset.x > width * OPEN_FRACTION || velocity.x < -VELOCITY_THRESHOLD; + if (wantRoom) { + const section = resolveSection(location.pathname); + if (section?.getRoomPath) { + const lastRoomId = lastRoom?.[section.key]; + if (lastRoomId) { + const roomPath = section.getRoomPath(lastRoomId); + panelIntentRef.current = 1; + setPanelIntent(1); + settle(-width); + navigate(roomPath); + } else { + settle(0); + } + } else { + settle(0); + } + } else { + settle(0); + } + }} style={{ x, display: 'flex', height: '100%', willChange: 'transform' }} >
navigate(linkPath)); + if (isMobile) { + navigate(linkPath); + } else { + // Keep heavy room mounts off the urgent path on desktop. + startTransition(() => navigate(linkPath)); + } + } + }; + + // Android WebView suppresses click synthesis after a drag gesture, so the + // first tap on a room row after swiping produces no click event. Navigate + // directly on pointerup when the touch had minimal movement (i.e. a tap). + const navPointerDownRef = useRef<{ x: number; y: number } | null>(null); + const handleNavPointerDown: PointerEventHandler = (evt) => { + warmupRoomDecryption(mx, room.roomId); + if (isMobile && evt.pointerType === 'touch') { + navPointerDownRef.current = { x: evt.clientX, y: evt.clientY }; } }; + const handleNavPointerUp: PointerEventHandler = (evt) => { + if (!isMobile || evt.pointerType !== 'touch' || !navPointerDownRef.current) return; + const down = navPointerDownRef.current; + navPointerDownRef.current = null; + const dx = Math.abs(evt.clientX - down.x); + const dy = Math.abs(evt.clientY - down.y); + if (dx > 10 || dy > 10) return; // was a drag, not a tap + if (room.isCallRoom()) return; // call rooms use onClick + evt.preventDefault(); + navigate(linkPath); + }; const handleChatButtonClick = (evt: MouseEvent) => { evt.stopPropagation(); @@ -484,7 +510,8 @@ export function RoomNavItem({ {(triggerRef) => ( warmupRoomDecryption(mx, room.roomId)} + onPointerDown={handleNavPointerDown} + onPointerUp={handleNavPointerUp} onTouchStart={onTouchStart} onTouchEnd={onTouchEnd} onTouchMove={onTouchMove} diff --git a/src/app/generated/tauri/events.ts b/src/app/generated/tauri/events.ts index f4dcb4717d..453ba9dd07 100644 --- a/src/app/generated/tauri/events.ts +++ b/src/app/generated/tauri/events.ts @@ -26,5 +26,3 @@ export async function onOpenSettings( handler(event.payload); }); } - - diff --git a/src/app/generated/tauri/types.ts b/src/app/generated/tauri/types.ts index 57e0db04a1..93961b8615 100644 --- a/src/app/generated/tauri/types.ts +++ b/src/app/generated/tauri/types.ts @@ -144,4 +144,3 @@ export interface UploadWriteChunkParams { chunk: string; [key: string]: unknown; } - diff --git a/src/app/hooks/router/useSelectedRoom.ts b/src/app/hooks/router/useSelectedRoom.ts index 3aba74835e..b99dafa78a 100644 --- a/src/app/hooks/router/useSelectedRoom.ts +++ b/src/app/hooks/router/useSelectedRoom.ts @@ -1,6 +1,9 @@ -import { useParams } from 'react-router-dom'; +import { useAtomValue } from 'jotai'; +import { matchPath, useLocation, useParams } from 'react-router-dom'; import { getCanonicalAliasRoomId, isRoomAlias } from '$utils/matrix'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { resolveSection } from '$pages/pathUtils'; +import { lastVisitedRoomAtom } from '$state/room/lastRoom'; export const useSelectedRoom = (): string | undefined => { const mx = useMatrixClient(); @@ -14,3 +17,15 @@ export const useSelectedRoom = (): string | undefined => { return roomId; }; + +export const useSelectedOrLastRoom = (): string | undefined => { + const selectedRoomId = useSelectedRoom(); + const location = useLocation(); + const lastVisitedRoom = useAtomValue(lastVisitedRoomAtom); + + if (selectedRoomId) return selectedRoomId; + + const section = resolveSection(location.pathname); + const listMatch = section && matchPath({ path: section.listPath, end: true }, location.pathname); + return listMatch ? lastVisitedRoom[section.key] : undefined; +}; diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index 8ac9ba96f3..2370983bba 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -42,7 +42,7 @@ import { } from '$components/nav'; import { getDirectCreatePath, getDirectRoomPath } from '$pages/pathUtils'; import { getCanonicalAliasOrRoomId } from '$utils/matrix'; -import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; +import { useSelectedOrLastRoom } from '$hooks/router/useSelectedRoom'; import { VirtualTile } from '$components/virtualizer'; import { RoomNavCategoryButton, RoomNavItem } from '$features/room-nav'; import { makeNavCategoryId } from '$state/closedNavCategories'; @@ -213,7 +213,7 @@ export function Direct() { const createDirectSelected = useDirectCreateSelected(); - const selectedRoomId = useSelectedRoom(); + const selectedRoomId = useSelectedOrLastRoom(); const noRoomToDisplay = directs.length === 0; const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index e0ed84784e..e99ed705d8 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -39,7 +39,7 @@ import { withSearchParam, } from '$pages/pathUtils'; import { getCanonicalAliasOrRoomId } from '$utils/matrix'; -import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; +import { useSelectedOrLastRoom } from '$hooks/router/useSelectedRoom'; import { useHomeCreateSelected, useHomeSearchSelected } from '$hooks/router/useHomeSelected'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { VirtualTile } from '$components/virtualizer'; @@ -265,7 +265,7 @@ export function Home() { const [joinCallOnSingleClick] = useSetting(settingsAtom, 'joinCallOnSingleClick'); - const selectedRoomId = useSelectedRoom(); + const selectedRoomId = useSelectedOrLastRoom(); const createRoomSelected = useHomeCreateSelected(); const searchSelected = useHomeSearchSelected(); const noRoomToDisplay = rooms.length === 0; diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index 9b6c196ad2..aa2c233427 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -19,7 +19,7 @@ import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '$utils/room'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { nameInitials } from '$utils/common'; import { getCanonicalAliasOrRoomId, mxcUrlToHttp } from '$utils/matrix'; -import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; +import { useSelectedOrLastRoom } from '$hooks/router/useSelectedRoom'; import { useGroupDMMembers } from '$hooks/useGroupDMMembers'; import { useSidebarDirectRoomIds } from './useSidebarDirectRoomIds'; import * as css from './DirectDMsList.css'; @@ -153,7 +153,7 @@ function DMItem({ room, selected }: DMItemProps) { export function DirectDMsList() { const mx = useMatrixClient(); - const selectedRoomId = useSelectedRoom(); + const selectedRoomId = useSelectedOrLastRoom(); const sidebarRoomIds = useSidebarDirectRoomIds(); const mountTimeRef = useRef(performance.now()); diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index b470f23050..7a5781264a 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -31,7 +31,7 @@ import { mDirectAtom } from '$state/mDirectList'; import { NavCategory, NavCategoryHeader, NavItem, NavItemContent, NavLink } from '$components/nav'; import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '$pages/pathUtils'; import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '$utils/matrix'; -import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; +import { useSelectedOrLastRoom } from '$hooks/router/useSelectedRoom'; import { useSpaceLobbySelected, useSpaceSearchSelected } from '$hooks/router/useSelectedSpace'; import { useSpace } from '$hooks/useSpace'; import { VirtualTile } from '$components/virtualizer'; @@ -562,7 +562,7 @@ export function Space() { const [joinCallOnSingleClick] = useSetting(settingsAtom, 'joinCallOnSingleClick'); const tombstoneEvent = useStateEvent(space, EventType.RoomTombstone); - const selectedRoomId = useSelectedRoom(); + const selectedRoomId = useSelectedOrLastRoom(); const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias); const searchSelected = useSpaceSearchSelected(spaceIdOrAlias); const callEmbed = useCallEmbed();