diff --git a/src/app/components/message/layout/Base.tsx b/src/app/components/message/layout/Base.tsx index 35fa772..aa5ffbf 100644 --- a/src/app/components/message/layout/Base.tsx +++ b/src/app/components/message/layout/Base.tsx @@ -35,6 +35,7 @@ export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice? size="T400" priority={notice ? '300' : '400'} className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)} + data-allow-text-selection="true" {...props} ref={ref} /> diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 33fcc02..017943e 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -215,6 +215,8 @@ export const MessageTextBody = recipe({ overflow: 'clip', overflowY: 'clip', lineHeight: '1.5', + userSelect: 'text', + WebkitUserSelect: 'text', }, variants: { preWrap: { diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index 70c6ad3..5cde616 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -60,10 +60,12 @@ export const PageNavHeader = as<'header', css.PageNavHeaderVariants>( export function PageNavContent({ scrollRef, + scrollProps, children, }: { children: ReactNode; scrollRef?: MutableRefObject; + scrollProps?: React.ComponentProps; }) { return ( @@ -74,6 +76,7 @@ export function PageNavContent({ size="300" hideTrack visibility="Hover" + {...scrollProps} >
{children}
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 2997026..38a7ea5 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -52,7 +52,7 @@ import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../ import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator'; import { useAlive } from '../../hooks/useAlive'; -import { autoScrollToBottom, editableActiveElement } from '../../utils/dom'; +import { autoScrollToBottom, editableActiveElement, isScrolledToBottom } from '../../utils/dom'; import { DefaultPlaceholder, CompactPlaceholder, @@ -110,6 +110,7 @@ import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange'; import { RenderMessageContent } from '../../components/RenderMessageContent'; import { Image } from '../../components/media'; import { ImageViewer } from '../../components/image-viewer'; +import { useInertialHorizontalScroll } from '../../hooks/useInertialHorizontalScroll'; import { roomToParentsAtom } from '../../state/room/roomToParents'; import { useRoomUnread } from '../../state/hooks/unread'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; @@ -651,7 +652,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli useCallback( (mEvt: MatrixEvent) => { const isOwnMessage = mEvt.getSender() === mx.getUserId(); - const shouldStickToBottom = atBottomRef.current || isOwnMessage; + const isBottomPinnedEvent = + mEvt.getType() === MessageEvent.RoomMessage || mEvt.getType() === MessageEvent.Sticker; + const shouldStickToBottom = atBottomRef.current || (isOwnMessage && isBottomPinnedEvent); // Always update timeline with new message setTimeline((ct) => ({ @@ -676,10 +679,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli setUnreadInfo(getRoomUnreadInfo(room)); } - // Preserve the pre-update bottom state so a newly added message keeps the view pinned. - scrollToBottomRef.current.count += 1; - scrollToBottomRef.current.smooth = !isOwnMessage; - scrollToBottomRef.current.force = shouldStickToBottom; + // Preserve the pre-update bottom state so newly added chat messages keep the view pinned. + if (shouldStickToBottom) { + scrollToBottomRef.current.count += 1; + scrollToBottomRef.current.smooth = !isOwnMessage; + scrollToBottomRef.current.force = true; + } // Show unread message preview for incoming messages when not at bottom if (!isOwnMessage && !atBottomRef.current) { @@ -753,7 +758,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const scrollElement = getScrollElement(); if (!editorBaseEntry || !scrollElement) return; - if (atBottomRef.current) { + if (isScrolledToBottom(scrollElement)) { autoScrollToBottom(scrollElement, false, true); } }; @@ -774,7 +779,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const previousHeight = timelineContentHeightRef.current; timelineContentHeightRef.current = nextHeight; - if (previousHeight === 0 || nextHeight <= previousHeight || !atBottomRef.current) { + if (previousHeight === 0 || nextHeight <= previousHeight || !isScrolledToBottom(scrollElement)) { return; } @@ -2023,7 +2028,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ref={timelineContentRef} direction="Column" justifyContent="End" - style={{ minHeight: '100%', padding: `${config.space.S600} 0` }} + style={{ + minHeight: '100%', + padding: `${config.space.S600} 0`, + userSelect: 'none', + WebkitUserSelect: 'none', + }} > {!canPaginateBack && rangeAtStart && getItems().length > 0 && (
; + isDragging: boolean; + canScrollBack: boolean; + canScrollForward: boolean; + scrollBack: () => void; + scrollForward: () => void; + scrollProps: { + onPointerDown: (evt: ReactPointerEvent) => void; + onPointerMove: (evt: ReactPointerEvent) => void; + onPointerUp: (evt: ReactPointerEvent) => void; + onPointerCancel: (evt: ReactPointerEvent) => void; + onLostPointerCapture: (evt: ReactPointerEvent) => void; + onDragStartCapture: (evt: React.DragEvent) => void; + onClickCapture: (evt: ReactMouseEvent) => void; + }; +}; + +const TEXT_SELECTION_TARGET_SELECTOR = + '[data-allow-text-selection="true"], input, textarea, [contenteditable="true"]'; + +type ScrollState = { + activePointerId: number | null; + startPrimary: number; + startScrollPrimary: number; + lastPrimary: number; + lastTime: number; + velocity: number; + dragging: boolean; + moved: boolean; + suppressClick: boolean; + animationFrameId: number | null; +}; + +type PointerLikeEvent = { + pointerId: number; + clientX: number; + clientY: number; + timeStamp: number; + preventDefault?: () => void; +}; + +const DEFAULT_OPTIONS: Required = { + axis: 'x', + scrollStepRatio: 1.3, + dragThreshold: 3, + friction: 0.95, + minVelocity: 0.02, +}; + +/** + * Shared hook for drag-scrolling horizontal overflow areas with inertial momentum. + */ +export function useInertialHorizontalScroll( + options: UseInertialHorizontalScrollOptions = {} +): UseInertialHorizontalScrollResult { + const resolvedOptions = useMemo( + () => ({ ...DEFAULT_OPTIONS, ...options }), + [options] + ); + + const scrollRef = useRef(null); + const stateRef = useRef({ + activePointerId: null, + startPrimary: 0, + startScrollPrimary: 0, + lastPrimary: 0, + lastTime: 0, + velocity: 0, + dragging: false, + moved: false, + suppressClick: false, + animationFrameId: null, + }); + const [isDragging, setIsDragging] = useState(false); + const [canScrollBack, setCanScrollBack] = useState(false); + const [canScrollForward, setCanScrollForward] = useState(false); + + const updateScrollState = useCallback(() => { + const scroll = scrollRef.current; + if (!scroll) return; + + const isHorizontal = resolvedOptions.axis === 'x'; + const scrollPrimary = isHorizontal ? scroll.scrollLeft : scroll.scrollTop; + const scrollSize = isHorizontal ? scroll.scrollWidth : scroll.scrollHeight; + const clientSize = isHorizontal ? scroll.clientWidth : scroll.clientHeight; + const maxScrollPrimary = Math.max(scrollSize - clientSize, 0); + setCanScrollBack(scrollPrimary > 1); + setCanScrollForward(scrollPrimary < maxScrollPrimary - 1); + }, [resolvedOptions.axis]); + + useEffect(() => { + updateScrollState(); + + const scroll = scrollRef.current; + if (!scroll) return undefined; + + const handleScroll = () => { + updateScrollState(); + }; + + const handleResize = () => { + updateScrollState(); + }; + + scroll.addEventListener('scroll', handleScroll, { passive: true }); + window.addEventListener('resize', handleResize, { passive: true }); + + return () => { + scroll.removeEventListener('scroll', handleScroll); + window.removeEventListener('resize', handleResize); + }; + }, [updateScrollState]); + + useEffect( + () => () => { + const { animationFrameId } = stateRef.current; + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + } + }, + [] + ); + + const scrollByStep = useCallback((direction: 1 | -1) => { + const scroll = scrollRef.current; + if (!scroll) return; + + const isHorizontal = resolvedOptions.axis === 'x'; + const currentPrimary = isHorizontal ? scroll.scrollLeft : scroll.scrollTop; + const offsetSize = isHorizontal ? scroll.offsetWidth : scroll.offsetHeight; + scroll.scrollTo({ + ...(isHorizontal + ? { left: currentPrimary + offsetSize / resolvedOptions.scrollStepRatio * direction } + : { top: currentPrimary + offsetSize / resolvedOptions.scrollStepRatio * direction }), + behavior: 'smooth', + }); + }, [resolvedOptions.axis, resolvedOptions.scrollStepRatio]); + + const stopInertia = useCallback(() => { + const state = stateRef.current; + if (state.animationFrameId !== null) { + cancelAnimationFrame(state.animationFrameId); + state.animationFrameId = null; + } + }, []); + + const startInertia = useCallback(() => { + const state = stateRef.current; + const scroll = scrollRef.current; + if (!scroll) return; + const isHorizontal = resolvedOptions.axis === 'x'; + + const step = (timestamp: number, previousTimestamp: number) => { + const currentState = stateRef.current; + const currentScroll = scrollRef.current; + if (!currentScroll) return; + + const deltaMs = Math.max(timestamp - previousTimestamp, 1); + const deltaScroll = currentState.velocity * deltaMs; + if (Math.abs(currentState.velocity) < resolvedOptions.minVelocity) { + currentState.animationFrameId = null; + updateScrollState(); + return; + } + + const before = isHorizontal ? currentScroll.scrollLeft : currentScroll.scrollTop; + if (isHorizontal) { + currentScroll.scrollLeft = before - deltaScroll; + } else { + currentScroll.scrollTop = before - deltaScroll; + } + updateScrollState(); + + const after = isHorizontal ? currentScroll.scrollLeft : currentScroll.scrollTop; + if (after === before) { + currentState.animationFrameId = null; + return; + } + + currentState.velocity *= Math.pow(resolvedOptions.friction, deltaMs / 16.6667); + currentState.animationFrameId = requestAnimationFrame((nextTimestamp) => step(nextTimestamp, timestamp)); + }; + + stopInertia(); + state.animationFrameId = requestAnimationFrame((timestamp) => step(timestamp, timestamp)); + }, [resolvedOptions.axis, resolvedOptions.friction, resolvedOptions.minVelocity, stopInertia, updateScrollState]); + + const endDrag = useCallback((pointerId: number | null) => { + const state = stateRef.current; + if (!state.dragging) return; + + const scroll = scrollRef.current; + if (scroll && pointerId !== null && scroll.hasPointerCapture(pointerId)) { + scroll.releasePointerCapture(pointerId); + } + + if (state.moved) { + state.suppressClick = true; + startInertia(); + } + + state.dragging = false; + state.activePointerId = null; + state.moved = false; + state.velocity = 0; + setIsDragging(false); + }, [startInertia]); + + const processPointerMove = useCallback((evt: PointerLikeEvent) => { + const state = stateRef.current; + if (!state.dragging || state.activePointerId !== evt.pointerId) { + return; + } + + const scroll = scrollRef.current; + if (!scroll) return; + const isHorizontal = resolvedOptions.axis === 'x'; + + const currentPrimary = isHorizontal ? evt.clientX : evt.clientY; + const deltaPrimary = currentPrimary - state.startPrimary; + const now = evt.timeStamp; + const deltaTime = Math.max(now - state.lastTime, 1); + + if (Math.abs(deltaPrimary) >= resolvedOptions.dragThreshold) { + state.moved = true; + } + + evt.preventDefault?.(); + + if (isHorizontal) { + scroll.scrollLeft = state.startScrollPrimary - deltaPrimary; + } else { + scroll.scrollTop = state.startScrollPrimary - deltaPrimary; + } + + const rawVelocity = (currentPrimary - state.lastPrimary) / deltaTime; + state.velocity = Math.max(Math.min(rawVelocity, 2), -2); + state.lastPrimary = currentPrimary; + state.lastTime = now; + }, [resolvedOptions.axis, resolvedOptions.dragThreshold]); + + const handlePointerDown = useCallback((evt: React.PointerEvent) => { + if (evt.button !== 0) return; + if (!evt.isPrimary) return; + + const state = stateRef.current; + if (state.dragging && state.activePointerId === evt.pointerId) return; + + const target = evt.target; + if (target instanceof Element && target.closest(TEXT_SELECTION_TARGET_SELECTOR)) { + return; + } + + const scroll = scrollRef.current; + if (!scroll) return; + const isHorizontal = resolvedOptions.axis === 'x'; + + stopInertia(); + evt.preventDefault(); + evt.stopPropagation(); + + state.activePointerId = evt.pointerId; + state.startPrimary = isHorizontal ? evt.clientX : evt.clientY; + state.startScrollPrimary = isHorizontal ? scroll.scrollLeft : scroll.scrollTop; + state.lastPrimary = isHorizontal ? evt.clientX : evt.clientY; + state.lastTime = evt.timeStamp; + state.velocity = 0; + state.dragging = true; + state.moved = false; + state.suppressClick = false; + + try { + scroll.setPointerCapture(evt.pointerId); + } catch { + // Ignore capture failures when browser has already ended pointer interaction. + } + + setIsDragging(true); + }, [resolvedOptions.axis, stopInertia]); + + const handlePointerMove = useCallback((evt: React.PointerEvent) => { + processPointerMove(evt); + }, [processPointerMove]); + + const handlePointerUp = useCallback((evt: React.PointerEvent) => { + const state = stateRef.current; + if (state.activePointerId !== evt.pointerId) return; + endDrag(evt.pointerId); + }, [endDrag]); + + const handlePointerCancel = useCallback((evt: React.PointerEvent) => { + const state = stateRef.current; + if (state.activePointerId !== evt.pointerId) return; + endDrag(evt.pointerId); + }, [endDrag]); + + const handleLostPointerCapture = useCallback((evt: React.PointerEvent) => { + const state = stateRef.current; + if (state.activePointerId !== evt.pointerId) return; + endDrag(evt.pointerId); + }, [endDrag]); + + useEffect(() => { + const handleWindowPointerMove = (evt: PointerEvent) => { + processPointerMove(evt); + }; + + const handleWindowPointerUpOrCancel = (evt: PointerEvent) => { + const state = stateRef.current; + if (state.activePointerId !== evt.pointerId) return; + endDrag(evt.pointerId); + }; + + window.addEventListener('pointermove', handleWindowPointerMove, true); + window.addEventListener('pointerup', handleWindowPointerUpOrCancel, true); + window.addEventListener('pointercancel', handleWindowPointerUpOrCancel, true); + + return () => { + window.removeEventListener('pointermove', handleWindowPointerMove, true); + window.removeEventListener('pointerup', handleWindowPointerUpOrCancel, true); + window.removeEventListener('pointercancel', handleWindowPointerUpOrCancel, true); + }; + }, [endDrag, processPointerMove]); + + useEffect(() => { + const handleGlobalDragTermination = () => { + const state = stateRef.current; + if (!state.dragging) return; + endDrag(state.activePointerId); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState !== 'visible') { + handleGlobalDragTermination(); + } + }; + + window.addEventListener('mouseup', handleGlobalDragTermination, true); + window.addEventListener('pointerup', handleGlobalDragTermination, true); + window.addEventListener('blur', handleGlobalDragTermination, true); + document.addEventListener('visibilitychange', handleVisibilityChange, true); + + return () => { + window.removeEventListener('mouseup', handleGlobalDragTermination, true); + window.removeEventListener('pointerup', handleGlobalDragTermination, true); + window.removeEventListener('blur', handleGlobalDragTermination, true); + document.removeEventListener('visibilitychange', handleVisibilityChange, true); + }; + }, [endDrag]); + + const handleClickCapture = useCallback((evt: React.MouseEvent) => { + const state = stateRef.current; + if (!state.suppressClick) return; + + state.suppressClick = false; + evt.preventDefault(); + evt.stopPropagation(); + }, []); + + const handleDragStartCapture = useCallback((evt: React.DragEvent) => { + evt.preventDefault(); + }, []); + + return { + scrollRef, + isDragging, + canScrollBack, + canScrollForward, + scrollBack: () => scrollByStep(-1), + scrollForward: () => scrollByStep(1), + scrollProps: { + onPointerDown: handlePointerDown, + onPointerMove: handlePointerMove, + onPointerUp: handlePointerUp, + onPointerCancel: handlePointerCancel, + onLostPointerCapture: handleLostPointerCapture, + onDragStartCapture: handleDragStartCapture, + onClickCapture: handleClickCapture, + }, + }; +} \ No newline at end of file diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index 4240317..bc32bd5 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -57,7 +57,7 @@ import { import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected'; import { allInvitesAtom } from '../../../state/room-list/inviteList'; import { UnreadBadge } from '../../../components/unread-badge'; - + type DirectMenuProps = { requestClose: () => void; }; @@ -235,7 +235,6 @@ const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct'); export function Direct() { const mx = useMatrixClient(); useNavToActivePathMapper('direct'); - const scrollRef = useRef(null); const directs = useDirectRooms(); const notificationPreferences = useRoomsNotificationPreferencesContext(); const roomToUnread = useAtomValue(roomToUnreadAtom); @@ -246,6 +245,7 @@ export function Direct() { const selectedRoomId = useSelectedRoom(); const noRoomToDisplay = directs.length === 0; const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); + const scrollRef = useRef(null); const reorderTrigger = useRoomListReorder(mx, directs);