11import 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' ;
54import { useAtomValue , useSetAtom } from 'jotai' ;
65import { matchPath , useLocation , useNavigate } from 'react-router-dom' ;
76import { useSetting } from '$state/hooks/settings' ;
@@ -18,15 +17,13 @@ import {
1817 SPACE_ROOM_PATH ,
1918} from '$pages/paths' ;
2019import { resolveSection } from '$pages/pathUtils' ;
21- import { haptic } from '$utils/haptics' ;
2220import { isRoomAlias , isRoomId } from '$utils/matrix' ;
2321import { 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 ;
2725const OPEN_FRACTION = 0.35 ;
28- const VELOCITY_THRESHOLD = 0.4 ;
29- const DIRECTION_DEADZONE = 10 ;
26+ const VELOCITY_THRESHOLD = 400 ;
3027
3128type 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. */
4337export 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
0 commit comments