Skip to content

Commit cfa29dc

Browse files
authored
fix(mobile-drawer): fix double tap on room switch (#1175)
<!-- Please read https://github.com/SableClient/Sable/blob/dev/CONTRIBUTING.md before submitting your pull request --> ### Description <!-- Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. --> Fixes # #### Type of change - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] This change requires a documentation update ### Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my own code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings ### AI disclosure: - [ ] Partially AI assisted (clarify which code was AI assisted and briefly explain what it does). - [ ] Fully AI generated (explain what all the generated code does in moderate detail). <!-- Write any explanation required here, but do not generate the explanation using AI!! You must prove you understand what the code in this PR does. -->
2 parents dce31d4 + cd915c8 commit cfa29dc

10 files changed

Lines changed: 139 additions & 175 deletions

File tree

.changeset/fix-double-tap.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
default: patch
3+
---
4+
5+
Fixed needs to double tap a room on android

src/app/components/page/MobileNavDrawer.tsx

Lines changed: 78 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { ReactNode } from 'react';
2-
import { startTransition, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
3-
import { motion, useMotionValue, useReducedMotion } from 'framer-motion';
4-
import { useDrag } from '@use-gesture/react';
2+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
3+
import { animate, motion, useDragControls, useMotionValue, useReducedMotion } from 'framer-motion';
54
import { useAtomValue, useSetAtom } from 'jotai';
65
import { matchPath, useLocation, useNavigate } from 'react-router-dom';
76
import { useSetting } from '$state/hooks/settings';
@@ -18,15 +17,13 @@ import {
1817
SPACE_ROOM_PATH,
1918
} from '$pages/paths';
2019
import { resolveSection } from '$pages/pathUtils';
21-
import { haptic } from '$utils/haptics';
2220
import { isRoomAlias, isRoomId } from '$utils/matrix';
2321
import { PersistentRoomHost } from './PersistentRoomHost';
2422

25-
const SLIDE_MS = 300;
26-
const SLIDE_EASE = 'cubic-bezier(0.32, 0.72, 0, 1)';
23+
const SETTLE_STIFFNESS = 1000;
24+
const SETTLE_DAMPING = 63;
2725
const OPEN_FRACTION = 0.35;
28-
const VELOCITY_THRESHOLD = 0.4;
29-
const DIRECTION_DEADZONE = 10;
26+
const VELOCITY_THRESHOLD = 400;
3027

3128
type MobileNavDrawerProps = {
3229
nav: ReactNode;
@@ -35,9 +32,6 @@ type MobileNavDrawerProps = {
3532
children: ReactNode;
3633
};
3734

38-
const clamp = (value: number, min: number, max: number): number =>
39-
Math.min(Math.max(value, min), max);
40-
4135
/** Sliding mobile drawer: the list and active room are adjacent panels; dragging
4236
* reveals the list and commits the route on release. */
4337
export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDrawerProps) {
@@ -73,12 +67,11 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
7367
const contentOpen = !listView;
7468

7569
const viewportRef = useRef<HTMLDivElement | null>(null);
76-
const sliderRef = useRef<HTMLDivElement | null>(null);
7770
const navPanelRef = useRef<HTMLDivElement | null>(null);
7871
const contentPanelRef = useRef<HTMLDivElement | null>(null);
7972
const [width, setWidth] = useState(0);
8073
const x = useMotionValue(0);
81-
const draggingRef = useRef(false);
74+
const dragControls = useDragControls();
8275

8376
const initialIntent = contentOpen ? 1 : 0;
8477
const [panelIntent, setPanelIntent] = useState(initialIntent);
@@ -97,36 +90,22 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
9790
return () => cic(handle as number);
9891
}, [isRoomRoute, roomArmed]);
9992

100-
const applyTransition = useCallback((animated: boolean) => {
101-
const el = sliderRef.current;
102-
if (el) el.style.transition = animated ? `transform ${SLIDE_MS}ms ${SLIDE_EASE}` : 'none';
103-
}, []);
104-
10593
const settle = useCallback(
10694
(target: number) => {
10795
if (reduceMotion) {
108-
applyTransition(false);
10996
x.jump(target);
11097
return;
11198
}
112-
applyTransition(true);
113-
x.set(target);
99+
animate(x, target, {
100+
type: 'spring',
101+
stiffness: SETTLE_STIFFNESS,
102+
damping: SETTLE_DAMPING,
103+
velocity: 0,
104+
});
114105
},
115-
[reduceMotion, x, applyTransition]
106+
[reduceMotion, x]
116107
);
117108

118-
const readX = useCallback((): number => {
119-
const el = sliderRef.current;
120-
if (!el) return x.get();
121-
const t = getComputedStyle(el).transform;
122-
if (!t || t === 'none') return 0;
123-
try {
124-
return new DOMMatrix(t).m41;
125-
} catch {
126-
return x.get();
127-
}
128-
}, [x]);
129-
130109
useLayoutEffect(() => {
131110
const el = viewportRef.current;
132111
if (!el) return undefined;
@@ -143,7 +122,6 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
143122
}, [panelIntent]);
144123

145124
useLayoutEffect(() => {
146-
if (draggingRef.current) return;
147125
const routePanel = contentOpen ? 1 : 0;
148126
if (routePanel !== panelIntentRef.current) {
149127
panelIntentRef.current = routePanel;
@@ -152,137 +130,26 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
152130
if (width > 0 && !reduceMotion) {
153131
settle(target);
154132
} else {
155-
applyTransition(false);
156133
x.jump(target);
157134
}
158135
}
159-
}, [contentOpen, width, x, settle, applyTransition, reduceMotion]);
136+
}, [contentOpen, width, x, settle, reduceMotion]);
160137

