From 21b4ec1e59001bbd4c61528fa693bca49767c643 Mon Sep 17 00:00:00 2001 From: North-West-Wind Date: Sun, 19 Jan 2025 17:07:44 +0800 Subject: [PATCH 1/4] swipe left reply --- src/app/features/room/RoomTimeline.tsx | 16 +++---- src/app/features/room/message/Message.tsx | 22 ++++++++- src/app/features/room/message/styles.css.ts | 1 + src/app/hooks/useTouchOffset.ts | 53 +++++++++++++++++++++ 4 files changed, 80 insertions(+), 12 deletions(-) create mode 100644 src/app/hooks/useTouchOffset.ts diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 63b3d3e2cd..46d0d13ac3 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -117,6 +117,7 @@ import { useMentionClickHandler } from '../../hooks/useMentionClickHandler'; import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useTouchOffset } from '../../hooks/useTouchOffset'; const TimelineFloat = as<'div', css.TimelineFloatVariants>( ({ position, className, ...props }, ref) => ( @@ -888,13 +889,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli [mx, room, editor] ); - const handleReplyClick: MouseEventHandler = useCallback( - (evt) => { - const replyId = evt.currentTarget.getAttribute('data-event-id'); - if (!replyId) { - console.warn('Button should have "data-event-id" attribute!'); - return; - } + const handleReply = useCallback( + (replyId: string) => { const replyEvt = room.findEventById(replyId); if (!replyEvt) return; const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet()); @@ -990,7 +986,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli relations={hasReactions ? reactionRelations : undefined} onUserClick={handleUserClick} onUsernameClick={handleUsernameClick} - onReplyClick={handleReplyClick} + onReply={handleReply} onReactionToggle={handleReactionToggle} onEditId={handleEdit} reply={ @@ -1062,7 +1058,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli relations={hasReactions ? reactionRelations : undefined} onUserClick={handleUserClick} onUsernameClick={handleUsernameClick} - onReplyClick={handleReplyClick} + onReply={handleReply} onReactionToggle={handleReactionToggle} onEditId={handleEdit} reply={ @@ -1170,7 +1166,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli relations={hasReactions ? reactionRelations : undefined} onUserClick={handleUserClick} onUsernameClick={handleUsernameClick} - onReplyClick={handleReplyClick} + onReply={handleReply} onReactionToggle={handleReactionToggle} reactions={ reactionRelations && ( diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 21b186422d..436ee7c252 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -76,6 +76,7 @@ import { getViaServers } from '../../../plugins/via-servers'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents'; import { StateEvent } from '../../../../types/matrix/room'; +import { useTouchOffset } from '../../../hooks/useTouchOffset'; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; @@ -666,7 +667,7 @@ export type MessageProps = { messageSpacing: MessageSpacing; onUserClick: MouseEventHandler; onUsernameClick: MouseEventHandler; - onReplyClick: MouseEventHandler; + onReply: (replyId: string) => void; onEditId?: (eventId?: string) => void; onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; reply?: ReactNode; @@ -690,7 +691,7 @@ export const Message = as<'div', MessageProps>( messageSpacing, onUserClick, onUsernameClick, - onReplyClick, + onReply, onReactionToggle, onEditId, reply, @@ -708,6 +709,19 @@ export const Message = as<'div', MessageProps>( const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); const [menuAnchor, setMenuAnchor] = useState(); const [emojiBoardAnchor, setEmojiBoardAnchor] = useState(); + const { offset, onTouchStart, onTouchEnd, onTouchMove } = useTouchOffset({ limit: [-0.25 * window.innerWidth, 0, 0, 0], touchEndCallback: ([x]) => { + if (x < -0.2 * window.innerWidth) + onReply(mEvent.getId()!); + }}); + + const onReplyClick: MouseEventHandler = useCallback((evt) => { + const replyId = evt.currentTarget.getAttribute('data-event-id'); + if (!replyId) { + console.warn('Button should have "data-event-id" attribute!'); + return; + } + onReply(replyId); + }, []); const senderDisplayName = getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; @@ -838,6 +852,10 @@ export const Message = as<'div', MessageProps>( collapse={collapse} highlight={highlight} selected={!!menuAnchor || !!emojiBoardAnchor} + onTouchStart={onTouchStart} + onTouchEnd={onTouchEnd} + onTouchMove={onTouchMove} + style={{ transform: `translateX(${offset[0]}px)`, transition: offset[0] ? "none" : "" }} {...props} {...hoverProps} {...focusWithinProps} diff --git a/src/app/features/room/message/styles.css.ts b/src/app/features/room/message/styles.css.ts index b87cb50548..4dcfa62784 100644 --- a/src/app/features/room/message/styles.css.ts +++ b/src/app/features/room/message/styles.css.ts @@ -3,6 +3,7 @@ import { DefaultReset, config, toRem } from 'folds'; export const MessageBase = style({ position: 'relative', + transition: 'transform ease .25s' }); export const MessageOptionsBase = style([ diff --git a/src/app/hooks/useTouchOffset.ts b/src/app/hooks/useTouchOffset.ts new file mode 100644 index 0000000000..0f760c1735 --- /dev/null +++ b/src/app/hooks/useTouchOffset.ts @@ -0,0 +1,53 @@ +import React, { useEffect, useState } from "react"; + +type Coordinates = [number, number]; +type Limit = [number | undefined, number | undefined, number | undefined, number | undefined]; // x+, x-, y+, y- + +function getTouchListAverageCoordinates(list: React.TouchList) { + return Array.from(list).map(touch => [touch.clientX, touch.clientY]).reduce((a, b) => { + a[0] += b[0]; + a[1] += b[1]; + return a; + }).map(coord => coord / list.length) as Coordinates; +} + +function clamp(val: number, min: number | undefined, max: number | undefined) { + if (min === undefined && max === undefined) return val; + if (min === undefined) return Math.min(val, max!); + if (max === undefined) return Math.max(val, min!); + return Math.max(Math.min(val, max), min); +} + +export function useTouchOffset(options?: { limit?: Limit, touchEndCallback?: (offset: Coordinates) => any }) { + const [startPos, setStartPos] = useState(null); + const [offset, setOffset] = useState([0, 0]); + + const limitedSetOffset = (coords: Coordinates) => { + if (!options?.limit) setOffset(coords); + else setOffset(coords.map((coord, ii) => clamp(coord, options.limit![ii * 2], options.limit![ii * 2 + 1])) as Coordinates); + }; + + const onTouchStart = (event: React.TouchEvent) => { + const coords = getTouchListAverageCoordinates(event.touches); + if (!startPos) setStartPos(coords); + else limitedSetOffset(coords.map((coord, ii) => coord - startPos[ii]) as Coordinates); + }; + + const onTouchEnd = (event: React.TouchEvent) => { + if (event.touches.length == 0) { + if (options?.touchEndCallback) options.touchEndCallback(offset); + setStartPos(null); + setOffset([0, 0]); + } else if (startPos) { + const coords = getTouchListAverageCoordinates(event.touches); + limitedSetOffset(coords.map((coord, ii) => coord - startPos[ii]) as Coordinates); + } + }; + + const onTouchMove = (event: React.TouchEvent) => { + const coords = getTouchListAverageCoordinates(event.touches); + if (startPos) limitedSetOffset(coords.map((coord, ii) => coord - startPos[ii]) as Coordinates); + }; + + return { offset, onTouchStart, onTouchEnd, onTouchMove }; +} \ No newline at end of file From c2df0d83faa1f271a2a0053276f4fbbfade7a64e Mon Sep 17 00:00:00 2001 From: North-West-Wind Date: Sun, 19 Jan 2025 18:26:56 +0800 Subject: [PATCH 2/4] slide menu --- src/app/components/BackRouteHandler.tsx | 82 +------------------- src/app/components/SlideMenuChild.tsx | 22 ++++++ src/app/components/page/Page.tsx | 10 ++- src/app/components/page/style.css.ts | 10 +++ src/app/features/room/Room.tsx | 15 +++- src/app/features/room/RoomTimeline.tsx | 3 + src/app/features/room/message/Message.tsx | 2 +- src/app/hooks/useGoBack.ts | 77 ++++++++++++++++++ src/app/hooks/useSlideMenu.ts | 20 +++++ src/app/hooks/useTouchOffset.ts | 21 +++-- src/app/pages/client/explore/Featured.tsx | 14 +++- src/app/pages/client/explore/Server.tsx | 15 +++- src/app/pages/client/inbox/Invites.tsx | 16 +++- src/app/pages/client/inbox/Notifications.tsx | 15 +++- 14 files changed, 227 insertions(+), 95 deletions(-) create mode 100644 src/app/components/SlideMenuChild.tsx create mode 100644 src/app/hooks/useGoBack.ts create mode 100644 src/app/hooks/useSlideMenu.ts diff --git a/src/app/components/BackRouteHandler.tsx b/src/app/components/BackRouteHandler.tsx index fa3d759292..2e2eb02db3 100644 --- a/src/app/components/BackRouteHandler.tsx +++ b/src/app/components/BackRouteHandler.tsx @@ -1,86 +1,10 @@ -import { ReactNode, 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 } from '../pages/paths'; +import { ReactNode } from 'react'; +import { useGoBack } from '../hooks/useGoBack'; type BackRouteHandlerProps = { children: (onBack: () => void) => ReactNode; }; export function BackRouteHandler({ children }: BackRouteHandlerProps) { - const navigate = useNavigate(); - const location = useLocation(); - - const goBack = useCallback(() => { - 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 - ); - if (spaceMatch?.params.spaceIdOrAlias) { - navigate(getSpacePath(spaceMatch.params.spaceIdOrAlias)); - 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]); - + const goBack = useGoBack(); return children(goBack); } diff --git a/src/app/components/SlideMenuChild.tsx b/src/app/components/SlideMenuChild.tsx new file mode 100644 index 0000000000..1411004c74 --- /dev/null +++ b/src/app/components/SlideMenuChild.tsx @@ -0,0 +1,22 @@ +import { ReactNode } from "react"; +import { matchPath, useLocation } from "react-router-dom"; +import { HOME_PATH, DIRECT_PATH, SPACE_PATH, EXPLORE_PATH, INBOX_PATH } from "../pages/paths"; +import React from "react"; +import { Direct } from "../pages/client/direct"; +import { Explore } from "../pages/client/explore"; +import { Home } from "../pages/client/home"; +import { Inbox } from "../pages/client/inbox"; +import { Space } from "../pages/client/space"; + +export function SlideMenuChild() { + const location = useLocation(); + + let previousComponent: ReactNode; + if (matchPath({ path: HOME_PATH, caseSensitive: true, end: false, }, location.pathname)) previousComponent = ; + else if (matchPath({ path: DIRECT_PATH, caseSensitive: true, end: false, }, location.pathname)) previousComponent = ; + else if (matchPath({ path: EXPLORE_PATH, caseSensitive: true, end: false, }, location.pathname)) previousComponent = ; + else if (matchPath({ path: INBOX_PATH, caseSensitive: true, end: false, }, location.pathname)) previousComponent = ; + else if (matchPath({ path: SPACE_PATH, caseSensitive: true, end: false, }, location.pathname)) previousComponent = ; + + return previousComponent; +} \ No newline at end of file diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index a8b9ea0446..17149478f8 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -1,4 +1,4 @@ -import React, { ComponentProps, MutableRefObject, ReactNode } from 'react'; +import React, { ComponentProps, CSSProperties, MutableRefObject, ReactNode } from 'react'; import { Box, Header, Line, Scroll, Text, as } from 'folds'; import classNames from 'classnames'; import { ContainerColor } from '../../styles/ContainerColor.css'; @@ -146,3 +146,11 @@ export function PageHero({ export const PageContentCenter = as<'div'>(({ className, ...props }, ref) => (
)); + +export function PageRootFloat({ children, style }: { children: ReactNode, style: CSSProperties }) { + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/src/app/components/page/style.css.ts b/src/app/components/page/style.css.ts index 23f2da4941..f104ea967c 100644 --- a/src/app/components/page/style.css.ts +++ b/src/app/components/page/style.css.ts @@ -78,3 +78,13 @@ export const PageContentCenter = style([ margin: 'auto', }, ]); + +export const PageRootFloat = style([ + DefaultReset, + { + position: "fixed", + left: 0, top: 0, + width: "100vw", height: "100vh", + transition: "transform ease .25s" + } +]); \ No newline at end of file diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index ee3e702740..2b5624dc53 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -13,6 +13,10 @@ import { useKeyDown } from '../../hooks/useKeyDown'; import { markAsRead } from '../../../client/action/notifications'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoomMembers } from '../../hooks/useRoomMembers'; +import { PageRootFloat } from '../../components/page'; +import { SidebarNav } from '../../pages/client/SidebarNav'; +import { SlideMenuChild } from '../../components/SlideMenuChild'; +import { useSlideMenu } from '../../hooks/useSlideMenu'; export function Room() { const { eventId } = useParams(); @@ -24,6 +28,8 @@ export function Room() { const powerLevels = usePowerLevels(room); const members = useRoomMembers(mx, room.roomId); + const { offset, offsetOverride, onTouchStart, onTouchEnd, onTouchMove } = useSlideMenu(); + useKeyDown( window, useCallback( @@ -38,7 +44,7 @@ export function Room() { return ( - + {screenSize === ScreenSize.Desktop && isDrawer && ( <> @@ -47,6 +53,13 @@ export function Room() { )} + {screenSize === ScreenSize.Mobile && + + + } ); } diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 46d0d13ac3..8b146d56fe 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -118,6 +118,9 @@ import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useTouchOffset } from '../../hooks/useTouchOffset'; +import { PageRoot } from '../../components/page'; +import { Space } from '../../pages/client/space'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; const TimelineFloat = as<'div', css.TimelineFloatVariants>( ({ position, className, ...props }, ref) => ( diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 436ee7c252..dc464cf56d 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -709,7 +709,7 @@ export const Message = as<'div', MessageProps>( const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); const [menuAnchor, setMenuAnchor] = useState(); const [emojiBoardAnchor, setEmojiBoardAnchor] = useState(); - const { offset, onTouchStart, onTouchEnd, onTouchMove } = useTouchOffset({ limit: [-0.25 * window.innerWidth, 0, 0, 0], touchEndCallback: ([x]) => { + const { offset, onTouchStart, onTouchEnd, onTouchMove } = useTouchOffset({ offsetLimit: [-0.25 * window.innerWidth, 0, 0, 0], touchEndCallback: ([x]) => { if (x < -0.2 * window.innerWidth) onReply(mEvent.getId()!); }}); diff --git a/src/app/hooks/useGoBack.ts b/src/app/hooks/useGoBack.ts new file mode 100644 index 0000000000..400811a9dc --- /dev/null +++ b/src/app/hooks/useGoBack.ts @@ -0,0 +1,77 @@ +import { useCallback } from "react"; +import { useNavigate, useLocation, matchPath } from "react-router-dom"; +import { HOME_PATH, DIRECT_PATH, SPACE_PATH, EXPLORE_PATH, INBOX_PATH } from "../pages/paths"; +import { getHomePath, getDirectPath, getSpacePath, getExplorePath, getInboxPath } from "../pages/pathUtils"; + +export function useGoBack() { + const navigate = useNavigate(); + const location = useLocation(); + + const goBack = useCallback(() => { + 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 + ); + if (spaceMatch?.params.spaceIdOrAlias) { + navigate(getSpacePath(spaceMatch.params.spaceIdOrAlias)); + 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]); + + return goBack; +} \ No newline at end of file diff --git a/src/app/hooks/useSlideMenu.ts b/src/app/hooks/useSlideMenu.ts new file mode 100644 index 0000000000..e23d1d9165 --- /dev/null +++ b/src/app/hooks/useSlideMenu.ts @@ -0,0 +1,20 @@ +import { useState } from "react"; +import { useGoBack } from "./useGoBack"; +import { useTouchOffset } from "./useTouchOffset"; + +export function useSlideMenu() { + const goBack = useGoBack(); + const [offsetOverride, setOffsetOverride] = useState(false); + const { offset, onTouchStart, onTouchEnd, onTouchMove } = useTouchOffset({ + startLimit: [0, 0.1 * window.innerWidth, undefined, undefined], + offsetLimit: [0, window.innerWidth, 0, 0], + touchEndCallback: (offset) => { + if (offset[0] > window.innerWidth * 0.5) { + setOffsetOverride(true); + setTimeout(() => goBack(), 250); + } + } + }); + + return { offset, offsetOverride, onTouchStart, onTouchEnd, onTouchMove }; +} \ No newline at end of file diff --git a/src/app/hooks/useTouchOffset.ts b/src/app/hooks/useTouchOffset.ts index 0f760c1735..cbd68a2415 100644 --- a/src/app/hooks/useTouchOffset.ts +++ b/src/app/hooks/useTouchOffset.ts @@ -11,26 +11,35 @@ function getTouchListAverageCoordinates(list: React.TouchList) { }).map(coord => coord / list.length) as Coordinates; } -function clamp(val: number, min: number | undefined, max: number | undefined) { +function clamp(val: number, min?: number, max?: number) { if (min === undefined && max === undefined) return val; if (min === undefined) return Math.min(val, max!); if (max === undefined) return Math.max(val, min!); return Math.max(Math.min(val, max), min); } -export function useTouchOffset(options?: { limit?: Limit, touchEndCallback?: (offset: Coordinates) => any }) { +function isBetween(val: number, min?: number, max?: number) { + if (min === undefined && max === undefined) return true; + if (min === undefined) return val <= max!; + if (max === undefined) return val >= min!; + return val >= min && val <= max; +} + +export function useTouchOffset(options?: { startLimit?: Limit, offsetLimit?: Limit, touchEndCallback?: (offset: Coordinates) => any }) { const [startPos, setStartPos] = useState(null); const [offset, setOffset] = useState([0, 0]); const limitedSetOffset = (coords: Coordinates) => { - if (!options?.limit) setOffset(coords); - else setOffset(coords.map((coord, ii) => clamp(coord, options.limit![ii * 2], options.limit![ii * 2 + 1])) as Coordinates); + if (!options?.offsetLimit) setOffset(coords); + else setOffset(coords.map((coord, ii) => clamp(coord, options.offsetLimit![ii * 2], options.offsetLimit![ii * 2 + 1])) as Coordinates); }; const onTouchStart = (event: React.TouchEvent) => { const coords = getTouchListAverageCoordinates(event.touches); - if (!startPos) setStartPos(coords); - else limitedSetOffset(coords.map((coord, ii) => coord - startPos[ii]) as Coordinates); + if (!startPos) { + if (!options?.startLimit || coords.map((coord, ii) => isBetween(coord, options.startLimit![ii * 2], options.startLimit![ii * 2 + 1])).every(x => x)) + setStartPos(coords); + } else limitedSetOffset(coords.map((coord, ii) => coord - startPos[ii]) as Coordinates); }; const onTouchEnd = (event: React.TouchEvent) => { diff --git a/src/app/pages/client/explore/Featured.tsx b/src/app/pages/client/explore/Featured.tsx index f056cbb5cd..5df4cdd51b 100644 --- a/src/app/pages/client/explore/Featured.tsx +++ b/src/app/pages/client/explore/Featured.tsx @@ -12,12 +12,16 @@ import { PageHeader, PageHero, PageHeroSection, + PageRootFloat, } from '../../../components/page'; import { RoomTopicViewer } from '../../../components/room-topic-viewer'; import * as css from './style.css'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; +import { useSlideMenu } from '../../../hooks/useSlideMenu'; +import { SlideMenuChild } from '../../../components/SlideMenuChild'; +import { SidebarNav } from '../SidebarNav'; export function FeaturedRooms() { const { featuredCommunities } = useClientConfig(); @@ -25,6 +29,7 @@ export function FeaturedRooms() { const allRooms = useAtomValue(allRoomsAtom); const screenSize = useScreenSizeContext(); const { navigateSpace, navigateRoom } = useRoomNavigate(); + const { offset, offsetOverride, onTouchStart, onTouchEnd, onTouchMove } = useSlideMenu(); return ( @@ -41,7 +46,7 @@ export function FeaturedRooms() { )} - + @@ -133,6 +138,13 @@ export function FeaturedRooms() { + {screenSize === ScreenSize.Mobile && + + + } ); } diff --git a/src/app/pages/client/explore/Server.tsx b/src/app/pages/client/explore/Server.tsx index 1f493df17f..49196f6760 100644 --- a/src/app/pages/client/explore/Server.tsx +++ b/src/app/pages/client/explore/Server.tsx @@ -32,7 +32,7 @@ import FocusTrap from 'focus-trap-react'; import { useAtomValue } from 'jotai'; import { useQuery } from '@tanstack/react-query'; import { MatrixClient, Method, RoomType } from 'matrix-js-sdk'; -import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page'; +import { Page, PageContent, PageContentCenter, PageHeader, PageRootFloat } from '../../../components/page'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { RoomTopicViewer } from '../../../components/room-topic-viewer'; import { RoomCard, RoomCardBase, RoomCardGrid } from '../../../components/room-card'; @@ -45,6 +45,9 @@ import { getMxIdServer } from '../../../utils/matrix'; import { stopPropagation } from '../../../utils/keyboard'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; +import { useSlideMenu } from '../../../hooks/useSlideMenu'; +import { SlideMenuChild } from '../../../components/SlideMenuChild'; +import { SidebarNav } from '../SidebarNav'; const useServerSearchParams = (searchParams: URLSearchParams): ExploreServerPathSearchParams => useMemo( @@ -356,6 +359,7 @@ export function PublicRooms() { const searchInputRef = useRef(null); const navigate = useNavigate(); const roomTypeFilters = useRoomTypeFilters(); + const { offset, offsetOverride, onTouchStart, onTouchEnd, onTouchMove } = useSlideMenu(); const currentLimit: number = useMemo(() => { const limitParam = serverSearchParams.limit; @@ -516,7 +520,7 @@ export function PublicRooms() { )} - + @@ -661,6 +665,13 @@ export function PublicRooms() { + {screenSize === ScreenSize.Mobile && + + + } ); } diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx index 8dcfa1c209..14974ff5cd 100644 --- a/src/app/pages/client/inbox/Invites.tsx +++ b/src/app/pages/client/inbox/Invites.tsx @@ -18,7 +18,7 @@ import { import { useAtomValue } from 'jotai'; import FocusTrap from 'focus-trap-react'; import { MatrixError, Room } from 'matrix-js-sdk'; -import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page'; +import { Page, PageContent, PageContentCenter, PageHeader, PageRootFloat } from '../../../components/page'; import { useDirectInvites, useRoomInvites, useSpaceInvites } from '../../../state/hooks/inviteList'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { allInvitesAtom } from '../../../state/room-list/inviteList'; @@ -43,6 +43,9 @@ import { useRoomTopic } from '../../../hooks/useRoomMeta'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { useSlideMenu } from '../../../hooks/useSlideMenu'; +import { SlideMenuChild } from '../../../components/SlideMenuChild'; +import { SidebarNav } from '../SidebarNav'; const COMPACT_CARD_WIDTH = 548; @@ -214,6 +217,8 @@ export function Invites() { const { navigateRoom, navigateSpace } = useRoomNavigate(); + const { offset, offsetOverride, onTouchStart, onTouchEnd, onTouchMove } = useSlideMenu(); + const renderInvite = (roomId: string, direct: boolean, handleNavigate: (rId: string) => void) => { const room = mx.getRoom(roomId); if (!room) return null; @@ -253,7 +258,7 @@ export function Invites() { - + @@ -304,6 +309,13 @@ export function Invites() { + {screenSize === ScreenSize.Mobile && + + + } ); } diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index 0c832b0946..2c6d0901b4 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -26,7 +26,7 @@ import { import { useVirtualizer } from '@tanstack/react-virtual'; import { HTMLReactParserOptions } from 'html-react-parser'; import { Opts as LinkifyOpts } from 'linkifyjs'; -import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page'; +import { Page, PageContent, PageContentCenter, PageHeader, PageRootFloat } from '../../../components/page'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; import { InboxNotificationsPathSearchParams } from '../../paths'; @@ -82,6 +82,9 @@ import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { useSlideMenu } from '../../../hooks/useSlideMenu'; +import { SlideMenuChild } from '../../../components/SlideMenuChild'; +import { SidebarNav } from '../SidebarNav'; type RoomNotificationsGroup = { roomId: string; @@ -506,6 +509,7 @@ export function Notifications() { const scrollRef = useRef(null); const scrollTopAnchorRef = useRef(null); const [refreshIntervalTime, setRefreshIntervalTime] = useState(DEFAULT_REFRESH_MS); + const { offset, offsetOverride, onTouchStart, onTouchEnd, onTouchMove } = useSlideMenu(); const onlyHighlight = notificationsSearchParams.only === 'highlight'; const setOnlyHighlighted = (highlight: boolean) => { @@ -587,7 +591,7 @@ export function Notifications() { - + @@ -711,6 +715,13 @@ export function Notifications() { + {screenSize === ScreenSize.Mobile && + + + } ); } From b4862d5fb84bf584eeb32b3bb3786a6016e39965 Mon Sep 17 00:00:00 2001 From: North-West-Wind Date: Sun, 19 Jan 2025 18:41:47 +0800 Subject: [PATCH 3/4] velocity slide menu --- src/app/hooks/useSlideMenu.ts | 4 ++-- src/app/hooks/useTouchOffset.ts | 38 ++++++++++++++++++++++----------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/app/hooks/useSlideMenu.ts b/src/app/hooks/useSlideMenu.ts index e23d1d9165..a75763e8fc 100644 --- a/src/app/hooks/useSlideMenu.ts +++ b/src/app/hooks/useSlideMenu.ts @@ -8,8 +8,8 @@ export function useSlideMenu() { const { offset, onTouchStart, onTouchEnd, onTouchMove } = useTouchOffset({ startLimit: [0, 0.1 * window.innerWidth, undefined, undefined], offsetLimit: [0, window.innerWidth, 0, 0], - touchEndCallback: (offset) => { - if (offset[0] > window.innerWidth * 0.5) { + touchEndCallback: (offset, velocity, averageVelocity) => { + if (offset[0] > window.innerWidth * 0.5 || (velocity && velocity[0] > 0.9) || (averageVelocity && averageVelocity[0] > 0.8)) { setOffsetOverride(true); setTimeout(() => goBack(), 250); } diff --git a/src/app/hooks/useTouchOffset.ts b/src/app/hooks/useTouchOffset.ts index cbd68a2415..c6e01f1b94 100644 --- a/src/app/hooks/useTouchOffset.ts +++ b/src/app/hooks/useTouchOffset.ts @@ -1,6 +1,6 @@ import React, { useEffect, useState } from "react"; -type Coordinates = [number, number]; +type Vec2 = [number, number]; type Limit = [number | undefined, number | undefined, number | undefined, number | undefined]; // x+, x-, y+, y- function getTouchListAverageCoordinates(list: React.TouchList) { @@ -8,7 +8,7 @@ function getTouchListAverageCoordinates(list: React.TouchList) { a[0] += b[0]; a[1] += b[1]; return a; - }).map(coord => coord / list.length) as Coordinates; + }).map(coord => coord / list.length) as Vec2; } function clamp(val: number, min?: number, max?: number) { @@ -25,38 +25,50 @@ function isBetween(val: number, min?: number, max?: number) { return val >= min && val <= max; } -export function useTouchOffset(options?: { startLimit?: Limit, offsetLimit?: Limit, touchEndCallback?: (offset: Coordinates) => any }) { - const [startPos, setStartPos] = useState(null); - const [offset, setOffset] = useState([0, 0]); +export function useTouchOffset(options?: { startLimit?: Limit, offsetLimit?: Limit, touchEndCallback?: (offset: Vec2, velocity?: Vec2, averageVelocity?: Vec2) => any }) { + const [startPos, setStartPos] = useState(null); + const [offset, setOffset] = useState([0, 0]); + const [velocity, setVelocity] = useState([0, 0]); + const [_, setLastTime] = useState(Date.now()); + const [startTime, setStartTime] = useState(Date.now()); - const limitedSetOffset = (coords: Coordinates) => { + const limitedSetOffset = (coords: Vec2) => { + setLastTime(lt => { + const now = Date.now(); + setVelocity(Array.from(coords).map((coord, ii) => (coord - offset[ii]) / (now - lt)) as Vec2); + return Date.now(); + }); if (!options?.offsetLimit) setOffset(coords); - else setOffset(coords.map((coord, ii) => clamp(coord, options.offsetLimit![ii * 2], options.offsetLimit![ii * 2 + 1])) as Coordinates); + else setOffset(coords.map((coord, ii) => clamp(coord, options.offsetLimit![ii * 2], options.offsetLimit![ii * 2 + 1])) as Vec2); }; const onTouchStart = (event: React.TouchEvent) => { const coords = getTouchListAverageCoordinates(event.touches); if (!startPos) { - if (!options?.startLimit || coords.map((coord, ii) => isBetween(coord, options.startLimit![ii * 2], options.startLimit![ii * 2 + 1])).every(x => x)) + if (!options?.startLimit || coords.map((coord, ii) => isBetween(coord, options.startLimit![ii * 2], options.startLimit![ii * 2 + 1])).every(x => x)) { setStartPos(coords); - } else limitedSetOffset(coords.map((coord, ii) => coord - startPos[ii]) as Coordinates); + setLastTime(Date.now()); + setStartTime(Date.now()); + } + } else limitedSetOffset(coords.map((coord, ii) => coord - startPos[ii]) as Vec2); }; const onTouchEnd = (event: React.TouchEvent) => { if (event.touches.length == 0) { - if (options?.touchEndCallback) options.touchEndCallback(offset); + if (options?.touchEndCallback) options.touchEndCallback(offset, velocity, !startPos ? undefined : offset.map(((coord, ii) => (coord - startPos[ii]) / (Date.now() - startTime))) as Vec2); setStartPos(null); setOffset([0, 0]); + setVelocity([0, 0]); } else if (startPos) { const coords = getTouchListAverageCoordinates(event.touches); - limitedSetOffset(coords.map((coord, ii) => coord - startPos[ii]) as Coordinates); + limitedSetOffset(coords.map((coord, ii) => coord - startPos[ii]) as Vec2); } }; const onTouchMove = (event: React.TouchEvent) => { const coords = getTouchListAverageCoordinates(event.touches); - if (startPos) limitedSetOffset(coords.map((coord, ii) => coord - startPos[ii]) as Coordinates); + if (startPos) limitedSetOffset(coords.map((coord, ii) => coord - startPos[ii]) as Vec2); }; - return { offset, onTouchStart, onTouchEnd, onTouchMove }; + return { offset, velocity, onTouchStart, onTouchEnd, onTouchMove }; } \ No newline at end of file From 5b8c673d8a98016fe55e549e5bb1e816c512ccc9 Mon Sep 17 00:00:00 2001 From: North-West-Wind Date: Sun, 19 Jan 2025 22:23:18 +0800 Subject: [PATCH 4/4] a lot of comments --- src/app/components/SlideMenuChild.tsx | 1 + src/app/components/page/Page.tsx | 1 + src/app/features/room/Room.tsx | 1 + src/app/features/room/RoomTimeline.tsx | 1 + src/app/features/room/message/Message.tsx | 2 ++ src/app/hooks/useGoBack.ts | 1 + src/app/hooks/useSlideMenu.ts | 2 ++ src/app/hooks/useTouchOffset.ts | 21 ++++++++++++++++---- src/app/pages/client/explore/Featured.tsx | 1 + src/app/pages/client/explore/Server.tsx | 1 + src/app/pages/client/inbox/Invites.tsx | 1 + src/app/pages/client/inbox/Notifications.tsx | 1 + 12 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/app/components/SlideMenuChild.tsx b/src/app/components/SlideMenuChild.tsx index 1411004c74..0add2306be 100644 --- a/src/app/components/SlideMenuChild.tsx +++ b/src/app/components/SlideMenuChild.tsx @@ -8,6 +8,7 @@ import { Home } from "../pages/client/home"; import { Inbox } from "../pages/client/inbox"; import { Space } from "../pages/client/space"; +// A component to match path and return the corresponding slide menu parent. export function SlideMenuChild() { const location = useLocation(); diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index 17149478f8..85b8ba010c 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -147,6 +147,7 @@ export const PageContentCenter = as<'div'>(({ className, ...props }, ref) => (
)); +// Only used in mobile for slide menu export function PageRootFloat({ children, style }: { children: ReactNode, style: CSSProperties }) { return ( diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 2b5624dc53..11fe94f1d1 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -53,6 +53,7 @@ export function Room() { )} + {/* Create a slide menu offscreen for mobile. Same for all other slide menus. */} {screenSize === ScreenSize.Mobile && { const replyEvt = room.findEventById(replyId); diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index dc464cf56d..97ab721e5f 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -709,11 +709,13 @@ export const Message = as<'div', MessageProps>( const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); const [menuAnchor, setMenuAnchor] = useState(); const [emojiBoardAnchor, setEmojiBoardAnchor] = useState(); + // Swipe left gesture that, if it is pulled to the left by 20% of screen width, trigger a reply const { offset, onTouchStart, onTouchEnd, onTouchMove } = useTouchOffset({ offsetLimit: [-0.25 * window.innerWidth, 0, 0, 0], touchEndCallback: ([x]) => { if (x < -0.2 * window.innerWidth) onReply(mEvent.getId()!); }}); + // Wrapper of the new onReply for the old onReplyClick const onReplyClick: MouseEventHandler = useCallback((evt) => { const replyId = evt.currentTarget.getAttribute('data-event-id'); if (!replyId) { diff --git a/src/app/hooks/useGoBack.ts b/src/app/hooks/useGoBack.ts index 400811a9dc..bcdb7c2d76 100644 --- a/src/app/hooks/useGoBack.ts +++ b/src/app/hooks/useGoBack.ts @@ -3,6 +3,7 @@ import { useNavigate, useLocation, matchPath } from "react-router-dom"; import { HOME_PATH, DIRECT_PATH, SPACE_PATH, EXPLORE_PATH, INBOX_PATH } from "../pages/paths"; import { getHomePath, getDirectPath, getSpacePath, getExplorePath, getInboxPath } from "../pages/pathUtils"; +// Moved goBack from BackRouteHandler for reusabilitiy export function useGoBack() { const navigate = useNavigate(); const location = useLocation(); diff --git a/src/app/hooks/useSlideMenu.ts b/src/app/hooks/useSlideMenu.ts index a75763e8fc..72ccb7982e 100644 --- a/src/app/hooks/useSlideMenu.ts +++ b/src/app/hooks/useSlideMenu.ts @@ -2,6 +2,7 @@ import { useState } from "react"; import { useGoBack } from "./useGoBack"; import { useTouchOffset } from "./useTouchOffset"; +// Reusable slide menu gesture handler export function useSlideMenu() { const goBack = useGoBack(); const [offsetOverride, setOffsetOverride] = useState(false); @@ -11,6 +12,7 @@ export function useSlideMenu() { touchEndCallback: (offset, velocity, averageVelocity) => { if (offset[0] > window.innerWidth * 0.5 || (velocity && velocity[0] > 0.9) || (averageVelocity && averageVelocity[0] > 0.8)) { setOffsetOverride(true); + // Slide menu transition takes .25s so we wait for that. setTimeout(() => goBack(), 250); } } diff --git a/src/app/hooks/useTouchOffset.ts b/src/app/hooks/useTouchOffset.ts index c6e01f1b94..8ea11becba 100644 --- a/src/app/hooks/useTouchOffset.ts +++ b/src/app/hooks/useTouchOffset.ts @@ -1,8 +1,9 @@ -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; -type Vec2 = [number, number]; -type Limit = [number | undefined, number | undefined, number | undefined, number | undefined]; // x+, x-, y+, y- +type Vec2 = [number, number]; // x, y +type Limit = [number | undefined, number | undefined, number | undefined, number | undefined]; // min x, max x, min y, max y +// Function for calculating average touch position from multiple touches. function getTouchListAverageCoordinates(list: React.TouchList) { return Array.from(list).map(touch => [touch.clientX, touch.clientY]).reduce((a, b) => { a[0] += b[0]; @@ -11,6 +12,7 @@ function getTouchListAverageCoordinates(list: React.TouchList) { }).map(coord => coord / list.length) as Vec2; } +// Used for offset limits. It makes sure the offset value is within the range. Undefined means there is no limit for min/max. function clamp(val: number, min?: number, max?: number) { if (min === undefined && max === undefined) return val; if (min === undefined) return Math.min(val, max!); @@ -18,6 +20,7 @@ function clamp(val: number, min?: number, max?: number) { return Math.max(Math.min(val, max), min); } +// Used for start limits. Returns a boolean indicating if a value is valid to be considered as a starting point. function isBetween(val: number, min?: number, max?: number) { if (min === undefined && max === undefined) return true; if (min === undefined) return val <= max!; @@ -25,6 +28,7 @@ function isBetween(val: number, min?: number, max?: number) { return val >= min && val <= max; } +// Returns the offset from start point to end point export function useTouchOffset(options?: { startLimit?: Limit, offsetLimit?: Limit, touchEndCallback?: (offset: Vec2, velocity?: Vec2, averageVelocity?: Vec2) => any }) { const [startPos, setStartPos] = useState(null); const [offset, setOffset] = useState([0, 0]); @@ -32,6 +36,7 @@ export function useTouchOffset(options?: { startLimit?: Limit, offsetLimit?: Lim const [_, setLastTime] = useState(Date.now()); const [startTime, setStartTime] = useState(Date.now()); + // Wrapper of setOffset and setVelocity, making sure offset is within limit const limitedSetOffset = (coords: Vec2) => { setLastTime(lt => { const now = Date.now(); @@ -45,21 +50,28 @@ export function useTouchOffset(options?: { startLimit?: Limit, offsetLimit?: Lim const onTouchStart = (event: React.TouchEvent) => { const coords = getTouchListAverageCoordinates(event.touches); if (!startPos) { + // Start if limit is not set, or the touch point is within limits if (!options?.startLimit || coords.map((coord, ii) => isBetween(coord, options.startLimit![ii * 2], options.startLimit![ii * 2 + 1])).every(x => x)) { setStartPos(coords); setLastTime(Date.now()); setStartTime(Date.now()); } - } else limitedSetOffset(coords.map((coord, ii) => coord - startPos[ii]) as Vec2); + } + // Touches after the first one. Calculate offset using the average point. + else limitedSetOffset(coords.map((coord, ii) => coord - startPos[ii]) as Vec2); }; const onTouchEnd = (event: React.TouchEvent) => { + // Length 0 means there are no more touching points if (event.touches.length == 0) { + // A callback with offset, velocity and average velocity. Used to determine if the gesture is strong enough for actions. if (options?.touchEndCallback) options.touchEndCallback(offset, velocity, !startPos ? undefined : offset.map(((coord, ii) => (coord - startPos[ii]) / (Date.now() - startTime))) as Vec2); + // Reset starting point, offset and velocity. setStartPos(null); setOffset([0, 0]); setVelocity([0, 0]); } else if (startPos) { + // There are still point(s) being touched. Use the average of them to calculate offset. const coords = getTouchListAverageCoordinates(event.touches); limitedSetOffset(coords.map((coord, ii) => coord - startPos[ii]) as Vec2); } @@ -67,6 +79,7 @@ export function useTouchOffset(options?: { startLimit?: Limit, offsetLimit?: Lim const onTouchMove = (event: React.TouchEvent) => { const coords = getTouchListAverageCoordinates(event.touches); + // Update offset according to touch point movement if (startPos) limitedSetOffset(coords.map((coord, ii) => coord - startPos[ii]) as Vec2); }; diff --git a/src/app/pages/client/explore/Featured.tsx b/src/app/pages/client/explore/Featured.tsx index 5df4cdd51b..9821fafb84 100644 --- a/src/app/pages/client/explore/Featured.tsx +++ b/src/app/pages/client/explore/Featured.tsx @@ -138,6 +138,7 @@ export function FeaturedRooms() { + {/* Create a slide menu offscreen for mobile. Same for all other slide menus. */} {screenSize === ScreenSize.Mobile && + {/* Create a slide menu offscreen for mobile. Same for all other slide menus. */} {screenSize === ScreenSize.Mobile && + {/* Create a slide menu offscreen for mobile. Same for all other slide menus. */} {screenSize === ScreenSize.Mobile && + {/* Create a slide menu offscreen for mobile. Same for all other slide menus. */} {screenSize === ScreenSize.Mobile &&