diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 8fd1e1f5a..110c80b22 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -1,5 +1,5 @@ -import type { MouseEventHandler, MouseEvent, PointerEventHandler } from 'react'; -import { forwardRef, startTransition, useState, useEffect, useRef } from 'react'; +import type { MouseEventHandler, MouseEvent } from 'react'; +import { forwardRef, startTransition, useState, useEffect } from 'react'; import type { Room } from '$types/matrix-sdk'; import { RoomEvent as RoomEventEnum } from '$types/matrix-sdk'; import type { RectCords } from 'folds'; @@ -78,6 +78,7 @@ import { useRoomName, useRoomTopic } from '$hooks/useRoomMeta'; import { nicknamesAtom } from '$state/nicknames'; import { useRoomNavigate } from '$hooks/useRoomNavigate'; import { warmupRoomDecryption } from '$utils/decryptScheduler'; +import { useMobileTapActivation } from '$hooks/useMobileTapActivation'; // Call Hooks & Plugins import { useCallMembers, useCallSession } from '$hooks/useCall'; @@ -418,27 +419,9 @@ export function RoomNavItem({ } }; - // 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(); - startTransition(() => navigate(linkPath)); - }; + const mobileTapActivation = useMobileTapActivation(isMobile && !room.isCallRoom(), () => { + navigate(linkPath); + }); const handleChatButtonClick = (evt: MouseEvent) => { evt.stopPropagation(); @@ -506,8 +489,13 @@ export function RoomNavItem({ {(triggerRef) => ( { + warmupRoomDecryption(mx, room.roomId); + mobileTapActivation.onPointerDown(evt); + }} + onPointerMove={mobileTapActivation.onPointerMove} + onPointerUp={mobileTapActivation.onPointerUp} + onPointerCancel={mobileTapActivation.onPointerCancel} onTouchStart={onTouchStart} onTouchEnd={onTouchEnd} onTouchMove={onTouchMove} diff --git a/src/app/hooks/useMobileTapActivation.ts b/src/app/hooks/useMobileTapActivation.ts new file mode 100644 index 000000000..a41f92b44 --- /dev/null +++ b/src/app/hooks/useMobileTapActivation.ts @@ -0,0 +1,81 @@ +import type { PointerEventHandler } from 'react'; +import { useRef } from 'react'; + +const TAP_MOVEMENT_THRESHOLD = 10; +const MAX_TAP_DURATION = 500; + +// Android WebView suppresses click synthesis after a drag, so the first tap +// on a nav control after swiping the drawer produces no click event. Activate +// directly on pointerup instead. Do not wrap the callback in startTransition +// or defer it. That reintroduces the double-tap. +export function useMobileTapActivation( + enabled: boolean, + onActivate: () => void +): { + onPointerDown: PointerEventHandler; + onPointerMove: PointerEventHandler; + onPointerUp: PointerEventHandler; + onPointerCancel: PointerEventHandler; +} { + const onActivateRef = useRef(onActivate); + const pointerDownRef = useRef<{ + pointerId: number; + x: number; + y: number; + timestamp: number; + eligible: boolean; + } | null>(null); + onActivateRef.current = onActivate; + + const onPointerDown: PointerEventHandler = (evt) => { + if (!enabled || evt.pointerType !== 'touch' || !evt.isPrimary || evt.button !== 0) { + pointerDownRef.current = null; + return; + } + + pointerDownRef.current = { + pointerId: evt.pointerId, + x: evt.clientX, + y: evt.clientY, + timestamp: evt.timeStamp, + eligible: true, + }; + }; + const onPointerMove: PointerEventHandler = (evt) => { + const pointerDown = pointerDownRef.current; + if (!pointerDown || evt.pointerId !== pointerDown.pointerId) return; + + if ( + Math.abs(evt.clientX - pointerDown.x) > TAP_MOVEMENT_THRESHOLD || + Math.abs(evt.clientY - pointerDown.y) > TAP_MOVEMENT_THRESHOLD + ) { + pointerDown.eligible = false; + } + }; + const onPointerUp: PointerEventHandler = (evt) => { + const pointerDown = pointerDownRef.current; + if (!pointerDown || evt.pointerId !== pointerDown.pointerId) return; + + pointerDownRef.current = null; + if ( + !enabled || + evt.pointerType !== 'touch' || + !evt.isPrimary || + !pointerDown.eligible || + Math.abs(evt.clientX - pointerDown.x) > TAP_MOVEMENT_THRESHOLD || + Math.abs(evt.clientY - pointerDown.y) > TAP_MOVEMENT_THRESHOLD || + evt.timeStamp - pointerDown.timestamp >= MAX_TAP_DURATION + ) { + return; + } + + evt.preventDefault(); + onActivateRef.current(); + }; + const onPointerCancel: PointerEventHandler = (evt) => { + if (evt.pointerId !== pointerDownRef.current?.pointerId) return; + pointerDownRef.current = null; + }; + + return { onPointerDown, onPointerMove, onPointerUp, onPointerCancel }; +} diff --git a/src/app/pages/client/sidebar/InboxTab.tsx b/src/app/pages/client/sidebar/InboxTab.tsx index 67851b5d0..4c0fef696 100644 --- a/src/app/pages/client/sidebar/InboxTab.tsx +++ b/src/app/pages/client/sidebar/InboxTab.tsx @@ -25,6 +25,7 @@ import { Text, Box, color } from 'folds'; import { searchModalAtom } from '$state/searchModal'; import { EnvelopeSimple, getPhosphorIconSize, Tray } from '$components/icons/phosphor'; import { BookmarkIcon, ChatCircleDotsIcon } from '@phosphor-icons/react'; +import { useMobileTapActivation } from '$hooks/useMobileTapActivation'; export function InboxTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile?: boolean }) { const screenSize = useScreenSizeContext(); @@ -54,6 +55,7 @@ export function InboxTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile? const path = inviteCount > 0 ? getInboxInvitesPath() : getInboxNotificationsPath(); navigate(path); }; + const mobileTapActivation = useMobileTapActivation(isMobile ?? false, handleInboxClick); return ( @@ -65,6 +67,7 @@ export function InboxTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile? ref={triggerRef} outlined={!isMobile} onClick={handleInboxClick} + {...mobileTapActivation} size={'400'} > {(notificationsSelected && ( diff --git a/src/app/pages/client/sidebar/MessageTab.tsx b/src/app/pages/client/sidebar/MessageTab.tsx index 7e5d7473e..880f461db 100644 --- a/src/app/pages/client/sidebar/MessageTab.tsx +++ b/src/app/pages/client/sidebar/MessageTab.tsx @@ -22,6 +22,7 @@ import { roomToUnreadAtom } from '$state/room/roomToUnread'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { resolveUnreadBadgeMode } from '$components/unread-badge'; +import { useMobileTapActivation } from '$hooks/useMobileTapActivation'; export function MessageTab({ isBottom, isMobile }: { isBottom?: boolean; isMobile?: boolean }) { const rooms = useAtomValue(allRoomsAtom); @@ -48,6 +49,7 @@ export function MessageTab({ isBottom, isMobile }: { isBottom?: boolean; isMobil navigate(getSpacePath(lastSpaceId)); }; + const mobileTapActivation = useMobileTapActivation(isMobile ?? false, onBack); const [showUnreadCounts] = useSetting(settingsAtom, 'showUnreadCounts'); const [badgeCountDMsOnly] = useSetting(settingsAtom, 'badgeCountDMsOnly'); @@ -72,6 +74,7 @@ export function MessageTab({ isBottom, isMobile }: { isBottom?: boolean; isMobil ref={triggerRef} outlined={!isMobile} onClick={onBack} + {...mobileTapActivation} size={'400'} > @@ -28,6 +30,7 @@ export function NavigateTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi ref={triggerRef} outlined={!isMobile} onClick={open} + {...mobileTapActivation} size={'400'} > ) { type SpaceTabProps = { space: Room; selected: boolean; - onClick: MouseEventHandler; + onSelect: (spaceId: string) => void; folder?: ISidebarFolder; onDragging: (dragItem?: SidebarDraggable) => void; disabled?: boolean; @@ -546,7 +547,7 @@ type SpaceTabProps = { function SpaceTab({ space, selected, - onClick, + onSelect, folder, onDragging, disabled, @@ -554,6 +555,7 @@ function SpaceTab({ }: Readonly) { const isMobile = useScreenSizeContext() === ScreenSize.Mobile; const targetRef = useRef(null); + const mobileTapActivation = useMobileTapActivation(isMobile, () => onSelect(space.roomId)); const spaceDraggable: SidebarDraggable = useMemo( () => @@ -600,8 +602,9 @@ function SpaceTab({ data-id={space.roomId} ref={triggerRef} size={folder ? '300' : '400'} - onClick={onClick} + onClick={() => onSelect(space.roomId)} onContextMenu={handleContextMenu} + {...mobileTapActivation} > ) { const selectedSpaceId = useSelectedSpace(); - const handleSpaceClick: MouseEventHandler = (evt) => { - const target = evt.currentTarget; - const targetSpaceId = target.getAttribute('data-id'); - if (!targetSpaceId) return; - - setLastSpaceId(targetSpaceId); - const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId)); + const handleSpaceClick = (spaceId: string) => { + setLastSpaceId(spaceId); + const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, spaceId)); if (screenSize === ScreenSize.Mobile) { navigate(spacePath); return; } - const activePath = navToActivePath.get(targetSpaceId); + const activePath = navToActivePath.get(spaceId); if (activePath?.pathname.startsWith(spacePath)) { navigate(joinPathComponent(activePath)); return; } - navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, targetSpaceId))); + navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, spaceId))); }; const handleFolderToggle: MouseEventHandler = (evt) => { @@ -1061,7 +1060,7 @@ export function SpaceTabs({ scrollRef }: Readonly) { key={space.roomId} space={space} selected={space.roomId === selectedSpaceId} - onClick={handleSpaceClick} + onSelect={handleSpaceClick} folder={item} onDragging={setDraggingItem} disabled={ @@ -1100,7 +1099,7 @@ export function SpaceTabs({ scrollRef }: Readonly) { key={space.roomId} space={space} selected={space.roomId === selectedSpaceId} - onClick={handleSpaceClick} + onSelect={handleSpaceClick} onDragging={setDraggingItem} disabled={typeof draggingItem === 'string' ? draggingItem === space.roomId : false} onUnpin={orphanSpaces.includes(space.roomId) ? undefined : handleUnpin} diff --git a/src/app/pages/client/sidebar/UserMenuTab.tsx b/src/app/pages/client/sidebar/UserMenuTab.tsx index ec646bbac..64182785e 100644 --- a/src/app/pages/client/sidebar/UserMenuTab.tsx +++ b/src/app/pages/client/sidebar/UserMenuTab.tsx @@ -66,6 +66,7 @@ import { } from '@phosphor-icons/react'; import * as css from './UserMenuTab.css'; import { getMxIdServer } from '$utils/mxIdHelper'; +import { useMobileTapActivation } from '$hooks/useMobileTapActivation'; const log = createLogger('AccountSwitcherTab'); @@ -681,6 +682,9 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi }; const handleCloseMenu = () => setMenuAnchor(undefined); + const mobileTapActivation = useMobileTapActivation(isMobile ?? false, () => { + navigate(getProfilePath()); + }); const isActive = (!!menuAnchor || profileSelected) && !isMobile; @@ -690,6 +694,7 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi direction="Column" alignItems="Center" onClick={handleToggle} + {...mobileTapActivation} style={ isMobile ? { @@ -702,7 +707,7 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi } > }> - +