161138
useLayoutEffect(() => {
162-
if (draggingRef.current) return;
163139
const target = panelIntentRef.current === 1 ? -width : 0;
164-
applyTransition(false);
165140
x.jump(target);
166-
}, [width, x, applyTransition]);
167-
168-
const bind = useDrag(
169-
({
170-
first,
171-
active,
172-
canceled,
173-
movement: [mx],
174-
offset: [ox],
175-
velocity: [vx],
176-
direction: [dx],
177-
event,
178-
cancel,
179-
}) => {
180-
if (canceled) {
181-
if (draggingRef.current) {
182-
draggingRef.current = false;
183-
settle(panelIntentRef.current === 1 ? -width : 0);
184-
}
185-
return;
186-
}
187-
if (!mobileGestures || width === 0) return;
188-
189-
const target = event?.target;
190-
if (first && target instanceof HTMLElement && target.closest('[data-gestures="ignore"]')) {
191-
cancel();
192-
return;
193-
}
194-
195-
if (panelIntentRef.current === 1) {
196-
if (mx < -DIRECTION_DEADZONE) {
197-
if (draggingRef.current) {
198-
draggingRef.current = false;
199-
settle(-width);
200-
}
201-
cancel();
202-
return;
203-
}
204-
if (active) {
205-
if (first) {
206-
x.stop();
207-
applyTransition(false);
208-
}
209-
draggingRef.current = true;
210-
x.set(clamp(ox, -width, 0));
211-
return;
212-
}
213-
draggingRef.current = false;
214-
const opened = width + ox > width * OPEN_FRACTION || (vx > VELOCITY_THRESHOLD && dx > 0);
215-
if (opened) {
216-
haptic('light');
217-
panelIntentRef.current = 0;
218-
setPanelIntent(0);
219-
settle(0);
220-
const section = resolveSection(location.pathname);
221-
if (section?.getRoomPath && matchedRoomId && isRoomRoute) {
222-
setLastRoom((prev) => ({ ...prev, [section.key]: matchedRoomId }));
223-
}
224-
if (section) startTransition(() => navigate(section.listPath));
225-
} else {
226-
settle(-width);
227-
}
228-
return;
229-
}
230-
231-
if (mx > DIRECTION_DEADZONE) {
232-
cancel();
233-
return;
234-
}
235-
if (!canOpenRoom && mx < -DIRECTION_DEADZONE) {
236-
cancel();
237-
return;
238-
}
239-
if (active) {
240-
if (first) {
241-
x.stop();
242-
applyTransition(false);
243-
if (!roomArmed) setRoomArmed(true);
244-
}
245-
draggingRef.current = true;
246-
x.set(clamp(ox, -width, 0));
247-
return;
248-
}
249-
draggingRef.current = false;
250-
const wantRoom = -ox > width * OPEN_FRACTION || (vx > VELOCITY_THRESHOLD && dx < 0);
251-
if (wantRoom) {
252-
const section = resolveSection(location.pathname);
253-
if (section?.getRoomPath) {
254-
const lastRoomId = lastRoom?.[section.key];
255-
if (lastRoomId) {
256-
const roomPath = section.getRoomPath(lastRoomId);
257-
haptic('light');
258-
panelIntentRef.current = 1;
259-
setPanelIntent(1);
260-
settle(-width);
261-
startTransition(() => navigate(roomPath));
262-
} else {
263-
settle(0);
264-
}
265-
} else {
266-
settle(0);
267-
}
268-
} else {
269-
settle(0);
270-
}
271-
},
272-
// axis:'x' + touch-action:pan-y lets the browser keep native vertical scroll.
273-
{
274-
axis: 'x',
275-
filterTaps: true,
276-
tapsThreshold: DIRECTION_DEADZONE,
277-
pointer: { capture: false },
278-
eventOptions: { passive: true },
279-
from: () => [readX(), 0],
280-
}
281-
);
141+
}, [width, x]);
282142

