diff --git a/.gitignore b/.gitignore index 1c1104414..0f2a832f2 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ devAssets *.tfbackend !*.tfbackend.example crash.log + +.worktrees \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 5683a6d19..d1181888e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,6 @@ "file-saver": "^2.0.5", "focus-trap-react": "^10.3.1", "folds": "^2.6.1", - "framer-motion": "12.34.3", "html-dom-parser": "^5.1.8", "html-react-parser": "^4.2.10", "i18next": "^25.8.13", @@ -50,6 +49,7 @@ "matrix-js-sdk": "^38.4.0", "matrix-widget-api": "1.13.0", "millify": "^6.1.0", + "motion": "12.35.1", "pdfjs-dist": "^5.4.624", "prismjs": "^1.30.0", "react": "^18.3.1", @@ -11153,12 +11153,12 @@ } }, "node_modules/framer-motion": { - "version": "12.34.3", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz", - "integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==", + "version": "12.35.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.35.1.tgz", + "integrity": "sha512-rL8cLrjYZNShZqKV3U0Qj6Y5WDiZXYEM5giiTLfEqsIZxtspzMDCkKmrO5po76jWfvOg04+Vk+sfBvTD0iMmLw==", "license": "MIT", "dependencies": { - "motion-dom": "^12.34.3", + "motion-dom": "^12.35.1", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, @@ -12939,10 +12939,36 @@ "integrity": "sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==", "license": "MIT" }, + "node_modules/motion": { + "version": "12.35.1", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.35.1.tgz", + "integrity": "sha512-yEt/49kWC0VU/IEduDfeZw82eDemlPwa1cyo/gcEEUCN4WgpSJpUcxz6BUwakGabvJiTzLQ58J73515I5tfykQ==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.35.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/motion-dom": { - "version": "12.34.3", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz", - "integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==", + "version": "12.35.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.35.1.tgz", + "integrity": "sha512-7n6r7TtNOsH2UFSAXzTkfzOeO5616v9B178qBIjmu/WgEyJK0uqwytCEhwKBTuM/HJA40ptAw7hLFpxtPAMRZQ==", "license": "MIT", "dependencies": { "motion-utils": "^12.29.2" @@ -23372,11 +23398,11 @@ } }, "framer-motion": { - "version": "12.34.3", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz", - "integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==", + "version": "12.35.1", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.35.1.tgz", + "integrity": "sha512-rL8cLrjYZNShZqKV3U0Qj6Y5WDiZXYEM5giiTLfEqsIZxtspzMDCkKmrO5po76jWfvOg04+Vk+sfBvTD0iMmLw==", "requires": { - "motion-dom": "^12.34.3", + "motion-dom": "^12.35.1", "motion-utils": "^12.29.2", "tslib": "^2.4.0" } @@ -24408,7 +24434,7 @@ "jwt-decode": "^4.0.0", "loglevel": "^1.9.2", "matrix-events-sdk": "0.0.1", - "matrix-widget-api": "^1.10.0", + "matrix-widget-api": "1.13.0", "oidc-client-ts": "^3.0.1", "p-retry": "7", "sdp-transform": "^2.14.1", @@ -24522,10 +24548,19 @@ "resolved": "https://registry.npmjs.org/modern-ahocorasick/-/modern-ahocorasick-1.1.0.tgz", "integrity": "sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==" }, + "motion": { + "version": "12.35.1", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.35.1.tgz", + "integrity": "sha512-yEt/49kWC0VU/IEduDfeZw82eDemlPwa1cyo/gcEEUCN4WgpSJpUcxz6BUwakGabvJiTzLQ58J73515I5tfykQ==", + "requires": { + "framer-motion": "^12.35.1", + "tslib": "^2.4.0" + } + }, "motion-dom": { - "version": "12.34.3", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz", - "integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==", + "version": "12.35.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.35.1.tgz", + "integrity": "sha512-7n6r7TtNOsH2UFSAXzTkfzOeO5616v9B178qBIjmu/WgEyJK0uqwytCEhwKBTuM/HJA40ptAw7hLFpxtPAMRZQ==", "requires": { "motion-utils": "^12.29.2" } @@ -26905,4 +26940,4 @@ "dev": true } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 2667d1481..960f13bf5 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,6 @@ "file-saver": "^2.0.5", "focus-trap-react": "^10.3.1", "folds": "^2.6.1", - "framer-motion": "12.34.3", "html-dom-parser": "^5.1.8", "html-react-parser": "^4.2.10", "i18next": "^25.8.13", @@ -66,6 +65,7 @@ "matrix-js-sdk": "^38.4.0", "matrix-widget-api": "1.13.0", "millify": "^6.1.0", + "motion": "12.35.1", "pdfjs-dist": "^5.4.624", "prismjs": "^1.30.0", "react": "^18.3.1", @@ -121,4 +121,4 @@ "vite-plugin-top-level-await": "^1.6.0", "wrangler": "^4.70.0" } -} \ No newline at end of file +} diff --git a/src/app/components/BackRouteHandler.tsx b/src/app/components/BackRouteHandler.tsx index a6e91bcd1..1c90eccb2 100644 --- a/src/app/components/BackRouteHandler.tsx +++ b/src/app/components/BackRouteHandler.tsx @@ -1,113 +1,10 @@ -import { ReactNode, useCallback } from 'react'; -import { useSetAtom } from 'jotai'; -import { matchPath, useLocation, useNavigate } from 'react-router-dom'; -import { - getDirectPath, - getExplorePath, - getHomePath, - getInboxPath, - getSpacePath, -} from '$pages/pathUtils'; -import { - DIRECT_PATH, - EXPLORE_PATH, - HOME_PATH, - INBOX_PATH, - SPACE_PATH, - HOME_ROOM_PATH, - DIRECT_ROOM_PATH, - SPACE_ROOM_PATH, -} from '$pages/paths'; -import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; +import { ReactNode } from 'react'; +import { useBackRoute } from '$hooks/useBackRoute'; type BackRouteHandlerProps = { children: (onBack: () => void) => ReactNode; }; export function BackRouteHandler({ children }: BackRouteHandlerProps) { - const navigate = useNavigate(); - const location = useLocation(); - const setLastRoomId = useSetAtom(lastVisitedRoomIdAtom); - - const goBack = useCallback(() => { - const roomPaths = [HOME_ROOM_PATH, DIRECT_ROOM_PATH, SPACE_ROOM_PATH]; - - const roomMatch = roomPaths - .map((path) => matchPath({ path, end: false }, location.pathname)) - .find((match) => match !== null); - - const currentRoomIdOrAlias = roomMatch?.params.roomIdOrAlias; - if (currentRoomIdOrAlias) { - setLastRoomId(decodeURIComponent(currentRoomIdOrAlias)); - } - - if ( - matchPath( - { - path: HOME_PATH, - caseSensitive: true, - end: false, - }, - location.pathname - ) - ) { - navigate(getHomePath()); - return; - } - if ( - matchPath( - { - path: DIRECT_PATH, - caseSensitive: true, - end: false, - }, - location.pathname - ) - ) { - navigate(getDirectPath()); - return; - } - const spaceMatch = matchPath( - { - path: SPACE_PATH, - caseSensitive: true, - end: false, - }, - location.pathname - ); - const encodedSpaceIdOrAlias = spaceMatch?.params.spaceIdOrAlias; - const decodedSpaceIdOrAlias = - encodedSpaceIdOrAlias && decodeURIComponent(encodedSpaceIdOrAlias); - - if (decodedSpaceIdOrAlias) { - navigate(getSpacePath(decodedSpaceIdOrAlias)); - return; - } - if ( - matchPath( - { - path: EXPLORE_PATH, - caseSensitive: true, - end: false, - }, - location.pathname - ) - ) { - navigate(getExplorePath()); - return; - } - if ( - matchPath( - { - path: INBOX_PATH, - caseSensitive: true, - end: false, - }, - location.pathname - ) - ) { - navigate(getInboxPath()); - } - }, [navigate, location, setLastRoomId]); - + const goBack = useBackRoute(); return children(goBack); } diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index ba29f3b78..fc105d362 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -57,6 +57,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { left: 0, width: '100%', height: '50%', + zIndex: 11, }} ref={callEmbedRef} /> diff --git a/src/app/components/SwipeableChatWrapper.tsx b/src/app/components/SwipeableChatWrapper.tsx index 2eba0d011..5c0a167fe 100644 --- a/src/app/components/SwipeableChatWrapper.tsx +++ b/src/app/components/SwipeableChatWrapper.tsx @@ -1,76 +1,80 @@ -import { ReactNode } from 'react'; -import { motion, useMotionValue, useSpring } from 'framer-motion'; +import { ReactNode, createContext } from 'react'; +import { animate, motion, useMotionValue, MotionValue } from 'motion/react'; import { useDrag } from '@use-gesture/react'; import { useAtomValue } from 'jotai'; import { settingsAtom, RightSwipeAction } from '$state/settings'; -import { mobileOrTablet } from '$utils/user-agent'; +import { useIsMobile } from '$hooks/useIsMobile'; + +const SWIPE_DISTANCE = 80; +const SWIPE_VELOCITY = 0.4; +const SNAP_SPRING = { type: 'spring' as const, stiffness: 600, damping: 50, mass: 0.6 }; + +const SwipeContext = createContext | null>(null); interface SwipeableChatWrapperProps { children: ReactNode; onOpenSidebar?: () => void; onOpenMembers?: () => void; - onReply?: () => void; } export function SwipeableChatWrapper({ children, onOpenSidebar, onOpenMembers, - onReply, }: SwipeableChatWrapperProps) { const settings = useAtomValue(settingsAtom); + const isMobile = useIsMobile(); const x = useMotionValue(0); - const springX = useSpring(x, { stiffness: 400, damping: 40 }); + + const canSwipeRight = !isMobile && !!onOpenSidebar; + const canSwipeLeft = + settings.mobileGestures && + isMobile && + settings.rightSwipeAction === RightSwipeAction.Members && + !!onOpenMembers; + const gesturesEnabled = settings.mobileGestures && (canSwipeRight || canSwipeLeft); const bind = useDrag( ({ active, movement: [mx], velocity: [vx], direction: [dx], event: e }) => { if (e && 'target' in e && e.target instanceof HTMLElement) { - if (e.target.closest('[data-gestures="ignore"]')) { - return; - } + if (e.target.closest('[data-gestures="ignore"]')) return; } - if (!settings.mobileGestures || !mobileOrTablet()) return; - let val = mx; - - const canSwipeRight = !!onOpenSidebar; - const canSwipeLeft = - settings.rightSwipeAction === RightSwipeAction.Members ? !!onOpenMembers : !!onReply; - if (!canSwipeRight && val > 0) val = 0; if (!canSwipeLeft && val < 0) val = 0; if (active) { x.set(val); } else { - const swipeThreshold = 120; - const velocityThreshold = 0.5; - - if (val > swipeThreshold || (vx > velocityThreshold && dx > 0 && val > 0)) { + if (canSwipeRight && (val > SWIPE_DISTANCE || (vx > SWIPE_VELOCITY && dx > 0 && val > 0))) { onOpenSidebar?.(); - } else if (val < -swipeThreshold || (vx > velocityThreshold && dx < 0 && val < 0)) { - if (settings.rightSwipeAction === RightSwipeAction.Members) { - onOpenMembers?.(); - } else { - onReply?.(); - } + } else if ( + canSwipeLeft && + (val < -SWIPE_DISTANCE || (vx > SWIPE_VELOCITY && dx < 0 && val < 0)) + ) { + onOpenMembers?.(); } - x.set(0); + animate(x, 0, SNAP_SPRING); } }, { axis: 'x', - bounds: { left: -200, right: 200 }, + bounds: { left: -160, right: 160 }, rubberband: true, filterTaps: true, + pointer: { capture: false }, + enabled: gesturesEnabled, } ); - if (!settings.mobileGestures || !mobileOrTablet()) { - return ( + return ( +
- {children} + + {children} +
- ); - } - - return ( -
- - {children} - -
+
); } diff --git a/src/app/components/SwipeableMessageWrapper.tsx b/src/app/components/SwipeableMessageWrapper.tsx index 4b219355c..c66e4c582 100644 --- a/src/app/components/SwipeableMessageWrapper.tsx +++ b/src/app/components/SwipeableMessageWrapper.tsx @@ -1,27 +1,83 @@ -import { useMotionValue, useSpring, useTransform, motion } from 'framer-motion'; +import { animate, useMotionValue, useTransform, motion } from 'motion/react'; import { useDrag } from '@use-gesture/react'; -import { ReactNode, useMemo, useState } from 'react'; +import { ReactNode, useCallback, useMemo, useRef, useState } from 'react'; import { useAtomValue } from 'jotai'; import { config, Icon, Icons } from 'folds'; -import { mobileOrTablet } from '$utils/user-agent'; +import { useIsMobile } from '$hooks/useIsMobile'; import { RightSwipeAction, settingsAtom } from '$state/settings'; -function ActiveSwipeWrapper({ children, onReply }: { children: ReactNode; onReply: () => void }) { +const SWIPE_DISTANCE = 50; +const SWIPE_VELOCITY = 0.3; +const SNAP_SPRING = { type: 'spring' as const, stiffness: 600, damping: 50, mass: 0.6 }; + +function useLongPress( + onLongPress: () => void, + { delay = 500, moveThreshold = 8 }: { delay?: number; moveThreshold?: number } = {} +) { + const timerRef = useRef | null>(null); + const startPosRef = useRef<{ x: number; y: number } | null>(null); + + const cancel = useCallback(() => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = null; + startPosRef.current = null; + }, []); + + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + if (e.pointerType === 'mouse') return; + startPosRef.current = { x: e.clientX, y: e.clientY }; + timerRef.current = setTimeout(() => { + onLongPress(); + cancel(); + }, delay); + }, + [onLongPress, delay, cancel] + ); + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + if (!startPosRef.current) return; + const dx = e.clientX - startPosRef.current.x; + const dy = e.clientY - startPosRef.current.y; + if (Math.sqrt(dx * dx + dy * dy) > moveThreshold) cancel(); + }, + [cancel, moveThreshold] + ); + + return { + onPointerDown, + onPointerMove, + onPointerUp: cancel, + onPointerLeave: cancel, + onPointerCancel: cancel, + }; +} + +function ActiveSwipeWrapper({ + children, + onReply, + onLongPress, +}: { + children: ReactNode; + onReply: () => void; + onLongPress?: () => void; +}) { const x = useMotionValue(0); - const springX = useSpring(x, { stiffness: 300, damping: 35 }); const [isReady, setIsReady] = useState(false); const iconOpacity = useTransform(x, [0, -8], [0, 1]); + const longPressHandlers = useLongPress(onLongPress ?? (() => {}), { delay: 500 }); const bind = useDrag( - ({ active, movement: [mx] }) => { + ({ active, movement: [mx], velocity: [vx], direction: [dx] }) => { if (active) { - const val = mx < 0 ? mx : 0; - x.set(Math.max(-80, val)); - if (mx < -50 !== isReady) setIsReady(mx < -50); + x.set(mx < 0 ? Math.max(-80, mx) : 0); + const nextReady = mx < -SWIPE_DISTANCE; + if (nextReady !== isReady) setIsReady(nextReady); } else { - if (mx < -50) onReply(); - x.set(0); + if (mx < -SWIPE_DISTANCE || (vx > SWIPE_VELOCITY && dx < 0 && mx < 0)) onReply(); setIsReady(false); + animate(x, 0, SNAP_SPRING); } }, { @@ -30,11 +86,22 @@ function ActiveSwipeWrapper({ children, onReply }: { children: ReactNode; onRepl rubberband: true, filterTaps: true, eventOptions: { passive: true }, + // Without this, useDrag calls setPointerCapture on pointerdown, stealing the pointer from MobileRoomOverlay on rightward swipes, causing it to always see mx=0 and snap back instead of navigating. + pointer: { capture: false }, } ); return ( -
+
- {children} + + {children} + +
+ ); +} + +function LongPressOnly({ + children, + onLongPress, +}: { + children: ReactNode; + onLongPress: () => void; +}) { + const handlers = useLongPress(onLongPress); + return ( +
+ {children}
); } @@ -68,23 +159,30 @@ function ActiveSwipeWrapper({ children, onReply }: { children: ReactNode; onRepl export function SwipeableMessageWrapper({ children, onReply, + onLongPress, }: { children: ReactNode; onReply: () => void; + onLongPress?: () => void; }) { const settings = useAtomValue(settingsAtom); + const isMobile = useIsMobile(); const isSwipeToReplyEnabled = useMemo( () => - settings.mobileGestures && - mobileOrTablet() && - settings.rightSwipeAction !== RightSwipeAction.Members, - [settings.mobileGestures, settings.rightSwipeAction] + settings.mobileGestures && isMobile && settings.rightSwipeAction !== RightSwipeAction.Members, + [settings.mobileGestures, settings.rightSwipeAction, isMobile] ); if (!isSwipeToReplyEnabled) { - return children; + if (onLongPress && isMobile) + return {children}; + return <>{children}; } - return {children}; + return ( + + {children} + + ); } diff --git a/src/app/components/SwipeableOverlayWrapper.tsx b/src/app/components/SwipeableOverlayWrapper.tsx index 15521bb12..445ef0b70 100644 --- a/src/app/components/SwipeableOverlayWrapper.tsx +++ b/src/app/components/SwipeableOverlayWrapper.tsx @@ -1,9 +1,15 @@ import { ReactNode } from 'react'; -import { motion, useMotionValue, useSpring } from 'framer-motion'; -import { useDrag } from '@use-gesture/react'; import { useAtomValue } from 'jotai'; import { settingsAtom } from '$state/settings'; -import { mobileOrTablet } from '$utils/user-agent'; +import { useIsMobile } from '$hooks/useIsMobile'; +import { useDrag } from '@use-gesture/react'; +import { createLogger } from '$utils/debug'; + +const log = createLogger('SwipeableOverlayWrapper'); + +const SWIPE_DISTANCE = 60; +const SWIPE_VELOCITY = 0.3; +const AXIS_LOCK_RATIO = 1.5; interface SwipeableOverlayWrapperProps { children: ReactNode; @@ -17,93 +23,47 @@ export function SwipeableOverlayWrapper({ direction, }: SwipeableOverlayWrapperProps) { const settings = useAtomValue(settingsAtom); - const x = useMotionValue(0); - const springX = useSpring(x, { stiffness: 400, damping: 40 }); + const isMobile = useIsMobile(); + const gesturesEnabled = settings.mobileGestures && isMobile; const bind = useDrag( - ({ active, movement: [mx], velocity: [vx], direction: [dx], event, event: e }) => { - if (e && 'target' in e && e.target instanceof HTMLElement) { - if (e.target.closest('[data-gestures="ignore"]')) { - return; - } - } - - if (!settings.mobileGestures || !mobileOrTablet()) return; - - event.stopPropagation(); + ({ active, movement: [mx, my], velocity: [vx], direction: [dx] }) => { + if (active) return; - let val = mx; + const axisBlocked = Math.abs(my) * AXIS_LOCK_RATIO > Math.abs(mx); + if (axisBlocked) return; - if (direction === 'left' && val > 0) val = 0; - if (direction === 'right' && val < 0) val = 0; + const thresholdMet = + direction === 'left' + ? mx < -SWIPE_DISTANCE || (vx > SWIPE_VELOCITY && dx < 0 && mx < 0) + : mx > SWIPE_DISTANCE || (vx > SWIPE_VELOCITY && dx > 0 && mx > 0); - if (active) { - x.set(val); - } else { - const swipeThreshold = 100; - const velocityThreshold = 0.5; - - const swipedLeft = - direction === 'left' && (val < -swipeThreshold || (vx > velocityThreshold && dx < 0)); - const swipedRight = - direction === 'right' && (val > swipeThreshold || (vx > velocityThreshold && dx > 0)); - - if (swipedLeft || swipedRight) { - onClose(); - } - - x.set(0); + if (thresholdMet) { + log.log('swipe detected — calling onClose'); + onClose(); } }, { axis: 'x', - bounds: direction === 'left' ? { left: -300, right: 0 } : { left: 0, right: 300 }, - rubberband: true, filterTaps: true, - pointer: { capture: true }, + pointer: { capture: false }, + enabled: gesturesEnabled, } ); - if (!settings.mobileGestures || !mobileOrTablet()) { - return ( -
- {children} -
- ); - } - return (
- - {children} - + {children}
); } diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 474522cdf..393399cd8 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -255,6 +255,7 @@ type RoomNavItemProps = { notificationMode?: RoomNotificationMode; showAvatar?: boolean; direct?: boolean; + swipeSelected?: boolean; }; export function RoomNavItem({ @@ -264,6 +265,7 @@ export function RoomNavItem({ direct, notificationMode, linkPath, + swipeSelected, }: RoomNavItemProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); @@ -368,7 +370,7 @@ export function RoomNavItem({ variant="Background" radii="400" highlight={unread !== undefined || hasRoomUnread} - aria-selected={selected} + aria-selected={selected || swipeSelected} data-hover={!!menuAnchor} onContextMenu={handleContextMenu} {...hoverProps} diff --git a/src/app/features/room/CallChatView.tsx b/src/app/features/room/CallChatView.tsx index b51b97cae..ff804ca8b 100644 --- a/src/app/features/room/CallChatView.tsx +++ b/src/app/features/room/CallChatView.tsx @@ -3,11 +3,13 @@ import { useParams } from 'react-router-dom'; import { Box, Text, TooltipProvider, Tooltip, Icon, Icons, IconButton, toRem } from 'folds'; import { Page, PageHeader } from '../../components/page'; import { callChatAtom } from '../../state/callEmbed'; +import { useRoom } from '../../hooks/useRoom'; import { RoomView } from './RoomView'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; export function CallChatView() { const { eventId } = useParams(); + const room = useRoom(); const setChat = useSetAtom(callChatAtom); const screenSize = useScreenSizeContext(); @@ -49,7 +51,7 @@ export function CallChatView() { - + ); diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index c960d431b..7da88201a 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -58,15 +58,7 @@ export function Room() { )} - {!callView && ( - - - - - - - )} - + {!callView && } />} {callView && chat && ( <> {screenSize === ScreenSize.Desktop && ( diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 15f64e936..bb703d4f8 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -1,8 +1,8 @@ -import { useCallback, useRef, useState } from 'react'; +import { ReactNode, useCallback, useRef, useState } from 'react'; import { useAtomValue } from 'jotai'; import { Transforms } from 'slate'; import { Box, Text, config, toRem } from 'folds'; -import { EventType } from '$types/matrix-sdk'; +import { EventType, Room } from '$types/matrix-sdk'; import { ReactEditor } from 'slate-react'; import { isKeyHotkey } from 'is-hotkey'; import { useStateEvent } from '$hooks/useStateEvent'; @@ -26,7 +26,6 @@ import { RoomSettingsPage } from '$state/roomSettings'; import { GlobalModalManager } from '$components/message/modals/GlobalModalManager'; import { useDelayedEventsSupport } from '$hooks/useDelayedEventsSupport'; import { delayedEventsSupportedAtom } from '$state/scheduledMessages'; -import { useRoom } from '$hooks/useRoom'; import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; import { RoomInput } from './RoomInput'; import { RoomTombstone } from './RoomTombstone'; @@ -38,12 +37,8 @@ import { ScheduledMessagesList } from './schedule-send'; const FN_KEYS_REGEX = /^F\d+$/; const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { const { code } = evt; - if (evt.metaKey || evt.altKey || evt.ctrlKey) { - return false; - } - + if (evt.metaKey || evt.altKey || evt.ctrlKey) return false; if (FN_KEYS_REGEX.test(code)) return false; - if ( code.startsWith('OS') || code.startsWith('Meta') || @@ -62,27 +57,30 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => { ) { return false; } - return true; }; -export function RoomView({ eventId }: { eventId?: string }) { +export function RoomView({ + room, + eventId, + header, +}: { + room: Room; + eventId?: string; + header?: ReactNode; +}) { const roomInputRef = useRef(null); const roomViewRef = useRef(null); const [hideReads] = useSetting(settingsAtom, 'hideReads'); const screenSize = useScreenSizeContext(); - - const room = useRoom(); const { roomId } = room; const editor = useEditor(); - const mx = useMatrixClient(); const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone); const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); - const permissions = useRoomPermissions(creators, powerLevels); const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId()); @@ -107,9 +105,7 @@ export function RoomView({ eventId }: { eventId?: string }) { (evt) => { if (editableActiveElement()) return; const portalContainer = document.getElementById('portalContainer'); - if (portalContainer && portalContainer.children.length > 0) { - return; - } + if (portalContainer && portalContainer.children.length > 0) return; if (shouldFocusMessageField(evt) || isKeyHotkey('mod+v', evt)) { ReactEditor.focus(editor); } @@ -139,6 +135,7 @@ export function RoomView({ eventId }: { eventId?: string }) { } > + {header} void, delay = 300) { - const lastTapRef = useRef(0); - - return useCallback( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - (e: PointerEvent) => { - if (!mobileOrTablet()) return; - - const now = Date.now(); - const timeSinceLastTap = now - lastTapRef.current; +function useLongPress( + onLongPress: () => void, + isMobile: boolean, + { delay = 500, moveThreshold = 8 }: { delay?: number; moveThreshold?: number } = {} +) { + const timerRef = useRef | null>(null); + const startPosRef = useRef<{ x: number; y: number } | null>(null); + + const cancel = useCallback(() => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = null; + startPosRef.current = null; + }, []); + + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + if (!isMobile) return; + if (e.pointerType === 'mouse') return; + + startPosRef.current = { x: e.clientX, y: e.clientY }; + timerRef.current = setTimeout(() => { + onLongPress(); + cancel(); + }, delay); + }, + [isMobile, delay, onLongPress, cancel] + ); - if (timeSinceLastTap < delay && timeSinceLastTap > 0) { - callback(); - lastTapRef.current = 0; - } else { - lastTapRef.current = now; - } + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + if (!startPosRef.current) return; + const dx = e.clientX - startPosRef.current.x; + const dy = e.clientY - startPosRef.current.y; + if (Math.sqrt(dx * dx + dy * dy) > moveThreshold) cancel(); }, - [callback, delay] + [cancel, moveThreshold] ); + + return { + onPointerDown, + onPointerMove, + onPointerUp: cancel, + onPointerLeave: cancel, + onPointerCancel: cancel, + }; } const Pronouns = as< @@ -272,8 +296,9 @@ const Pronouns = as< { pronouns?: any[]; tagColor: string; + isMobile?: boolean; } ->(({ as: AsPronouns = 'span', pronouns, tagColor, ...props }, ref) => { +>(({ as: AsPronouns = 'span', pronouns, tagColor, isMobile, ...props }, ref) => { if (!pronouns || pronouns.length === 0) return null; const languageFilterEnabled = Boolean(getSettings().filterPronounsBasedOnLanguage ?? false); @@ -289,7 +314,7 @@ const Pronouns = as< ); const clamp = (str: string, len: number) => (str.length > len ? `${str.slice(0, len)}...` : str); - const limit = mobileOrTablet() ? 1 : 3; + const limit = isMobile ? 1 : 3; // if language specific pronouns can't be found matching the filter return unfiltered if (visiblePronouns.length === 0) { @@ -385,14 +410,15 @@ function MessageInternal( // UI State const [isDesktopHover, setIsDesktopHover] = useState(false); + const isMobile = useIsMobile(); const { hoverProps } = useHover({ onHoverChange: (h) => { - if (!mobileOrTablet()) setIsDesktopHover(h); + if (!isMobile) setIsDesktopHover(h); }, }); const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: (f) => { - if (!mobileOrTablet()) setIsDesktopHover(f); + if (!isMobile) setIsDesktopHover(f); }, }); @@ -447,7 +473,11 @@ function MessageInternal( {showPronouns && ( - + )} {tagIconSrc && } @@ -606,7 +636,7 @@ function MessageInternal( ); const handleContextMenu: MouseEventHandler = (evt) => { - if (mobileOrTablet()) { + if (isMobile) { evt.preventDefault(); return; } @@ -663,9 +693,9 @@ function MessageInternal( onReplyClick(mockEvent); }; - const onDoubleTap = useMobileDoubleTap(() => { + const longPressHandlers = useLongPress(() => { setMobileOptionsOpen(true); - }); + }, isMobile); const isThreadedMessage = mEvent.threadRootId !== undefined; @@ -997,7 +1027,7 @@ function MessageInternal( {messageLayout === MessageLayout.Compact && ( -
{msgContentJSX}
+
{msgContentJSX}
)} @@ -1009,14 +1039,14 @@ function MessageInternal( onContextMenu={handleContextMenu} align={useRightBubbles && senderId === mx.getUserId() ? 'right' : 'left'} > -
{msgContentJSX}
+
{msgContentJSX}
)} {messageLayout !== MessageLayout.Compact && messageLayout !== MessageLayout.Bubble && ( -
+
{headerJSX} {msgContentJSX}
@@ -1063,13 +1093,18 @@ export const Event = as<'div', EventProps>( ref ) => { const mx = useMatrixClient(); + const isMobile = useIsMobile(); const stateEvent = typeof mEvent.getStateKey() === 'string'; const [menuAnchor, setMenuAnchor] = useState(); const [mobileOptionsOpen, setMobileOptionsOpen] = useState(false); + const longPressHandlers = useLongPress(() => { + setMobileOptionsOpen(true); + }, isMobile); + const handleContextMenu: MouseEventHandler = (evt) => { - if (mobileOrTablet()) { + if (isMobile) { evt.preventDefault(); return; } @@ -1103,12 +1138,12 @@ export const Event = as<'div', EventProps>( const [isDesktopHover, setIsDesktopHover] = useState(false); const { hoverProps } = useHover({ onHoverChange: (h) => { - if (!mobileOrTablet()) setIsDesktopHover(h); + if (!isMobile) setIsDesktopHover(h); }, }); const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: (f) => { - if (!mobileOrTablet()) setIsDesktopHover(f); + if (!isMobile) setIsDesktopHover(f); }, }); @@ -1125,10 +1160,6 @@ export const Event = as<'div', EventProps>( return () => document.removeEventListener('pointerdown', handleClick, { capture: true }); }, [mobileOptionsOpen]); - const onDoubleTap = useMobileDoubleTap(() => { - setMobileOptionsOpen(true); - }); - return ( (
- {!mobileOrTablet() && ( + {!isMobile && ( (
)} -
+
{children}
diff --git a/src/app/hooks/useBackRoute.ts b/src/app/hooks/useBackRoute.ts new file mode 100644 index 000000000..7821bcd8b --- /dev/null +++ b/src/app/hooks/useBackRoute.ts @@ -0,0 +1,129 @@ +import { useCallback } from 'react'; +import { matchPath, useLocation, useNavigate } from 'react-router-dom'; +import { + getDirectPath, + getExplorePath, + getHomePath, + getInboxPath, + getSpacePath, +} from '$pages/pathUtils'; +import { + DIRECT_PATH, + EXPLORE_PATH, + HOME_PATH, + INBOX_PATH, + SPACE_PATH, + HOME_ROOM_PATH, + DIRECT_ROOM_PATH, + SPACE_ROOM_PATH, +} from '$pages/paths'; +import { useSetLastFocusedRoom, LastFocusedContext } from '$hooks/useLastFocusedRooms'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { isRoomAlias } from '$utils/matrix'; +import { createLogger } from '$utils/debug'; + +const log = createLogger('useBackRoute'); + +export const BACK_ROOM_PARAM = 'room'; + +export function useBackRoute() { + const navigate = useNavigate(); + const location = useLocation(); + const setLastFocusedRoom = useSetLastFocusedRoom(); + const mx = useMatrixClient(); + + const resolveSpaceId = useCallback( + (idOrAlias: string): string => { + if (!isRoomAlias(idOrAlias)) return idOrAlias; + return mx.getRooms().find((r) => r.getCanonicalAlias() === idOrAlias)?.roomId ?? idOrAlias; + }, + [mx] + ); + + const resolveRoomId = useCallback( + (idOrAlias: string): string => { + if (!isRoomAlias(idOrAlias)) return idOrAlias; + return mx.getRooms().find((r) => r.getCanonicalAlias() === idOrAlias)?.roomId ?? idOrAlias; + }, + [mx] + ); + + return useCallback(() => { + log.log('goBack called — pathname:', location.pathname); + + const roomPaths = [HOME_ROOM_PATH, DIRECT_ROOM_PATH, SPACE_ROOM_PATH]; + const roomMatches = roomPaths.map((path) => ({ + path, + match: matchPath({ path, end: false }, location.pathname), + })); + + const roomMatch = roomMatches.find((m) => m.match !== null)?.match ?? null; + const currentRoomIdOrAlias = roomMatch?.params.roomIdOrAlias; + const decodedRoomId = currentRoomIdOrAlias + ? decodeURIComponent(currentRoomIdOrAlias) + : undefined; + + log.log('currentRoomIdOrAlias:', currentRoomIdOrAlias, '→ decodedRoomId:', decodedRoomId); + + const spaceMatch = matchPath( + { path: SPACE_PATH, caseSensitive: true, end: false }, + location.pathname + ); + const decodedSpaceIdOrAlias = + spaceMatch?.params.spaceIdOrAlias && decodeURIComponent(spaceMatch.params.spaceIdOrAlias); + + const resolvedSpaceId = decodedSpaceIdOrAlias + ? resolveSpaceId(decodedSpaceIdOrAlias) + : undefined; + + let context: LastFocusedContext | undefined; + if (matchPath({ path: HOME_PATH, caseSensitive: true, end: false }, location.pathname)) { + context = 'home'; + } else if ( + matchPath({ path: DIRECT_PATH, caseSensitive: true, end: false }, location.pathname) + ) { + context = 'direct'; + } else if (resolvedSpaceId) { + context = { spaceId: resolvedSpaceId }; + } + + if (decodedRoomId && context) { + setLastFocusedRoom(context, resolveRoomId(decodedRoomId)); + } + + const roomSuffix = decodedRoomId + ? `?${BACK_ROOM_PARAM}=${encodeURIComponent(decodedRoomId)}` + : ''; + log.log('roomSuffix:', roomSuffix || '(none — room not found in URL)'); + + if (matchPath({ path: HOME_PATH, caseSensitive: true, end: false }, location.pathname)) { + const target = `${getHomePath()}${roomSuffix}`; + log.log('matched HOME_PATH → navigating to:', target); + navigate(target); + return; + } + if (matchPath({ path: DIRECT_PATH, caseSensitive: true, end: false }, location.pathname)) { + const target = `${getDirectPath()}${roomSuffix}`; + log.log('matched DIRECT_PATH → navigating to:', target); + navigate(target); + return; + } + if (decodedSpaceIdOrAlias) { + const target = `${getSpacePath(decodedSpaceIdOrAlias)}${roomSuffix}`; + log.log('matched SPACE_PATH → navigating to:', target); + navigate(target); + return; + } + if (matchPath({ path: EXPLORE_PATH, caseSensitive: true, end: false }, location.pathname)) { + log.log('matched EXPLORE_PATH'); + navigate(getExplorePath()); + return; + } + if (matchPath({ path: INBOX_PATH, caseSensitive: true, end: false }, location.pathname)) { + log.log('matched INBOX_PATH'); + navigate(getInboxPath()); + return; + } + log.warn('no path matched! pathname was:', location.pathname); + }, [location.pathname, resolveSpaceId, setLastFocusedRoom, resolveRoomId, navigate]); +} diff --git a/src/app/hooks/useIsMobile.ts b/src/app/hooks/useIsMobile.ts new file mode 100644 index 000000000..ad595ec87 --- /dev/null +++ b/src/app/hooks/useIsMobile.ts @@ -0,0 +1,22 @@ +import { useState, useEffect } from 'react'; + +const MOBILE_BREAKPOINT = 768; + +function getIsMobile() { + return typeof window !== 'undefined' && window.innerWidth < MOBILE_BREAKPOINT; +} + +export function useIsMobile() { + const [isMobile, setIsMobile] = useState(getIsMobile); + + useEffect(() => { + const handleResize = () => { + setIsMobile(getIsMobile()); + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + return isMobile; +} diff --git a/src/app/hooks/useLastFocusedRooms.ts b/src/app/hooks/useLastFocusedRooms.ts new file mode 100644 index 000000000..60c823d3f --- /dev/null +++ b/src/app/hooks/useLastFocusedRooms.ts @@ -0,0 +1,23 @@ +import { useAtomValue, useSetAtom } from 'jotai'; +import { useCallback } from 'react'; +import { lastFocusedRoomsAtom, setLastFocusedRoomAtom } from '$state/lastFocusedRooms'; + +export type LastFocusedContext = 'home' | 'direct' | { spaceId: string }; + +export const useLastFocusedRoom = (context: LastFocusedContext): string | undefined => { + const lastFocusedRooms = useAtomValue(lastFocusedRoomsAtom); + if (context === 'home') return lastFocusedRooms.home; + if (context === 'direct') return lastFocusedRooms.direct; + return lastFocusedRooms.spaces[context.spaceId]; +}; + +export const useSetLastFocusedRoom = () => { + const setLastFocusedRoom = useSetAtom(setLastFocusedRoomAtom); + + return useCallback( + (context: LastFocusedContext, roomId: string | undefined) => { + setLastFocusedRoom(context, roomId); + }, + [setLastFocusedRoom] + ); +}; diff --git a/src/app/pages/MobileFriendly.tsx b/src/app/pages/MobileFriendly.tsx index 2fe174fe5..5c4afe9e4 100644 --- a/src/app/pages/MobileFriendly.tsx +++ b/src/app/pages/MobileFriendly.tsx @@ -1,7 +1,15 @@ import { ReactNode } from 'react'; import { useMatch } from 'react-router-dom'; import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; -import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths'; +import { + DIRECT_PATH, + EXPLORE_PATH, + HOME_PATH, + INBOX_PATH, + SPACE_PATH, + LOBBY_PATH_SEGMENT, + ROOM_PATH_SEGMENT, +} from './paths'; type MobileFriendlyClientNavProps = { children: ReactNode; @@ -30,15 +38,25 @@ type MobileFriendlyPageNavProps = { }; export function MobileFriendlyPageNav({ path, children }: MobileFriendlyPageNavProps) { const screenSize = useScreenSizeContext(); - const exactPath = useMatch({ - path, + const exactPath = useMatch({ path, caseSensitive: true, end: true }); + const spaceLobbyMatch = useMatch({ + path: `${SPACE_PATH}${LOBBY_PATH_SEGMENT}`, caseSensitive: true, end: true, }); + const roomMatch = useMatch({ + path: `${path}${ROOM_PATH_SEGMENT}`, + caseSensitive: true, + end: false, + }); - if (screenSize === ScreenSize.Mobile && !exactPath) { - return null; + if (screenSize === ScreenSize.Mobile) { + return children; } + if (path === SPACE_PATH) { + if (!exactPath && !spaceLobbyMatch && !roomMatch) return null; + } else if (!exactPath && !roomMatch) return null; + return children; } diff --git a/src/app/pages/MobileRoomOverlay.tsx b/src/app/pages/MobileRoomOverlay.tsx new file mode 100644 index 000000000..ac7d6d449 --- /dev/null +++ b/src/app/pages/MobileRoomOverlay.tsx @@ -0,0 +1,150 @@ +import { ReactNode, useCallback, useEffect, useRef } from 'react'; +import { animate, motion, useMotionValue } from 'motion/react'; +import { useDrag } from '@use-gesture/react'; +import { useAtomValue } from 'jotai'; +import { settingsAtom } from '$state/settings'; +import { mobileOrTablet } from '$utils/user-agent'; +import { useBackRoute } from '$hooks/useBackRoute'; +import { createLogger } from '$utils/debug'; + +const log = createLogger('MobileRoomOverlay'); + +const SWIPE_DISTANCE = 80; +const SWIPE_VELOCITY = 0.4; +const SNAP_SPRING = { type: 'spring' as const, stiffness: 600, damping: 50, mass: 0.6 }; +const TRANSITION_SPRING = { type: 'spring' as const, stiffness: 380, damping: 36 }; +const CAPTURE_THRESHOLD = 8; + +export function MobileRoomOverlay({ children }: { children: ReactNode }) { + const settings = useAtomValue(settingsAtom); + const goBack = useBackRoute(); + const x = useMotionValue(window.innerWidth); + const divRef = useRef(null); + const embed = document.querySelector('[data-call-embed-container]'); + + useEffect(() => { + log.log('mounted, starting slide-in animation'); + animate(x, 0, TRANSITION_SPRING); + }, [x]); + + // Sync fixed call embed transform with overlay position + useEffect(() => { + const unsub = x.on('change', (val) => { + if (embed) embed.style.transform = `translateX(${val}px)`; + }); + return () => { + unsub(); + if (embed) embed.style.transform = ''; + }; + }, [embed, x]); + + // Disable pointer events on the call embed during rightward swipes so + // useDrag on the overlay can receive the gesture instead of the iframe + useEffect(() => { + if (!settings.mobileGestures) return undefined; + + let startX = 0; + let startY = 0; + let disabled = false; + + const onTouchStart = (e: TouchEvent) => { + startX = e.touches[0]?.clientX ?? 0; + startY = e.touches[0]?.clientY ?? 0; + disabled = false; + }; + + const onTouchMove = (e: TouchEvent) => { + if (disabled) return; + const dx = (e.touches[0]?.clientX ?? 0) - startX; + const dy = (e.touches[0]?.clientY ?? 0) - startY; + // Only disable for clearly rightward, non-vertical gestures + if (dx > CAPTURE_THRESHOLD && Math.abs(dy) < Math.abs(dx) * 1.5) { + if (embed) embed.style.pointerEvents = 'none'; + disabled = true; + } + }; + + const onTouchEnd = () => { + if (disabled) { + if (embed) embed.style.pointerEvents = ''; + disabled = false; + } + }; + + window.addEventListener('touchstart', onTouchStart, { passive: true }); + window.addEventListener('touchmove', onTouchMove, { passive: true }); + window.addEventListener('touchend', onTouchEnd, { passive: true }); + window.addEventListener('touchcancel', onTouchEnd, { passive: true }); + + return () => { + window.removeEventListener('touchstart', onTouchStart); + window.removeEventListener('touchmove', onTouchMove); + window.removeEventListener('touchend', onTouchEnd); + window.removeEventListener('touchcancel', onTouchEnd); + }; + }, [embed, settings.mobileGestures]); + + const navigateBack = useCallback(() => { + log.log('navigateBack — disabling pointer events, starting exit animation'); + if (divRef.current) divRef.current.style.pointerEvents = 'none'; + animate(x, window.innerWidth, { + ...TRANSITION_SPRING, + onComplete: () => { + log.log('exit animation complete — calling goBack'); + goBack(); + }, + }); + }, [x, goBack]); + + const bind = useDrag( + ({ active, movement: [mx], velocity: [vx], direction: [dx] }) => { + if (!settings.mobileGestures || !mobileOrTablet()) return; + if (active) { + x.set(Math.max(0, mx)); + } else { + const flung = vx > SWIPE_VELOCITY && dx > 0 && mx > 0; + log.log(`drag end — mx:${mx.toFixed(0)} vx:${vx.toFixed(2)} dx:${dx} flung:${flung}`); + if (mx > SWIPE_DISTANCE || flung) { + log.log('threshold met — calling navigateBack'); + navigateBack(); + } else { + log.log('snapping back'); + animate(x, 0, SNAP_SPRING); + } + } + }, + { + axis: 'x', + bounds: { left: 0 }, + rubberband: true, + filterTaps: true, + pointer: { capture: false }, + } + ); + + return ( + + {children} + + ); +} diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index d4684322a..9f154b22b 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -66,7 +66,8 @@ import { Notifications, Inbox, Invites } from './client/inbox'; import { setAfterLoginRedirectPath } from './afterLoginRedirectPath'; import { WelcomePage } from './client/WelcomePage'; import { SidebarNav } from './client/SidebarNav'; -import { MobileFriendlyPageNav, MobileFriendlyClientNav } from './MobileFriendly'; +import { MobileFriendlyPageNav } from './MobileFriendly'; +import { MobileRoomOverlay } from './MobileRoomOverlay'; import { ClientInitStorageAtom } from './client/ClientInitStorageAtom'; import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager'; import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences'; @@ -75,17 +76,12 @@ import { Create } from './client/create'; import { ToRoomEvent } from './client/ToRoomEvent'; import { CallStatusRenderer } from './CallStatusRenderer'; -/** - * Returns true if there is at least one stored session. - * Reads localStorage directly — safe to call outside React (in route loaders). - */ const hasStoredSession = (): boolean => { const sessions = getLocalStorageItem(MATRIX_SESSIONS_KEY, []); if (sessions.length > 0) return true; return !!getFallbackSession(); }; -/** Returns the first available session for the SW push. */ const getFirstSession = () => { const sessions = getLocalStorageItem(MATRIX_SESSIONS_KEY, []); if (sessions.length > 0) return sessions[0]; @@ -96,6 +92,9 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) const { hashRouter } = clientConfig; const mobile = screenSize === ScreenSize.Mobile; + const wrapRoom = (element: JSX.Element) => + mobile ? {element} : element; + const routes = createRoutesFromElements( - - - - } - > + }> @@ -216,11 +209,11 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } /> - } + )} /> } /> - } + )} /> } /> - } + )} /> void; }; @@ -179,7 +195,30 @@ export function Direct() { const createDirectSelected = useDirectCreateSelected(); - const selectedRoomId = useSelectedRoom(); + const [searchParams] = useSearchParams(); + const routeSelectedRoomId = useSelectedRoom(); + const backRoomParam = searchParams.get(BACK_ROOM_PARAM); + const selectedRoomId = routeSelectedRoomId ?? backRoomParam ?? undefined; + const setLastFocusedRoom = useSetLastFocusedRoom(); + + useEffect(() => { + if (routeSelectedRoomId) setLastFocusedRoom('direct', routeSelectedRoomId); + }, [routeSelectedRoomId, setLastFocusedRoom]); + + const lastRoomId = useLastFocusedRoom('direct'); + + useEffect(() => { + log.log( + 'selectedRoomId:', + selectedRoomId, + '| routeSelectedRoomId:', + routeSelectedRoomId, + '| backRoomParam:', + backRoomParam, + '| searchParams:', + Object.fromEntries(searchParams.entries()) + ); + }, [selectedRoomId, routeSelectedRoomId, backRoomParam, searchParams]); const noRoomToDisplay = directs.length === 0; const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); @@ -206,79 +245,103 @@ export function Direct() { closedCategories.has(categoryId) ); + const swipeTargetRoomId = useSwipeTargetRoomId( + mx, + new Set(sortedDirects), + lastRoomId, + sortedDirects[0] + ); + + const handleSwipeToRoom = useCallback(() => { + if (!mobileOrTablet()) return; + const validRoomIds = new Set(sortedDirects); + const targetRoomId = resolveSwipeTargetRoom( + mx, + validRoomIds, + selectedRoomId, + lastRoomId, + sortedDirects[0] + ); + if (!targetRoomId) return; + navigate(getDirectRoomPath(getCanonicalAliasOrRoomId(mx, targetRoomId))); + }, [selectedRoomId, lastRoomId, sortedDirects, mx, navigate]); + return ( - - {noRoomToDisplay ? ( - - ) : ( - - - - - navigate(getDirectCreatePath())}> - - - - - - - - Create Chat - + + + {noRoomToDisplay ? ( + + ) : ( + + + + + navigate(getDirectCreatePath())}> + + + + + + + + Create Chat + + - - - - - - - - + + + + + + + Chats + + +
- Chats - - -
- {virtualizer.getVirtualItems().map((vItem) => { - const roomId = sortedDirects[vItem.index]; - const room = mx.getRoom(roomId); - if (!room) return null; - const selected = selectedRoomId === roomId; + {virtualizer.getVirtualItems().map((vItem) => { + const roomId = sortedDirects[vItem.index]; + const room = mx.getRoom(roomId); + if (!room) return null; + const selected = selectedRoomId === roomId; - return ( - - - - ); - })} -
- - - - )} + return ( + + + + ); + })} +
+
+
+
+ )} +
); } diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index c25d99e30..63a55a2f2 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -1,5 +1,13 @@ -import { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { + MouseEventHandler, + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { Avatar, Box, @@ -61,8 +69,16 @@ import { import { UseStateProvider } from '$components/UseStateProvider'; import { JoinAddressPrompt } from '$components/join-address-prompt'; import { RoomSearchParams } from '$pages/paths'; +import { mobileOrTablet } from '$utils/user-agent'; +import { useLastFocusedRoom, useSetLastFocusedRoom } from '$hooks/useLastFocusedRooms'; +import { SwipeableOverlayWrapper } from '$components/SwipeableOverlayWrapper'; +import { BACK_ROOM_PARAM } from '$hooks/useBackRoute'; +import { createLogger } from '$utils/debug'; +import { resolveSwipeTargetRoom, useSwipeTargetRoomId } from '$utils/resolveSwipeTargetRoom'; import { useHomeRooms } from './useHomeRooms'; +const log = createLogger('Home'); + type HomeMenuProps = { requestClose: () => void; }; @@ -200,7 +216,30 @@ export function Home() { const roomToUnread = useAtomValue(roomToUnreadAtom); const navigate = useNavigate(); - const selectedRoomId = useSelectedRoom(); + const [searchParams] = useSearchParams(); + const routeSelectedRoomId = useSelectedRoom(); + const backRoomParam = searchParams.get(BACK_ROOM_PARAM); + const selectedRoomId = routeSelectedRoomId ?? backRoomParam ?? undefined; + const setLastFocusedRoom = useSetLastFocusedRoom(); + + useEffect(() => { + if (routeSelectedRoomId) setLastFocusedRoom('home', routeSelectedRoomId); + }, [routeSelectedRoomId, setLastFocusedRoom]); + + const lastRoomId = useLastFocusedRoom('home'); + + useEffect(() => { + log.log( + 'selectedRoomId:', + selectedRoomId, + '| routeSelectedRoomId:', + routeSelectedRoomId, + '| backRoomParam:', + backRoomParam, + '| searchParams:', + Object.fromEntries(searchParams.entries()) + ); + }, [selectedRoomId, routeSelectedRoomId, backRoomParam, searchParams]); const createRoomSelected = useHomeCreateSelected(); const searchSelected = useHomeSearchSelected(); const noRoomToDisplay = rooms.length === 0; @@ -233,131 +272,155 @@ export function Home() { closedCategories.has(categoryId) ); + const swipeTargetRoomId = useSwipeTargetRoomId( + mx, + new Set(sortedRooms), + lastRoomId, + sortedRooms[0] + ); + + const handleSwipeToRoom = useCallback(() => { + if (!mobileOrTablet()) return; + const validRoomIds = new Set(sortedRooms); + const targetRoomId = resolveSwipeTargetRoom( + mx, + validRoomIds, + selectedRoomId, + lastRoomId, + sortedRooms[0] + ); + if (!targetRoomId) return; + navigate(getHomeRoomPath(getCanonicalAliasOrRoomId(mx, targetRoomId))); + }, [selectedRoomId, lastRoomId, sortedRooms, mx, navigate]); + return ( - - {noRoomToDisplay ? ( - - ) : ( - - - - - navigate(getHomeCreatePath())}> - - - - - - - - Create Room - + + + {noRoomToDisplay ? ( + + ) : ( + + + + + navigate(getHomeCreatePath())}> + + + + + + + + Create Room + + - - - - - - {(open, setOpen) => ( - <> - - setOpen(true)}> - - - - - - - - Join with Address - + + + + + {(open, setOpen) => ( + <> + + setOpen(true)}> + + + + + + + + Join with Address + + - - - - - {open && ( - setOpen(false)} - onOpen={(roomIdOrAlias, viaServers, eventId) => { - setOpen(false); - const path = getHomeRoomPath(roomIdOrAlias, eventId); - navigate( - viaServers - ? withSearchParam(path, { - viaServers: encodeSearchParamValueArray(viaServers), - }) - : path - ); - }} - /> - )} - - )} - - - - - - - - - - - Message Search - + + + + {open && ( + setOpen(false)} + onOpen={(roomIdOrAlias, viaServers, eventId) => { + setOpen(false); + const path = getHomeRoomPath(roomIdOrAlias, eventId); + navigate( + viaServers + ? withSearchParam(path, { + viaServers: encodeSearchParamValueArray(viaServers), + }) + : path + ); + }} + /> + )} + + )} + + + + + + + + + + + Message Search + + - - - - - - - - + + + + + + + Rooms + + +
- Rooms - - -
- {virtualizer.getVirtualItems().map((vItem) => { - const roomId = sortedRooms[vItem.index]; - const room = mx.getRoom(roomId); - if (!room) return null; - const selected = selectedRoomId === roomId; + {virtualizer.getVirtualItems().map((vItem) => { + const roomId = sortedRooms[vItem.index]; + const room = mx.getRoom(roomId); + if (!room) return null; + const selected = selectedRoomId === roomId; - return ( - - - - ); - })} -
- - - - )} + return ( + + + + ); + })} +
+
+ +
+ )} +
); } diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index bc88b9af5..ebb9ef86b 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -1,4 +1,12 @@ -import { MouseEventHandler, forwardRef, useCallback, useMemo, useRef, useState } from 'react'; +import { + MouseEventHandler, + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useAtom, useAtomValue } from 'jotai'; import { Avatar, @@ -20,7 +28,7 @@ import { } from 'folds'; import { useVirtualizer } from '@tanstack/react-virtual'; import FocusTrap from 'focus-trap-react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { JoinRule, Room, RoomJoinRulesEventContent } from '$types/matrix-sdk'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { mDirectAtom } from '$state/mDirectList'; @@ -68,10 +76,15 @@ import { ContainerColor } from '$styles/ContainerColor.css'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { BreakWord } from '$styles/Text.css'; import { InviteUserPrompt } from '$components/invite-user-prompt'; +import { useCallEmbed } from '$hooks/useCallEmbed'; import { mobileOrTablet } from '$utils/user-agent'; -import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; +import { useLastFocusedRoom, useSetLastFocusedRoom } from '$hooks/useLastFocusedRooms'; import { SwipeableOverlayWrapper } from '$components/SwipeableOverlayWrapper'; -import { useCallEmbed } from '$hooks/useCallEmbed'; +import { BACK_ROOM_PARAM } from '$hooks/useBackRoute'; +import { createLogger } from '$utils/debug'; +import { resolveSwipeTargetRoom, useSwipeTargetRoomId } from '$utils/resolveSwipeTargetRoom'; + +const log = createLogger('Space'); type SpaceMenuProps = { room: Room; @@ -376,7 +389,30 @@ export function Space() { const notificationPreferences = useRoomsNotificationPreferencesContext(); const tombstoneEvent = useStateEvent(space, StateEvent.RoomTombstone); - const selectedRoomId = useSelectedRoom(); + const [searchParams] = useSearchParams(); + const routeSelectedRoomId = useSelectedRoom(); + const backRoomParam = searchParams.get(BACK_ROOM_PARAM); + const selectedRoomId = routeSelectedRoomId ?? backRoomParam ?? undefined; + const setLastFocusedRoom = useSetLastFocusedRoom(); + + useEffect(() => { + if (routeSelectedRoomId) setLastFocusedRoom({ spaceId: space.roomId }, routeSelectedRoomId); + }, [routeSelectedRoomId, space.roomId, setLastFocusedRoom]); + + const lastRoomId = useLastFocusedRoom({ spaceId: space.roomId }); + + useEffect(() => { + log.log( + 'selectedRoomId:', + selectedRoomId, + '| routeSelectedRoomId:', + routeSelectedRoomId, + '| backRoomParam:', + backRoomParam, + '| searchParams:', + Object.fromEntries(searchParams.entries()) + ); + }, [selectedRoomId, routeSelectedRoomId, backRoomParam, searchParams]); const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias); const searchSelected = useSpaceSearchSelected(spaceIdOrAlias); const callEmbed = useCallEmbed(); @@ -430,14 +466,33 @@ export function Space() { getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId)); const navigate = useNavigate(); - const lastRoomId = useAtomValue(lastVisitedRoomIdAtom); + const firstRoomId = useMemo(() => { + const firstRoom = hierarchy.find((item) => { + const room = mx.getRoom(item.roomId); + return room && !room.isSpaceRoom(); + }); + return firstRoom?.roomId; + }, [hierarchy, mx]); + + const hierarchyRoomIds = useMemo( + () => new Set(hierarchy.map((item) => item.roomId)), + [hierarchy] + ); + + const swipeTargetRoomId = useSwipeTargetRoomId(mx, hierarchyRoomIds, lastRoomId, firstRoomId); const handleSwipeToRoom = useCallback(() => { - if (mobileOrTablet() && lastRoomId) { - const roomAliasOrId = getCanonicalAliasOrRoomId(mx, lastRoomId); - navigate(getSpaceRoomPath(spaceIdOrAlias, roomAliasOrId)); - } - }, [lastRoomId, spaceIdOrAlias, mx, navigate]); + if (!mobileOrTablet()) return; + const targetRoomId = resolveSwipeTargetRoom( + mx, + hierarchyRoomIds, + selectedRoomId, + lastRoomId, + firstRoomId + ); + if (!targetRoomId) return; + navigate(getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, targetRoomId))); + }, [selectedRoomId, lastRoomId, hierarchyRoomIds, firstRoomId, spaceIdOrAlias, mx, navigate]); return ( @@ -531,6 +586,7 @@ export function Space() { ; +}; + +const STORAGE_KEY = 'sable_last_focused_rooms'; + +export const readLastFocusedRoomsFromStorage = (): LastFocusedRooms => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return { spaces: {} }; + const parsed = JSON.parse(raw) as Partial; + return { home: parsed.home, direct: parsed.direct, spaces: parsed.spaces ?? {} }; + } catch { + return { spaces: {} }; + } +}; + +const writeLastFocusedRoomsToStorage = (value: LastFocusedRooms): void => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(value)); + } catch { + // storage quota exceeded or private browsing — silently ignore + } +}; + +export const lastFocusedRoomsAtom = atom(readLastFocusedRoomsFromStorage()); + +export const setLastFocusedRoomAtom = atom< + null, + [context: 'home' | 'direct' | { spaceId: string }, roomId: string | undefined], + void +>(null, (get, set, context, roomId) => { + const prev = get(lastFocusedRoomsAtom); + let next: LastFocusedRooms; + + if (context === 'home') { + next = { ...prev, home: roomId }; + } else if (context === 'direct') { + next = { ...prev, direct: roomId }; + } else { + const spaces = { ...prev.spaces }; + if (roomId === undefined) { + delete spaces[context.spaceId]; + } else { + spaces[context.spaceId] = roomId; + } + next = { ...prev, spaces }; + } + + set(lastFocusedRoomsAtom, next); + writeLastFocusedRoomsToStorage(next); +}); diff --git a/src/app/state/room/lastRoom.ts b/src/app/state/room/lastRoom.ts deleted file mode 100644 index e3f7d1c61..000000000 --- a/src/app/state/room/lastRoom.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { atom } from 'jotai'; - -// This is only used for mobile swipe gestures -// It is not particularly accurate and shouldn't be used for much else -// unless you plan major refractors -export const lastVisitedRoomIdAtom = atom(undefined); diff --git a/src/app/utils/debug.ts b/src/app/utils/debug.ts index 6f7f2b367..916cc1e7f 100644 --- a/src/app/utils/debug.ts +++ b/src/app/utils/debug.ts @@ -8,7 +8,8 @@ * localStorage.removeItem('sable_debug'); location.reload(); */ -const isDebug = (): boolean => localStorage.getItem('sable_debug') === '1'; +export const isDebug = (): boolean => + import.meta.env.DEV || localStorage.getItem('sable_debug') === '1'; type LogLevel = 'log' | 'warn' | 'error'; diff --git a/src/app/utils/resolveSwipeTargetRoom.ts b/src/app/utils/resolveSwipeTargetRoom.ts new file mode 100644 index 000000000..04fd9d118 --- /dev/null +++ b/src/app/utils/resolveSwipeTargetRoom.ts @@ -0,0 +1,34 @@ +import { useMemo } from 'react'; +import { MatrixClient } from '$types/matrix-sdk'; +import { getCanonicalAliasOrRoomId } from '$utils/matrix'; + +function resolveRoomId(mx: MatrixClient, roomIdOrAlias: string): string | undefined { + if (roomIdOrAlias.startsWith('!')) return roomIdOrAlias; + return mx.getRooms().find((r) => getCanonicalAliasOrRoomId(mx, r.roomId) === roomIdOrAlias) + ?.roomId; +} + +export function resolveSwipeTargetRoom( + mx: MatrixClient, + validRoomIds: Set, + ...candidates: (string | undefined)[] +): string | undefined { + return candidates.reduce((found, candidate) => { + if (found) return found; + if (!candidate) return undefined; + const roomId = resolveRoomId(mx, candidate); + return roomId && validRoomIds.has(roomId) ? roomId : undefined; + }, undefined); +} + +export function useSwipeTargetRoomId( + mx: MatrixClient, + validRoomIds: Set, + ...candidates: (string | undefined)[] +): string | undefined { + return useMemo( + () => resolveSwipeTargetRoom(mx, validRoomIds, ...candidates), + // eslint-disable-next-line react-hooks/exhaustive-deps + [mx, validRoomIds, ...candidates] + ); +}