283143
return (
284144
<div
285-
{...bind()}
145+
onPointerDown={(event) => {
146+
if (!mobileGestures || width === 0) return;
147+
const target = event.target as HTMLElement | null;
148+
if (target?.closest('[data-gestures="ignore"]')) return;
149+
// On the list panel, only start drag tracking when a room is available to open.
150+
if (panelIntentRef.current === 0 && !canOpenRoom) return;
151+
dragControls.start(event);
152+
}}
286153
ref={viewportRef}
287154
style={{
288155
position: 'relative',
@@ -291,11 +158,64 @@ export function MobileNavDrawer({ nav, rail, bottomNav, children }: MobileNavDra
291158
flexGrow: 1,
292159
height: '100%',
293160
width: '100%',
294-
touchAction: 'pan-y',
161+
touchAction: 'manipulation',
295162
}}
296163
>
297164
<motion.div
298-
ref={sliderRef}
165+
drag={mobileGestures ? 'x' : false}
166+
dragControls={dragControls}
167+
dragListener={false}
168+
dragConstraints={{ left: -width, right: 0 }}
169+
dragDirectionLock
170+
dragElastic={0.05}
171+
dragMomentum={false}
172+
onDragStart={() => {
173+
x.stop();
174+
if (panelIntentRef.current === 0 && !roomArmed) setRoomArmed(true);
175+
}}
176+
onDragEnd={(_event, info) => {
177+
const { offset, velocity } = info;
178+
179+
if (panelIntentRef.current === 1) {
180+
// Room panel → swipe right to reveal the list.
181+
const opened = offset.x > width * OPEN_FRACTION || velocity.x > VELOCITY_THRESHOLD;
182+
if (opened) {
183+
panelIntentRef.current = 0;
184+
setPanelIntent(0);
185+
settle(0);
186+
const section = resolveSection(location.pathname);
187+
if (section?.getRoomPath && matchedRoomId && isRoomRoute) {
188+
setLastRoom((prev) => ({ ...prev, [section.key]: matchedRoomId }));
189+
}
190+
if (section) navigate(section.listPath);
191+
} else {
192+
settle(-width);
193+
}
194+
return;
195+
}
196+
197+
// List panel → swipe left to open the last visited room.
198+
const wantRoom = -offset.x > width * OPEN_FRACTION || velocity.x < -VELOCITY_THRESHOLD;
199+
if (wantRoom) {
200+
const section = resolveSection(location.pathname);
201+
if (section?.getRoomPath) {
202+
const lastRoomId = lastRoom?.[section.key];
203+
if (lastRoomId) {
204+
const roomPath = section.getRoomPath(lastRoomId);
205+
panelIntentRef.current = 1;
206+
setPanelIntent(1);
207+
settle(-width);
208+
navigate(roomPath);
209+
} else {
210+
settle(0);
211+
}
212+
} else {
213+
settle(0);
214+
}
215+
} else {
216+
settle(0);
217+
}
218+
}}
299219
style={{ x, display: 'flex', height: '100%', willChange: 'transform' }}
300220
>
301221
<div

src/app/features/room-nav/RoomNavItem.tsx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { MouseEventHandler, MouseEvent } from 'react';
2-
import { forwardRef, startTransition, useState, useEffect } from 'react';
1+
import type { MouseEventHandler, MouseEvent, PointerEventHandler } from 'react';
2+
import { forwardRef, startTransition, useState, useEffect, useRef } from 'react';
33
import type { Room } from '$types/matrix-sdk';
44
import { RoomEvent as RoomEventEnum } from '$types/matrix-sdk';
55
import type { RectCords } from 'folds';
@@ -413,10 +413,36 @@ export function RoomNavItem({
413413
navigateRoom(room.roomId);
414414
}
415415
} else {
416-
// Render the room off the urgent path so the tap doesn't freeze the UI on mount.
417-
startTransition(() => navigate(linkPath));
416+
if (isMobile) {
417+
navigate(linkPath);
418+
} else {
419+
// Keep heavy room mounts off the urgent path on desktop.
420+
startTransition(() => navigate(linkPath));
421+
}
422+
}
423+
};
424+
425+
// Android WebView suppresses click synthesis after a drag gesture, so the
426+
// first tap on a room row after swiping produces no click event. Navigate
427+
// directly on pointerup when the touch had minimal movement (i.e. a tap).
428+
const navPointerDownRef = useRef<{ x: number; y: number } | null>(null);
429+
const handleNavPointerDown: PointerEventHandler<HTMLElement> = (evt) => {
430+
warmupRoomDecryption(mx, room.roomId);
431+
if (isMobile && evt.pointerType === 'touch') {
432+
navPointerDownRef.current = { x: evt.clientX, y: evt.clientY };
418433
}
419434
};
435+
const handleNavPointerUp: PointerEventHandler<HTMLElement> = (evt) => {
436+
if (!isMobile || evt.pointerType !== 'touch' || !navPointerDownRef.current) return;
437+
const down = navPointerDownRef.current;
438+
navPointerDownRef.current = null;
439+
const dx = Math.abs(evt.clientX - down.x);
440+
const dy = Math.abs(evt.clientY - down.y);
441+
if (dx > 10 || dy > 10) return; // was a drag, not a tap
442+
if (room.isCallRoom()) return; // call rooms use onClick
443+
evt.preventDefault();
444+
navigate(linkPath);
445+
};
420446

421447
const handleChatButtonClick = (evt: MouseEvent<HTMLButtonElement>) => {
422448
evt.stopPropagation();
@@ -484,7 +510,8 @@ export function RoomNavItem({
484510
{(triggerRef) => (
485511
<NavButton
486512
onClick={handleNavItemClick}
487-
onPointerDown={() => warmupRoomDecryption(mx, room.roomId)}
513+
onPointerDown={handleNavPointerDown}
514+
onPointerUp={handleNavPointerUp}
488515
onTouchStart={onTouchStart}
489516
onTouchEnd={onTouchEnd}
490517
onTouchMove={onTouchMove}

src/app/generated/tauri/events.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,3 @@ export async function onOpenSettings(
2626
handler(event.payload);
2727
});
2828
}
29-
30-

src/app/generated/tauri/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,4 +144,3 @@ export interface UploadWriteChunkParams {
144144
chunk: string;
145145
[key: string]: unknown;
146146
}
147-

0 commit comments

Comments
 (0)