diff --git a/src/app/components/DefaultErrorPage.tsx b/src/app/components/DefaultErrorPage.tsx index 62042cef1..045b1ea50 100644 --- a/src/app/components/DefaultErrorPage.tsx +++ b/src/app/components/DefaultErrorPage.tsx @@ -1,7 +1,6 @@ import { Box, Button, Dialog, Icon, Icons, Text, color, config } from 'folds'; import * as Sentry from '@sentry/react'; import { SplashScreen } from '$components/splash-screen'; -import { buildGitHubUrl } from '$features/bug-report/BugReportModal'; type ErrorPageProps = { error: Error; @@ -25,7 +24,9 @@ ${error.message} ${stacktrace} \`\`\``; - return buildGitHubUrl('bug', `Error: ${error.message}`, { context: automatedBugReport }); + const title = encodeURIComponent(`Error: ${error.message}`); + const body = encodeURIComponent(automatedBugReport); + return `https://github.com/SableClient/Sable/issues/new?template=bug_report.yml&title=${title}&description=${body}`; } // This component is used as the fallback for the ErrorBoundary in App.tsx, which means it will be rendered whenever an uncaught error is thrown in any of the child components and not handled locally. diff --git a/src/app/components/RoomNotificationSwitcher.test.tsx b/src/app/components/RoomNotificationSwitcher.test.tsx index bd0b7c04c..28bf44808 100644 --- a/src/app/components/RoomNotificationSwitcher.test.tsx +++ b/src/app/components/RoomNotificationSwitcher.test.tsx @@ -56,7 +56,7 @@ describe('RoomNotificationModeSwitcher', () => { RoomNotificationMode.SpecialMessages, RoomNotificationMode.Unset ); - }); + }, 15000); it('disables interaction while the room mode is changing', () => { modeStateStatus.current = 'loading'; diff --git a/src/app/components/SwipeableChatWrapper.tsx b/src/app/components/SwipeableChatWrapper.tsx index d4a547298..4035d70b0 100644 --- a/src/app/components/SwipeableChatWrapper.tsx +++ b/src/app/components/SwipeableChatWrapper.tsx @@ -1,10 +1,13 @@ -import type { ReactNode } from 'react'; -import { motion, useMotionValue, useSpring } from 'framer-motion'; -import { useDrag } from '@use-gesture/react'; +import { lazy, Suspense, type ReactNode } from 'react'; import { useAtomValue } from 'jotai'; -import { settingsAtom, RightSwipeAction } from '$state/settings'; +import { settingsAtom } from '$state/settings'; import { mobileOrTablet } from '$utils/user-agent'; +const SwipeableChatWrapperActive = lazy(async () => { + const mod = await import('./SwipeableChatWrapperActive'); + return { default: mod.SwipeableChatWrapperActive }; +}); + interface SwipeableChatWrapperProps { children: ReactNode; onOpenSidebar?: () => void; @@ -19,76 +22,10 @@ export function SwipeableChatWrapper({ onReply, }: SwipeableChatWrapperProps) { const settings = useAtomValue(settingsAtom); - const x = useMotionValue(0); - const springX = useSpring(x, { stiffness: 400, damping: 40 }); - - 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 (!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)) { - onOpenSidebar?.(); - } else if (val < -swipeThreshold || (vx > velocityThreshold && dx < 0 && val < 0)) { - if (settings.rightSwipeAction === RightSwipeAction.Members) { - onOpenMembers?.(); - } else { - onReply?.(); - } - } - x.set(0); - } - }, - { - axis: 'x', - bounds: { left: -200, right: 200 }, - rubberband: true, - filterTaps: true, - } - ); - - if (!settings.mobileGestures || !mobileOrTablet()) { - return ( -
- {children} -
- ); - } - - return ( + const plainWrapper = (
- + ); + + if (!settings.mobileGestures || !mobileOrTablet()) { + return plainWrapper; + } + + return ( + + {children} - -
+ + ); } diff --git a/src/app/components/SwipeableChatWrapperActive.tsx b/src/app/components/SwipeableChatWrapperActive.tsx new file mode 100644 index 000000000..dd7f9839b --- /dev/null +++ b/src/app/components/SwipeableChatWrapperActive.tsx @@ -0,0 +1,93 @@ +import type { ReactNode } from 'react'; +import { motion, useMotionValue, useSpring } from 'framer-motion'; +import { useDrag } from '@use-gesture/react'; +import { RightSwipeAction, type Settings } from '$state/settings'; + +interface SwipeableChatWrapperActiveProps { + children: ReactNode; + settings: Settings; + onOpenSidebar?: () => void; + onOpenMembers?: () => void; + onReply?: () => void; +} + +export function SwipeableChatWrapperActive({ + children, + settings, + onOpenSidebar, + onOpenMembers, + onReply, +}: SwipeableChatWrapperActiveProps) { + const x = useMotionValue(0); + const springX = useSpring(x, { stiffness: 400, damping: 40 }); + + 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; + } + } + + 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)) { + onOpenSidebar?.(); + } else if (val < -swipeThreshold || (vx > velocityThreshold && dx < 0 && val < 0)) { + if (settings.rightSwipeAction === RightSwipeAction.Members) { + onOpenMembers?.(); + } else { + onReply?.(); + } + } + x.set(0); + } + }, + { + axis: 'x', + bounds: { left: -200, right: 200 }, + rubberband: true, + filterTaps: true, + } + ); + + return ( +
+ + {children} + +
+ ); +} diff --git a/src/app/components/SwipeableMessageWrapper.tsx b/src/app/components/SwipeableMessageWrapper.tsx index 58fc9293e..0a7c75da4 100644 --- a/src/app/components/SwipeableMessageWrapper.tsx +++ b/src/app/components/SwipeableMessageWrapper.tsx @@ -1,70 +1,12 @@ -import { useMotionValue, useSpring, useTransform, motion } from 'framer-motion'; -import { useDrag } from '@use-gesture/react'; -import type { ReactNode } from 'react'; -import { useMemo, useState } from 'react'; +import { lazy, Suspense, type ReactNode } from 'react'; import { useAtomValue } from 'jotai'; -import { config, Icon, Icons } from 'folds'; import { mobileOrTablet } from '$utils/user-agent'; import { RightSwipeAction, settingsAtom } from '$state/settings'; -function ActiveSwipeWrapper({ children, onReply }: { children: ReactNode; onReply: () => 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 bind = useDrag( - ({ active, movement: [mx] }) => { - if (active) { - const val = mx < 0 ? mx : 0; - x.set(Math.max(-80, val)); - if (mx < -50 !== isReady) setIsReady(mx < -50); - } else { - if (mx < -50) onReply(); - x.set(0); - setIsReady(false); - } - }, - { - axis: 'x', - bounds: { right: 0 }, - rubberband: true, - filterTaps: true, - eventOptions: { passive: true }, - } - ); - - return ( -
-
- - - -
- {children} -
- ); -} +const SwipeableMessageWrapperActive = lazy(async () => { + const mod = await import('./SwipeableMessageWrapperActive'); + return { default: mod.SwipeableMessageWrapperActive }; +}); export function SwipeableMessageWrapper({ children, @@ -75,17 +17,18 @@ export function SwipeableMessageWrapper({ }) { const settings = useAtomValue(settingsAtom); - const isSwipeToReplyEnabled = useMemo( - () => - settings.mobileGestures && - mobileOrTablet() && - settings.rightSwipeAction !== RightSwipeAction.Members, - [settings.mobileGestures, settings.rightSwipeAction] - ); + const isSwipeToReplyEnabled = + settings.mobileGestures && + mobileOrTablet() && + settings.rightSwipeAction !== RightSwipeAction.Members; if (!isSwipeToReplyEnabled) { return children; } - return {children}; + return ( + + {children} + + ); } diff --git a/src/app/components/SwipeableMessageWrapperActive.tsx b/src/app/components/SwipeableMessageWrapperActive.tsx new file mode 100644 index 000000000..1f79458fd --- /dev/null +++ b/src/app/components/SwipeableMessageWrapperActive.tsx @@ -0,0 +1,73 @@ +import type { ReactNode } from 'react'; +import { useMemo, useState } from 'react'; +import { useMotionValue, useSpring, useTransform, motion } from 'framer-motion'; +import { useDrag } from '@use-gesture/react'; +import { config, Icon, Icons } from 'folds'; + +export function SwipeableMessageWrapperActive({ + children, + onReply, +}: { + children: ReactNode; + onReply: () => 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 bind = useDrag( + ({ active, movement: [mx] }) => { + if (active) { + const val = mx < 0 ? mx : 0; + x.set(Math.max(-80, val)); + if (mx < -50 !== isReady) setIsReady(mx < -50); + } else { + if (mx < -50) onReply(); + x.set(0); + setIsReady(false); + } + }, + { + axis: 'x', + bounds: { right: 0 }, + rubberband: true, + filterTaps: true, + eventOptions: { passive: true }, + } + ); + + const iconColor = useMemo( + () => (isReady ? 'var(--sable-surface-on-container)' : 'var(--sable-surface-container)'), + [isReady] + ); + + return ( +
+
+ + + +
+ {children} +
+ ); +} diff --git a/src/app/components/SwipeableOverlayWrapper.tsx b/src/app/components/SwipeableOverlayWrapper.tsx index a77b802f5..daeadd5cf 100644 --- a/src/app/components/SwipeableOverlayWrapper.tsx +++ b/src/app/components/SwipeableOverlayWrapper.tsx @@ -1,10 +1,13 @@ -import type { ReactNode } from 'react'; -import { motion, useMotionValue, useSpring } from 'framer-motion'; -import { useDrag } from '@use-gesture/react'; +import { lazy, Suspense, type ReactNode } from 'react'; import { useAtomValue } from 'jotai'; import { settingsAtom } from '$state/settings'; import { mobileOrTablet } from '$utils/user-agent'; +const SwipeableOverlayWrapperActive = lazy(async () => { + const mod = await import('./SwipeableOverlayWrapperActive'); + return { default: mod.SwipeableOverlayWrapperActive }; +}); + interface SwipeableOverlayWrapperProps { children: ReactNode; onClose: () => void; @@ -17,75 +20,10 @@ export function SwipeableOverlayWrapper({ direction, }: SwipeableOverlayWrapperProps) { const settings = useAtomValue(settingsAtom); - const x = useMotionValue(0); - const springX = useSpring(x, { stiffness: 400, damping: 40 }); - - 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(); - - let val = mx; - - if (direction === 'left' && val > 0) val = 0; - if (direction === 'right' && val < 0) val = 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); - } - }, - { - axis: 'x', - bounds: direction === 'left' ? { left: -300, right: 0 } : { left: 0, right: 300 }, - rubberband: true, - filterTaps: true, - pointer: { capture: true }, - } - ); - - if (!settings.mobileGestures || !mobileOrTablet()) { - return ( -
- {children} -
- ); - } - return ( + const plainWrapper = (
- - {children} - + {children}
); + + if (!settings.mobileGestures || !mobileOrTablet()) { + return plainWrapper; + } + + return ( + + + {children} + + + ); } diff --git a/src/app/components/SwipeableOverlayWrapperActive.tsx b/src/app/components/SwipeableOverlayWrapperActive.tsx new file mode 100644 index 000000000..5c117358b --- /dev/null +++ b/src/app/components/SwipeableOverlayWrapperActive.tsx @@ -0,0 +1,87 @@ +import type { ReactNode } from 'react'; +import { motion, useMotionValue, useSpring } from 'framer-motion'; +import { useDrag } from '@use-gesture/react'; + +interface SwipeableOverlayWrapperActiveProps { + children: ReactNode; + onClose: () => void; + direction: 'left' | 'right'; +} + +export function SwipeableOverlayWrapperActive({ + children, + onClose, + direction, +}: SwipeableOverlayWrapperActiveProps) { + const x = useMotionValue(0); + const springX = useSpring(x, { stiffness: 400, damping: 40 }); + + 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; + } + } + + event.stopPropagation(); + + let val = mx; + + if (direction === 'left' && val > 0) val = 0; + if (direction === 'right' && val < 0) val = 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); + } + }, + { + axis: 'x', + bounds: direction === 'left' ? { left: -300, right: 0 } : { left: 0, right: 300 }, + rubberband: true, + filterTaps: true, + pointer: { capture: true }, + } + ); + + return ( +
+ + {children} + +
+ ); +} diff --git a/src/app/components/UserRoomProfileRenderer.test.tsx b/src/app/components/UserRoomProfileRenderer.test.tsx new file mode 100644 index 000000000..6f2f3a1cb --- /dev/null +++ b/src/app/components/UserRoomProfileRenderer.test.tsx @@ -0,0 +1,57 @@ +import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'; +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { UserRoomProfileRenderer } from './UserRoomProfileRenderer'; + +const mocks = vi.hoisted(() => ({ + state: { + roomId: '!room:example.org', + userId: '@alice:example.org', + cords: new DOMRect(0, 0, 1, 1), + }, + room: { + roomId: '!room:example.org', + }, +})); + +vi.mock('folds', () => ({ + Menu: forwardRef>( + ({ children, ...props }, ref) => ( +
+ {children} +
+ ) + ), + PopOut: vi.fn<({ content }: { content: ReactNode }) => ReactNode>(({ content }) => ( +
{content}
+ )), + toRem: vi.fn<(value: number) => string>((value) => `${value / 16}rem`), +})); + +vi.mock('$state/hooks/userRoomProfile', () => ({ + useCloseUserRoomProfile: () => vi.fn<() => void>(), + useUserRoomProfileState: () => mocks.state, +})); + +vi.mock('$hooks/useGetRoom', () => ({ + useAllJoinedRoomsSet: () => new Set([mocks.room.roomId]), + useGetRoom: () => (roomId: string) => (roomId === mocks.room.roomId ? mocks.room : undefined), +})); + +vi.mock('$hooks/useSpace', () => ({ + SpaceProvider: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock('$hooks/useRoom', () => ({ + RoomProvider: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock('./user-profile', () => ({ + UserRoomProfile: () => , +})); + +describe('UserRoomProfileRenderer', () => { + it('does not throw while lazy profile content is loading inside the focus trap', () => { + expect(() => render()).not.toThrow(); + }); +}); diff --git a/src/app/components/UserRoomProfileRenderer.tsx b/src/app/components/UserRoomProfileRenderer.tsx index 866bb6b0f..4e07e1270 100644 --- a/src/app/components/UserRoomProfileRenderer.tsx +++ b/src/app/components/UserRoomProfileRenderer.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense, useRef } from 'react'; import { Menu, PopOut, toRem } from 'folds'; import FocusTrap from 'focus-trap-react'; import { useCloseUserRoomProfile, useUserRoomProfileState } from '$state/hooks/userRoomProfile'; @@ -6,9 +7,14 @@ import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom'; import { stopPropagation } from '$utils/keyboard'; import { SpaceProvider } from '$hooks/useSpace'; import { RoomProvider } from '$hooks/useRoom'; -import { UserRoomProfile } from './user-profile'; + +const UserRoomProfile = lazy(async () => { + const mod = await import('./user-profile'); + return { default: mod.UserRoomProfile }; +}); function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) { + const menuRef = useRef(null); const { roomId, spaceId, userId, cords, position, initialProfile } = state; const allJoinedRooms = useAllJoinedRoomsSet(); const getRoom = useGetRoom(allJoinedRooms); @@ -28,15 +34,18 @@ function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) menuRef.current ?? document.body, onDeactivate: close, clickOutsideDeactivates: true, escapeDeactivates: stopPropagation, }} > - + - + + + diff --git a/src/app/components/code-highlight/CodeHighlightRenderer.tsx b/src/app/components/code-highlight/CodeHighlightRenderer.tsx index 45145fbb4..c1aefb3c1 100644 --- a/src/app/components/code-highlight/CodeHighlightRenderer.tsx +++ b/src/app/components/code-highlight/CodeHighlightRenderer.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { highlightCode, type HighlightResult, useArboriumThemeStatus } from '$plugins/arborium'; +import { loadSpaceMono } from '$utils/loadSpaceMono'; import * as css from './CodeHighlightRenderer.css'; type CodeHighlightRendererProps = { @@ -44,6 +45,10 @@ export function CodeHighlightRenderer({ result: createPlainResult(code, language), })); + useEffect(() => { + void loadSpaceMono(); + }, []); + useEffect(() => { let cancelled = false; diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 13868c4bf..310af5833 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -141,7 +141,7 @@ export const ImageContent = as<'div', ImageContentProps>( }, [mx, url, useAuthentication, mimeType, encInfo, matrixThumbnailMaxEdge, info?.w, info?.h]) ); - useEffect(() => { + useEffect((): void | (() => void) => { if (!viewer) { setViewerFullSrc(null); return; @@ -200,7 +200,7 @@ export const ImageContent = as<'div', ImageContentProps>( : isContained ? { minHeight: containedReserveStrip ? toRem(stripMin) : undefined } : hasDimensions - ? { aspectRatio: `${info!.w} / ${info!.h}` } + ? { aspectRatio: `${info.w} / ${info.h}` } : { minHeight: '150px' }; const fillPreviewSlotStyle = fillsSlot diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 34383df76..685eed323 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -85,6 +85,25 @@ function isLikelyPlayableOgVideo(prev: IPreviewUrlResponse): boolean { return false; } +function isLikelyPlayableOgAudio(prev: IPreviewUrlResponse): boolean { + const raw = prev['og:audio']; + if (typeof raw !== 'string') return false; + const url = raw.trim(); + if (!url) return false; + const mime = + typeof prev['og:audio:type'] === 'string' ? prev['og:audio:type'].toLowerCase().trim() : ''; + if (mime.startsWith('audio/')) return true; + if (/^mxc:\/\//i.test(url)) { + return ( + mime.startsWith('audio/') || /\.(mp3|m4a|aac|ogg|oga|opus|wav|flac|webm)(\?|$)/i.test(url) + ); + } + if (/^https?:\/\//i.test(url)) { + return /\.(mp3|m4a|aac|ogg|oga|opus|wav|flac)(\?|$)/i.test(url); + } + return false; +} + export const UrlPreviewCard = as< 'div', { @@ -320,10 +339,10 @@ export const UrlPreviewCard = as< /> )} - {!showOgVideo && !prev['og:image'] && prev['og:audio'] && ( + {!showOgVideo && !prev['og:image'] && isLikelyPlayableOgAudio(prev) && ( } diff --git a/src/app/features/bug-report/BugReportModal.tsx b/src/app/features/bug-report/BugReportModal.tsx index ee7d263bd..7185055a9 100644 --- a/src/app/features/bug-report/BugReportModal.tsx +++ b/src/app/features/bug-report/BugReportModal.tsx @@ -85,7 +85,7 @@ export function buildGitHubUrl( return `https://github.com/${GITHUB_REPO}/issues/new?${new URLSearchParams(params)}`; } -function BugReportModal() { +export function BugReportModal() { const close = useCloseBugReportModal(); const sentryEnabled = Sentry.isInitialized(); const [type, setType] = useState('bug'); diff --git a/src/app/features/bug-report/BugReportModalRenderer.tsx b/src/app/features/bug-report/BugReportModalRenderer.tsx new file mode 100644 index 000000000..194e44ba7 --- /dev/null +++ b/src/app/features/bug-report/BugReportModalRenderer.tsx @@ -0,0 +1,19 @@ +import { lazy, Suspense } from 'react'; +import { useBugReportModalOpen } from '$state/hooks/bugReportModal'; + +const BugReportModal = lazy(async () => { + const mod = await import('./BugReportModal'); + return { default: mod.BugReportModal }; +}); + +export function BugReportModalRenderer() { + const open = useBugReportModalOpen(); + + if (!open) return null; + + return ( + + + + ); +} diff --git a/src/app/features/bug-report/index.ts b/src/app/features/bug-report/index.ts deleted file mode 100644 index 6ed1c3753..000000000 --- a/src/app/features/bug-report/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { BugReportModalRenderer } from './BugReportModal'; diff --git a/src/app/features/create-room/CreateRoomModal.tsx b/src/app/features/create-room/CreateRoomModal.tsx index ba3f20a01..398394b5b 100644 --- a/src/app/features/create-room/CreateRoomModal.tsx +++ b/src/app/features/create-room/CreateRoomModal.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from 'react'; import { Box, config, @@ -19,7 +20,11 @@ import { useCloseCreateRoomModal, useCreateRoomModalState } from '$state/hooks/c import type { CreateRoomModalState } from '$state/createRoomModal'; import { stopPropagation } from '$utils/keyboard'; import { CreateRoomType } from '$components/create-room/types'; -import { CreateRoomForm } from './CreateRoom'; + +const CreateRoomForm = lazy(async () => { + const mod = await import('./CreateRoom'); + return { default: mod.CreateRoomForm }; +}); type CreateRoomModalProps = { state: CreateRoomModalState; @@ -73,7 +78,9 @@ function CreateRoomModal({ state }: CreateRoomModalProps) { direction="Column" gap="500" > - + + + diff --git a/src/app/features/create-room/index.ts b/src/app/features/create-room/index.ts deleted file mode 100644 index f60c94b37..000000000 --- a/src/app/features/create-room/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './CreateRoom'; -export * from './CreateRoomModal'; diff --git a/src/app/features/create-space/CreateSpaceModal.tsx b/src/app/features/create-space/CreateSpaceModal.tsx index 44d8acbf2..082d1c696 100644 --- a/src/app/features/create-space/CreateSpaceModal.tsx +++ b/src/app/features/create-space/CreateSpaceModal.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from 'react'; import { Box, config, @@ -18,7 +19,11 @@ import { SpaceProvider } from '$hooks/useSpace'; import { useCloseCreateSpaceModal, useCreateSpaceModalState } from '$state/hooks/createSpaceModal'; import type { CreateSpaceModalState } from '$state/createSpaceModal'; import { stopPropagation } from '$utils/keyboard'; -import { CreateSpaceForm } from './CreateSpace'; + +const CreateSpaceForm = lazy(async () => { + const mod = await import('./CreateSpace'); + return { default: mod.CreateSpaceForm }; +}); type CreateSpaceModalProps = { state: CreateSpaceModalState; @@ -71,7 +76,9 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) { direction="Column" gap="500" > - + + + diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index 165343c82..20d17a0ad 100644 --- a/src/app/features/lobby/HierarchyItemMenu.tsx +++ b/src/app/features/lobby/HierarchyItemMenu.tsx @@ -37,6 +37,7 @@ import { getCanonicalAliasOrRoomId } from '$utils/matrix'; import { useNavigate } from 'react-router-dom'; import { getSpaceLobbyPath } from '$pages/pathUtils'; import { EventType } from '$types/matrix-sdk'; +import { prefetchRoomSettingsModal, prefetchSpaceSettingsModal } from '$pages/routePrefetch'; type HierarchyItemWithParent = HierarchyItem & { parentId: string; @@ -195,9 +196,23 @@ function SettingsMenuItem({ } requestClose(); }; + const handleSettingsPrefetch = () => { + if ('space' in item) { + void prefetchSpaceSettingsModal(); + return; + } + void prefetchRoomSettingsModal(); + }; return ( - + Settings @@ -237,6 +252,13 @@ export function HierarchyItemMenu({ const handleOpenMenu: MouseEventHandler = (evt) => { setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; + const handleSettingsPrefetch = () => { + if ('space' in item) { + void prefetchSpaceSettingsModal(); + return; + } + void prefetchRoomSettingsModal(); + }; const handleRequestClose = useCallback(() => setMenuAnchor(undefined), []); const navigate = useNavigate(); @@ -249,6 +271,8 @@ export function HierarchyItemMenu({ ( openSpaceSettings(space.roomId); requestClose(); }; + const handleSettingsPrefetch = () => { + void prefetchSpaceSettingsModal(); + }; return ( @@ -93,6 +97,8 @@ const LobbyMenu = forwardRef( } radii="300" @@ -157,6 +163,9 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { const handleOpenMenu: MouseEventHandler = (evt) => { setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; + const handleSettingsPrefetch = () => { + void prefetchSpaceSettingsModal(); + }; return ( @@ -243,6 +252,8 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { diff --git a/src/app/features/lobby/index.ts b/src/app/features/lobby/index.ts deleted file mode 100644 index 08355c79a..000000000 --- a/src/app/features/lobby/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Lobby'; diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index a372b975a..102ab94d6 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -61,6 +61,7 @@ import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; import { useRoomName, useRoomTopic } from '$hooks/useRoomMeta'; import { nicknamesAtom } from '$state/nicknames'; import { useRoomNavigate } from '$hooks/useRoomNavigate'; +import { prefetchRoomSettingsModal } from '$pages/routePrefetch'; // Call Hooks & Plugins import { useCallMembers, useCallSession } from '$hooks/useCall'; @@ -135,6 +136,9 @@ const RoomNavItemMenu = forwardRef( openRoomSettings(room.roomId, space?.roomId); requestClose(); }; + const handleSettingsPrefetch = () => { + void prefetchRoomSettingsModal(); + }; return ( @@ -209,6 +213,8 @@ const RoomNavItemMenu = forwardRef( } radii="300" @@ -320,6 +326,9 @@ export function RoomNavItem({ const handleOpenMenu: MouseEventHandler = (evt) => { setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; + const handleSettingsPrefetch = () => { + void prefetchRoomSettingsModal(); + }; const handleNavItemClick: MouseEventHandler = (evt) => { if (room.isCallRoom()) { @@ -547,6 +556,8 @@ export function RoomNavItem({ > = (evt) => { openProfile(room.roomId, space?.roomId, userId, evt.currentTarget.getBoundingClientRect()); }; + const handlePrefetch = () => { + void prefetchUserProfileModal(); + }; const ariaLabel = isCallParticipant ? `Call Participant: ${name}` : name; return ( - + diff --git a/src/app/features/room-settings/RoomSettingsRenderer.tsx b/src/app/features/room-settings/RoomSettingsRenderer.tsx index 7487255ec..0aa5d8a0e 100644 --- a/src/app/features/room-settings/RoomSettingsRenderer.tsx +++ b/src/app/features/room-settings/RoomSettingsRenderer.tsx @@ -1,10 +1,15 @@ +import { lazy, Suspense } from 'react'; import { Modal500 } from '$components/Modal500'; import { useCloseRoomSettings, useRoomSettingsState } from '$state/hooks/roomSettings'; import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom'; import type { RoomSettingsState } from '$state/roomSettings'; import { RoomProvider } from '$hooks/useRoom'; import { SpaceProvider } from '$hooks/useSpace'; -import { RoomSettings } from './RoomSettings'; + +const RoomSettings = lazy(async () => { + const mod = await import('./RoomSettings'); + return { default: mod.RoomSettings }; +}); type RenderSettingsProps = { state: RoomSettingsState; @@ -23,7 +28,9 @@ function RenderSettings({ state }: RenderSettingsProps) { - + + + diff --git a/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx index 913e6dbaf..9bf3195ad 100644 --- a/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx +++ b/src/app/features/room-settings/abbreviations/RoomAbbreviations.tsx @@ -29,7 +29,7 @@ import { useForceUpdate } from '$hooks/useForceUpdate'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import type { MatrixError } from '$types/matrix-sdk'; import type { AbbreviationEntry, RoomAbbreviationsContent } from '$utils/abbreviations'; -import { getAllParents, getStateEvent } from '$utils/room'; +import { getAllParents, getStateEvent, hasRecursiveParent } from '$utils/room'; import { roomToParentsAtom } from '$state/room/roomToParents'; import { SequenceCardStyle } from '$features/common-settings/styles.css'; import { CustomStateEvent } from '$types/matrix/room'; @@ -61,7 +61,7 @@ export function RoomAbbreviations({ requestClose, isSpace }: AbbreviationsProps) (event) => { if (event.getType() !== (CustomStateEvent.RoomAbbreviations as string)) return; const eventRoomId = event.getRoomId(); - if (eventRoomId && getAllParents(roomToParents, room.roomId).has(eventRoomId)) { + if (eventRoomId && hasRecursiveParent(roomToParents, room.roomId, eventRoomId)) { forceAncestorUpdate(); } }, diff --git a/src/app/features/room-settings/index.ts b/src/app/features/room-settings/index.ts deleted file mode 100644 index d94968453..000000000 --- a/src/app/features/room-settings/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './RoomSettings'; -export * from './RoomSettingsRenderer'; diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index f751bcf31..813e79990 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -55,6 +55,7 @@ import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '$hooks/useMembe import { useRoomCreators } from '$hooks/useRoomCreators'; import { useSableCosmetics } from '$hooks/useSableCosmetics'; import { formatCompactNumber } from '$utils/formatCompactNumber'; +import { prefetchUserProfileModal } from '$pages/routePrefetch'; import * as css from './MembersDrawer.css'; type MemberDrawerHeaderProps = { @@ -116,6 +117,10 @@ function MemberItem({ pressed, typing, }: MemberItemProps) { + const handlePrefetch = () => { + void prefetchUserProfileModal(); + }; + const nicknames = useAtomValue(nicknamesAtom); const name = getMemberDisplayName(room, member.userId, nicknames) ?? @@ -139,6 +144,8 @@ function MemberItem({ variant="Background" radii="400" onClick={onClick} + onMouseEnter={handlePrefetch} + onFocus={handlePrefetch} before={
{ @@ -61,7 +63,7 @@ export function Room() { }); }, [isWidgetDrawerOpen, room.roomId]); const powerLevels = usePowerLevels(room); - const members = useRoomMembers(mx, room.roomId); + const members = useRoomMembers(mx, room.roomId, showMembersDrawer); const chat = useAtomValue(callChatAtom); const [openThreadId, setOpenThread] = useAtom(roomIdToOpenThreadAtomFamily(room.roomId)); const [threadBrowserOpen, setThreadBrowserOpen] = useAtom( @@ -100,7 +102,6 @@ export function Room() { ) ); - const callView = room.isCallRoom(); const abbreviations = useMergedAbbreviations(room); // Log call view state @@ -137,7 +138,7 @@ export function Room() { )} - {!callView && screenSize === ScreenSize.Desktop && isDrawer && ( + {showMembersDrawer && ( <> diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d63faa989..317f3615a 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1,37 +1,11 @@ import type { ReactNode } from 'react'; -import { - Fragment, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Editor } from 'slate'; import { useAtomValue, useSetAtom } from 'jotai'; import type { Room } from '$types/matrix-sdk'; import { PushProcessor, Direction } from '$types/matrix-sdk'; -import classNames from 'classnames'; import type { VListHandle } from 'virtua'; -import { VList } from 'virtua'; -import type { ContainerColor } from 'folds'; -import { - as, - Box, - Chip, - Icon, - Icons, - Line, - Text, - Badge, - color, - config, - toRem, - Spinner, -} from 'folds'; -import { MessageBase, CompactPlaceholder, DefaultPlaceholder } from '$components/message'; -import { RoomIntro } from '$components/room-intro'; +import { Chip, Icon, Icons, Text } from 'folds'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useAlive } from '$hooks/useAlive'; import { useMessageEdit } from '$hooks/useMessageEdit'; @@ -44,7 +18,6 @@ import { renderMatrixMention, factoryRenderLinkifyWithMention, } from '$plugins/react-custom-html-parser'; -import { today, yesterday, timeDayMonthYear } from '$utils/time'; import { unwrapRelationJumpTarget } from '$utils/room'; import { useMemberEventParser } from '$hooks/useMemberEventParser'; import { usePowerLevelsContext } from '$hooks/usePowerLevels'; @@ -60,7 +33,7 @@ import { useSpaceOptionally } from '$hooks/useSpace'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useIgnoredUsers } from '$hooks/useIgnoredUsers'; import { useImagePackRooms } from '$hooks/useImagePackRooms'; -import { settingsAtom, MessageLayout } from '$state/settings'; +import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; import { nicknamesAtom } from '$state/nicknames'; import { useRoomAbbreviationsContext } from '$hooks/useRoomAbbreviations'; @@ -79,41 +52,16 @@ import { import { useTimelineSync } from '$hooks/timeline/useTimelineSync'; import { useTimelineActions } from '$hooks/timeline/useTimelineActions'; import { - useProcessedTimeline, getProcessedRowIndexForRawTimelineIndex, + useProcessedTimeline, type ProcessedEvent, } from '$hooks/timeline/useProcessedTimeline'; import { useTimelineEventRenderer } from '$hooks/timeline/useTimelineEventRenderer'; -import * as css from './RoomTimeline.css'; - -const TimelineFloat = as<'div', css.TimelineFloatVariants>( - ({ position, className, ...props }, ref) => ( - - ) -); - -const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>( - ({ variant, children, ...props }, ref) => ( - - - {children} - - - ) -); +import { TimelinePaginationStatusRow } from './TimelinePaginationStatus'; +import { TimelineFloat, TimelineViewport } from './TimelineViewport'; +import { useTimelineViewportController } from './useTimelineViewportController'; -const getDayDividerText = (ts: number) => { - if (today(ts)) return 'Today'; - if (yesterday(ts)) return 'Yesterday'; - return timeDayMonthYear(ts); -}; +const TIMELINE_BUFFER_SIZE_PX = 160; export type RoomTimelineProps = { room: Room; @@ -150,7 +98,6 @@ export function RoomTimeline({ const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); const [showTombstoneEvents] = useSetting(settingsAtom, 'showTombstoneEvents'); const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); - const [reducedMotion] = useSetting(settingsAtom, 'reducedMotion'); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); const [autoplayStickers] = useSetting(settingsAtom, 'autoplayStickers'); @@ -189,7 +136,6 @@ export function RoomTimeline({ const hideReadsRef = useRef(hideReads); hideReadsRef.current = hideReads; - const prevViewportHeightRef = useRef(0); const messageListRef = useRef(null); const mediaAuthentication = useMediaAuthentication(); @@ -220,37 +166,16 @@ export function RoomTimeline({ atBottomRef.current = val; }, []); - const [shift, setShift] = useState(false); - const [topSpacerHeight, setTopSpacerHeight] = useState(0); - - const topSpacerHeightRef = useRef(0); - const mountScrollWindowRef = useRef(Date.now() + 3000); - const hasInitialScrolledRef = useRef(false); - // Stored in a ref so eventsLength fluctuations (e.g. onLifecycle timeline reset - // firing within the window) cannot cancel it via useLayoutEffect cleanup. - const initialScrollTimerRef = useRef | undefined>(undefined); - // Set to true when the 80 ms timer fires but processedEvents is still empty - // (e.g. the onLifecycle reset cleared the timeline before events refilled it). - // A recovery useLayoutEffect watches for processedEvents becoming non-empty - // and performs the final scroll + setIsReady when this flag is set. - const pendingReadyRef = useRef(false); - const currentRoomIdRef = useRef(room.roomId); - - const [isReady, setIsReady] = useState(false); - - if (currentRoomIdRef.current !== room.roomId) { - hasInitialScrolledRef.current = false; - mountScrollWindowRef.current = Date.now() + 3000; - currentRoomIdRef.current = room.roomId; - pendingReadyRef.current = false; - if (initialScrollTimerRef.current !== undefined) { - clearTimeout(initialScrollTimerRef.current); - initialScrollTimerRef.current = undefined; - } - setIsReady(false); - } + useEffect(() => { + if (eventId) return; + setUnreadInfo((prev) => { + if (!prev?.scrollTo) return prev; + return { ...prev, scrollTo: false }; + }); + }, [room.roomId, eventId]); const processedEventsRef = useRef([]); + const processedIndexByRawIndexRef = useRef>(new Map()); const timelineSyncRef = useRef(null as unknown as typeof timelineSync); const scrollToBottom = useCallback(() => { @@ -275,212 +200,66 @@ export function RoomTimeline({ timelineSyncRef.current = timelineSync; - const eventsLengthRef = useRef(timelineSync.eventsLength); - eventsLengthRef.current = timelineSync.eventsLength; - - const canPaginateBackRef = useRef(timelineSync.canPaginateBack); - canPaginateBackRef.current = timelineSync.canPaginateBack; - - const liveTimelineLinkedRef = useRef(timelineSync.liveTimelineLinked); - liveTimelineLinkedRef.current = timelineSync.liveTimelineLinked; - - const backwardStatusRef = useRef(timelineSync.backwardStatus); - backwardStatusRef.current = timelineSync.backwardStatus; - - const forwardStatusRef = useRef(timelineSync.forwardStatus); - forwardStatusRef.current = timelineSync.forwardStatus; - const getRawIndexToProcessedIndex = useCallback((rawIndex: number): number | undefined => { - const events = processedEventsRef.current; - const match = events.find((e) => e.itemIndex === rawIndex); - if (!match) return undefined; - return events.indexOf(match); + return processedIndexByRawIndexRef.current.get(rawIndex); }, []); - useLayoutEffect(() => { - if ( - !eventId && - !hasInitialScrolledRef.current && - timelineSync.eventsLength > 0 && - // Guard: only scroll once the timeline reflects the current room's live - // timeline. Without this, a render with stale data from the previous room - // (before the room-change reset propagates) fires the scroll at the wrong - // position and marks hasInitialScrolledRef = true, preventing the correct - // scroll when the right data arrives. - timelineSync.liveTimelineLinked && - vListRef.current - ) { - vListRef.current.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); - // Store in a ref rather than a local so subsequent eventsLength changes - // (e.g. the onLifecycle timeline reset firing within 80 ms) do NOT - // cancel this timer through the useLayoutEffect cleanup. - initialScrollTimerRef.current = setTimeout(() => { - initialScrollTimerRef.current = undefined; - if (processedEventsRef.current.length > 0) { - vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); - // Only mark ready once we've successfully scrolled. If processedEvents - // was empty when the timer fired (e.g. the onLifecycle reset cleared the - // timeline within the 80 ms window), defer setIsReady until the recovery - // effect below fires once events repopulate. - setIsReady(true); - } else { - pendingReadyRef.current = true; - } - }, 80); - hasInitialScrolledRef.current = true; - } - // No cleanup return — the timer must survive eventsLength fluctuations. - // It is cancelled on unmount by the dedicated effect below. - }, [timelineSync.eventsLength, timelineSync.liveTimelineLinked, eventId, room.roomId]); - - // Cancel the initial-scroll timer on unmount (the useLayoutEffect above - // intentionally does not cancel it when deps change). - useEffect( - () => () => { - if (initialScrollTimerRef.current !== undefined) clearTimeout(initialScrollTimerRef.current); - }, - [] - ); - - // If the timeline was blanked while content was already visible — e.g. a - // TimelineReset fired by mx.retryImmediately() when the app comes back from - // background — hide the timeline (opacity 0) and re-arm the initial-scroll so - // it runs again once events refill the live timeline. - useLayoutEffect(() => { - if (!isReady) return; - if (timelineSync.eventsLength > 0) return; - setIsReady(false); - hasInitialScrolledRef.current = false; - }, [isReady, timelineSync.eventsLength]); - - const recalcTopSpacer = useCallback(() => { - const v = vListRef.current; - if (!v) return; - const prev = topSpacerHeightRef.current; - - const newH = Math.max(0, v.viewportSize - v.scrollSize + prev); - if (Math.abs(prev - newH) > 2) { - topSpacerHeightRef.current = newH; - setTopSpacerHeight(newH); - if (prev > 0 && newH === 0 && processedEventsRef.current.length > 0) { - requestAnimationFrame(() => { - vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); - }); - } - } - }, []); - - useLayoutEffect(() => { - const id = requestAnimationFrame(recalcTopSpacer); - return () => cancelAnimationFrame(id); - }, [recalcTopSpacer, timelineSync.eventsLength]); - - const prevBackwardStatusRef = useRef(timelineSync.backwardStatus); - const wasAtBottomBeforePaginationRef = useRef(false); - - useLayoutEffect(() => { - const prev = prevBackwardStatusRef.current; - prevBackwardStatusRef.current = timelineSync.backwardStatus; - if (timelineSync.backwardStatus === 'loading') { - wasAtBottomBeforePaginationRef.current = atBottomRef.current; - if (!atBottomRef.current) setShift(true); - } else if (prev === 'loading' && timelineSync.backwardStatus === 'idle') { - setShift(false); - if (wasAtBottomBeforePaginationRef.current) { - vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); - } - } - }, [timelineSync.backwardStatus]); - - useEffect(() => { - let timeoutId: ReturnType | undefined; - if (timelineSync.focusItem) { - if (timelineSync.focusItem.scrollTo && vListRef.current) { - const processedIndex = getRawIndexToProcessedIndex(timelineSync.focusItem.index); - if (processedIndex !== undefined) { - vListRef.current.scrollToIndex(processedIndex, { align: 'center' }); - timelineSync.setFocusItem((prev) => (prev ? { ...prev, scrollTo: false } : undefined)); - } - } - timeoutId = setTimeout(() => { - timelineSync.setFocusItem(undefined); - }, 2000); - } - return () => { - if (timeoutId !== undefined) clearTimeout(timeoutId); - }; - }, [timelineSync.focusItem, timelineSync, reducedMotion, getRawIndexToProcessedIndex]); - - useEffect(() => { - if (timelineSync.focusItem) { - setIsReady(true); - } - }, [timelineSync.focusItem]); + const vListItemCount = timelineSync.eventsLength; + const vListIndices = useMemo(() => { + // Keep the cache-busting timeline identity explicit for exhaustive-deps. + void timelineSync.timeline; + return Array.from({ length: vListItemCount }, (_, i) => i); + }, [vListItemCount, timelineSync.timeline]); - useEffect(() => { - if (!eventId) return; - setIsReady(false); - timelineSyncRef.current.loadEventTimeline(eventId); - }, [eventId, room.roomId]); + const processedEvents = useProcessedTimeline({ + items: vListIndices, + linkedTimelines: timelineSync.timeline.linkedTimelines, + ignoredUsersSet, + showHiddenEvents, + showTombstoneEvents, + mxUserId: mx.getUserId(), + readUptoEventId: readUptoEventIdRef.current, + hideMembershipEvents, + hideNickAvatarEvents, + isReadOnly, + hideMemberInReadOnly, + }); - useEffect(() => { - if (eventId) return; - // Guard: once the timeline is visible to the user, do not override their - // scroll position. Without this, a later timeline refresh (e.g. the - // onLifecycle reset delivering a new linkedTimelines reference) can fire - // this effect after isReady and snap the view back to the read marker. - if (isReady) return; - const { readUptoEventId, inLiveTimeline, scrollTo } = unreadInfo ?? {}; - if (readUptoEventId && inLiveTimeline && scrollTo) { - const evtTimeline = getEventTimeline(room, readUptoEventId); - const absoluteIndex = evtTimeline - ? getEventIdAbsoluteIndex( - timelineSync.timeline.linkedTimelines, - evtTimeline, - readUptoEventId - ) - : undefined; + processedEventsRef.current = processedEvents; + processedIndexByRawIndexRef.current = new Map( + processedEvents.map((event, index) => [event.itemIndex, index]) + ); - if (absoluteIndex !== undefined) { - const processedIndex = getRawIndexToProcessedIndex(absoluteIndex); - if (processedIndex !== undefined && vListRef.current) { - vListRef.current.scrollToIndex(processedIndex, { align: 'start' }); - } - // Always consume the scroll intent once the event is located in the - // linked timelines, even if its processedIndex is undefined (filtered - // event). Without this, each linkedTimelines reference change retries - // the scroll indefinitely. - setUnreadInfo((prev) => (prev ? { ...prev, scrollTo: false } : prev)); - } - } - }, [ - room, - unreadInfo, - timelineSync.timeline.linkedTimelines, - eventId, + const { + shift, + topSpacerHeight, isReady, + beginJumpLoad, + settleTimelineAnchor, + handleVListScroll, + markUserScrollIntent, + } = useTimelineViewportController({ + roomId: room.roomId, + eventId, + timelineSync, + timelineSyncRef, + vListRef, + messageListRef, + processedEventsRef, + atBottomRef, + setAtBottom, getRawIndexToProcessedIndex, - ]); - - useEffect(() => { - const el = messageListRef.current; - if (!el) return () => {}; - - const observer = new ResizeObserver((entries) => { - const newHeight = entries[0]!.contentRect.height; - const prev = prevViewportHeightRef.current; - const atBottom = atBottomRef.current; - const shrank = newHeight < prev; + }); - if (shrank && atBottom) { - vListRef.current?.scrollTo(vListRef.current.scrollSize); - } - prevViewportHeightRef.current = newHeight; - }); + const showLoadingPlaceholders = !isReady && timelineSync.eventsLength === 0; + const hideInlineBackPagination = + topSpacerHeight > 0 && atBottomState && timelineSync.backwardStatus === 'loading'; - observer.observe(el); - return () => observer.disconnect(); - }, []); + const vListData = useMemo>(() => { + if (showLoadingPlaceholders) return []; + if (isReady && processedEvents.length === 0) return [undefined]; + return processedEvents; + }, [isReady, processedEvents, showLoadingPlaceholders]); const actions = useTimelineActions({ room, @@ -533,10 +312,14 @@ export function RoomTimeline({ } if (vListRef.current && processedIndex !== undefined) { vListRef.current.scrollToIndex(processedIndex, { align: 'center' }); + const focusEventId = processedEventsRef.current[processedIndex]?.id; + if (focusEventId) { + settleTimelineAnchor({ kind: 'message-center', eventId: focusEventId }); + } } timelineSync.setFocusItem({ index: focusRawIndex, scrollTo: false, highlight: true }); } else { - timelineSync.loadEventTimeline(anchorId); + beginJumpLoad(anchorId); } }, }); @@ -668,138 +451,33 @@ export function RoomTimeline({ timelineSync.eventsLength, ]); - const handleVListScroll = useCallback( - (offset: number) => { - const v = vListRef.current; - if (!v) return; - - const distanceFromBottom = v.scrollSize - offset - v.viewportSize; - const isNowAtBottom = distanceFromBottom < 100; - if (isNowAtBottom !== atBottomRef.current) { - setAtBottom(isNowAtBottom); - } - - if (offset < 500 && canPaginateBackRef.current && backwardStatusRef.current === 'idle') { - timelineSyncRef.current.handleTimelinePagination(true); - } - if ( - distanceFromBottom < 500 && - !liveTimelineLinkedRef.current && - forwardStatusRef.current === 'idle' - ) { - timelineSyncRef.current.handleTimelinePagination(false); - } - }, - [setAtBottom] - ); - - const showLoadingPlaceholders = - timelineSync.eventsLength === 0 && - (!isReady || timelineSync.canPaginateBack || timelineSync.backwardStatus === 'loading'); - let backPaginationJSX: ReactNode | undefined; if (timelineSync.canPaginateBack || timelineSync.backwardStatus !== 'idle') { - if (timelineSync.backwardStatus === 'error') { - backPaginationJSX = ( - - - Failed to load history. - - timelineSync.handleTimelinePagination(true)} - > - Retry - - - ); - } else if (timelineSync.backwardStatus === 'loading' && timelineSync.eventsLength > 0) { - backPaginationJSX = ( - - - - ); - } + backPaginationJSX = ( + timelineSync.handleTimelinePagination(true)} + hidden={hideInlineBackPagination} + /> + ); } let frontPaginationJSX: ReactNode | undefined; if (!timelineSync.liveTimelineLinked || timelineSync.forwardStatus !== 'idle') { - if (timelineSync.forwardStatus === 'error') { - frontPaginationJSX = ( - - - Failed to load messages. - - timelineSync.handleTimelinePagination(false)} - > - Retry - - - ); - } else if (timelineSync.forwardStatus === 'loading' && timelineSync.eventsLength > 0) { - frontPaginationJSX = ( - - - - ); - } + frontPaginationJSX = ( + timelineSync.handleTimelinePagination(false)} + /> + ); } - const vListItemCount = - timelineSync.eventsLength === 0 && - (!isReady || timelineSync.canPaginateBack || timelineSync.backwardStatus === 'loading') - ? 3 - : timelineSync.eventsLength; - const vListIndices = useMemo(() => { - // Keep the cache-busting timeline identity explicit for exhaustive-deps. - void timelineSync.timeline; - return Array.from({ length: vListItemCount }, (_, i) => i); - }, [vListItemCount, timelineSync.timeline]); - - const processedEvents = useProcessedTimeline({ - items: vListIndices, - linkedTimelines: timelineSync.timeline.linkedTimelines, - ignoredUsersSet, - showHiddenEvents, - showTombstoneEvents, - mxUserId: mx.getUserId(), - readUptoEventId: readUptoEventIdRef.current, - hideMembershipEvents, - hideNickAvatarEvents, - isReadOnly, - hideMemberInReadOnly, - }); - - processedEventsRef.current = processedEvents; - - // Recovery: if the 80 ms initial-scroll timer fired while processedEvents was - // empty (timeline was mid-reset), scroll to bottom and reveal the timeline once - // events repopulate. Fires on every processedEvents.length change but is - // guarded by pendingReadyRef so it only acts once per initial-scroll attempt. - useLayoutEffect(() => { - if (!pendingReadyRef.current) return; - if (processedEvents.length === 0) return; - pendingReadyRef.current = false; - vListRef.current?.scrollToIndex(processedEvents.length - 1, { align: 'end' }); - setIsReady(true); - }, [processedEvents.length]); - useEffect(() => { if (!onEditLastMessageRef) return; const ref = onEditLastMessageRef; @@ -817,215 +495,55 @@ export function RoomTimeline({ }; }, [onEditLastMessageRef, mx, actions]); - useEffect(() => { - const v = vListRef.current; - if (!v) return; - if ( - canPaginateBackRef.current && - backwardStatusRef.current === 'idle' && - v.scrollSize <= v.viewportSize - ) { - timelineSyncRef.current.handleTimelinePagination(true); - } - }, [timelineSync.eventsLength, timelineSync.backwardStatus]); - - useEffect(() => { - if (!canPaginateBackRef.current) return () => {}; - - let rafId: number; - let attempts = 0; - const MAX_ATTEMPTS = 20; - const processedLengthAtEffectStart = processedEvents.length; - - const check = () => { - const v = vListRef.current; - if (!v) return; - - if (v.viewportSize === 0) { - attempts += 1; - if (attempts <= MAX_ATTEMPTS) rafId = requestAnimationFrame(check); - return; - } - - if (!canPaginateBackRef.current) return; - if (backwardStatusRef.current !== 'idle') return; - - const atTop = v.scrollOffset < 500; - const noVisibleGrowth = processedEvents.length === processedLengthAtEffectStart; - const hasRealScrollRoom = v.scrollSize > v.viewportSize + 300; - - if (!hasRealScrollRoom || (atTop && noVisibleGrowth)) { - timelineSyncRef.current.handleTimelinePagination(true); - } - }; - - rafId = requestAnimationFrame(check); - return () => cancelAnimationFrame(rafId); - }, [timelineSync.eventsLength, timelineSync.backwardStatus, processedEvents.length]); - - return ( - - {unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && isReady && ( - - } - onClick={() => timelineSync.loadEventTimeline(unreadInfo.readUptoEventId)} - > - Jump to Unread - - } - onClick={() => markAsRead(mx, room.roomId, hideReads)} - > - Mark as Read - - - )} - -
- - ref={vListRef} - data={processedEvents} - shift={shift} - className={css.messageList} - style={{ - flex: 1, - minHeight: 0, - display: 'flex', - flexDirection: 'column', - paddingTop: topSpacerHeight > 0 ? topSpacerHeight : config.space.S600, - paddingBottom: config.space.S600, - }} - onScroll={handleVListScroll} + const unreadBanner = + unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && isReady ? ( + + } + onClick={() => beginJumpLoad(unreadInfo.readUptoEventId)} > - {(eventData, index) => { - if (showLoadingPlaceholders) { - return ( - - {messageLayout === MessageLayout.Compact ? ( - - ) : ( - - )} - - ); - } - - if (!eventData) { - if (index === 0 && !timelineSync.canPaginateBack) { - return ( - - {backPaginationJSX} -
- -
-
- ); - } - if (index === 0) return {backPaginationJSX}; - return ; - } - - const renderedEvent = renderMatrixEvent( - eventData.mEvent.getType(), - typeof eventData.mEvent.getStateKey() === 'string', - eventData.id, - eventData.mEvent, - eventData.itemIndex, - eventData.timelineSet, - eventData.collapsed - ); - - const dividers = ( - <> - {eventData.willRenderDayDivider && ( - - - - {getDayDividerText(eventData.mEvent.getTs())} - - - - )} - {eventData.willRenderNewDivider && ( - - - - New Messages - - - - )} - - ); - - if (index === 0) { - return ( - - {!timelineSync.canPaginateBack && ( -
- -
- )} - {backPaginationJSX} - {dividers} - {renderedEvent} -
- ); - } - - return ( - - {dividers} - {renderedEvent} - - ); - }} - -
- - {frontPaginationJSX} + Jump to Unread + + } + onClick={() => markAsRead(mx, room.roomId, hideReads)} + > + Mark as Read + + + ) : undefined; - {!atBottomState && isReady && ( - - } - onClick={() => { - if (eventId) navigateRoom(room.roomId, undefined, { replace: true }); - timelineSync.setTimeline(getInitialTimeline(room)); - scrollToBottom(); - }} - > - Jump to Latest - - - )} -
+ return ( + { + if (eventId) navigateRoom(room.roomId, undefined, { replace: true }); + timelineSync.setTimeline(getInitialTimeline(room)); + settleTimelineAnchor({ kind: 'bottom' }); + }} + renderMatrixEvent={renderMatrixEvent} + /> ); } diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index f45e6172f..fb2cc7d98 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -91,6 +91,7 @@ import { callChatAtom } from '$state/callEmbed'; import { RoomSettingsPage } from '$state/roomSettings'; import { roomIdToThreadBrowserAtomFamily } from '$state/room/roomToThreadBrowser'; import { roomIdToOpenThreadAtomFamily } from '$state/room/roomToOpenThread'; +import { prefetchRoomSettingsModal } from '$pages/routePrefetch'; import { JumpToTime } from './jump-to-time'; import { RoomPinMenu } from './room-pin-menu'; import * as css from './RoomViewHeader.css'; @@ -186,6 +187,9 @@ const RoomMenu = forwardRef(({ room, requestClose openSettings(room.roomId, parentSpace?.roomId); requestClose(); }; + const handleSettingsPrefetch = () => { + void prefetchRoomSettingsModal(); + }; return ( @@ -274,6 +278,8 @@ const RoomMenu = forwardRef(({ room, requestClose } radii="300" @@ -563,6 +569,9 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { const handleOpenMenu: MouseEventHandler = (evt) => { setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; + const handleRoomSettingsPrefetch = () => { + void prefetchRoomSettingsModal(); + }; const handleOpenPinMenu: MouseEventHandler = (evt) => { setPinMenuAnchor(evt.currentTarget.getBoundingClientRect()); @@ -898,6 +907,8 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { diff --git a/src/app/features/room/TimelineEventRow.test.tsx b/src/app/features/room/TimelineEventRow.test.tsx new file mode 100644 index 000000000..9371e52a1 --- /dev/null +++ b/src/app/features/room/TimelineEventRow.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import type { Room } from '$types/matrix-sdk'; +import type { ProcessedEvent } from '$hooks/timeline/useProcessedTimeline'; +import { MessageLayout } from '$state/settings'; +import { TimelineEventRow } from './TimelineEventRow'; + +const createProcessedEvent = (): ProcessedEvent => + ({ + id: '$event', + itemIndex: 0, + mEvent: { + getType: () => 'm.room.message', + getStateKey: () => undefined, + getTs: () => Date.now(), + }, + timelineSet: {}, + eventSender: null, + collapsed: false, + willRenderNewDivider: false, + willRenderDayDivider: false, + }) as unknown as ProcessedEvent; + +describe('TimelineEventRow', () => { + it('keeps backward pagination inside the first virtual row before the first event', () => { + const { container } = render( + Loading older messages
} + renderMatrixEvent={() =>
First message
} + /> + ); + + expect(screen.getByText('Loading older messages')).toBeInTheDocument(); + expect(screen.getByText('First message')).toBeInTheDocument(); + expect(container.textContent).toBe('Loading older messagesFirst message'); + }); +}); diff --git a/src/app/features/room/TimelineEventRow.tsx b/src/app/features/room/TimelineEventRow.tsx new file mode 100644 index 000000000..08cdc5ed8 --- /dev/null +++ b/src/app/features/room/TimelineEventRow.tsx @@ -0,0 +1,130 @@ +import type { ReactNode } from 'react'; +import { Fragment } from 'react'; +import type { Room } from '$types/matrix-sdk'; +import type { ContainerColor } from 'folds'; +import { as, Badge, Box, color, Line, Text, toRem } from 'folds'; +import { MessageBase } from '$components/message'; +import { RoomIntro } from '$components/room-intro'; +import { today, timeDayMonthYear, yesterday } from '$utils/time'; +import { MessageLayout, type MessageSpacing } from '$state/settings'; +import type { ProcessedEvent } from '$hooks/timeline/useProcessedTimeline'; + +const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>( + ({ variant, children, ...props }, ref) => ( + + + {children} + + + ) +); + +const getDayDividerText = (ts: number) => { + if (today(ts)) return 'Today'; + if (yesterday(ts)) return 'Yesterday'; + return timeDayMonthYear(ts); +}; + +export type TimelineEventRowProps = { + eventData: ProcessedEvent | undefined; + index: number; + room: Room; + messageLayout: MessageLayout; + messageSpacing: MessageSpacing; + canPaginateBack: boolean; + backPagination?: ReactNode; + renderMatrixEvent: ( + eventType: string, + isStateEvent: boolean, + eventId: string, + event: ProcessedEvent['mEvent'], + itemIndex: number, + timelineSet: ProcessedEvent['timelineSet'], + collapsed: boolean + ) => ReactNode; +}; + +export function TimelineEventRow({ + eventData, + index, + room, + messageLayout, + messageSpacing, + canPaginateBack, + backPagination, + renderMatrixEvent, +}: Readonly) { + const introPadding = `${toRem(28)} ${toRem(16)} ${toRem(24)} ${ + messageLayout === MessageLayout.Compact ? toRem(16) : toRem(64) + }`; + + if (!eventData) { + if (index === 0 && !canPaginateBack) { + return ( + + {backPagination} +
+ +
+
+ ); + } + if (index === 0) return {backPagination}; + return ; + } + + const renderedEvent = renderMatrixEvent( + eventData.mEvent.getType(), + typeof eventData.mEvent.getStateKey() === 'string', + eventData.id, + eventData.mEvent, + eventData.itemIndex, + eventData.timelineSet, + eventData.collapsed + ); + + const dividers = ( + <> + {eventData.willRenderDayDivider && ( + + + + {getDayDividerText(eventData.mEvent.getTs())} + + + + )} + {eventData.willRenderNewDivider && ( + + + + New Messages + + + + )} + + ); + + if (index === 0) { + return ( + + {!canPaginateBack && ( +
+ +
+ )} + {backPagination} + {dividers} + {renderedEvent} +
+ ); + } + + return ( + + {dividers} + {renderedEvent} + + ); +} diff --git a/src/app/features/room/TimelineLoadingPlaceholders.tsx b/src/app/features/room/TimelineLoadingPlaceholders.tsx new file mode 100644 index 000000000..6f5aab17d --- /dev/null +++ b/src/app/features/room/TimelineLoadingPlaceholders.tsx @@ -0,0 +1,42 @@ +import { Box, config } from 'folds'; +import { CompactPlaceholder, DefaultPlaceholder, MessageBase } from '$components/message'; +import { MessageLayout, type MessageSpacing } from '$state/settings'; + +export type TimelineLoadingPlaceholdersProps = { + count?: number; + messageLayout: MessageLayout; + messageSpacing: MessageSpacing; +}; + +export function TimelineLoadingPlaceholders({ + count = 6, + messageLayout, + messageSpacing, +}: Readonly) { + return ( + + {Array.from({ length: count }, (_, index) => ( + + {messageLayout === MessageLayout.Compact ? ( + + ) : ( + + )} + + ))} + + ); +} diff --git a/src/app/features/room/TimelinePaginationStatus.test.tsx b/src/app/features/room/TimelinePaginationStatus.test.tsx new file mode 100644 index 000000000..3ff5fc488 --- /dev/null +++ b/src/app/features/room/TimelinePaginationStatus.test.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { TimelinePaginationStatusRow } from './TimelinePaginationStatus'; + +describe('TimelinePaginationStatusRow', () => { + it('does not render when hidden', () => { + const { container } = render( + void>()} + hidden + /> + ); + + expect(container.firstChild).toBeNull(); + }); + + it('renders retry UI on error', () => { + const onRetry = vi.fn<() => void>(); + render( + + ); + + expect(screen.getByText('Failed to load history.')).toBeInTheDocument(); + screen.getByText('Retry').click(); + expect(onRetry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/features/room/TimelinePaginationStatus.tsx b/src/app/features/room/TimelinePaginationStatus.tsx new file mode 100644 index 000000000..744931cbc --- /dev/null +++ b/src/app/features/room/TimelinePaginationStatus.tsx @@ -0,0 +1,52 @@ +import { Box, Chip, Spinner, Text, color, config } from 'folds'; + +export type TimelinePaginationStatus = 'idle' | 'loading' | 'error'; + +export type TimelinePaginationStatusRowProps = { + direction: 'backward' | 'forward'; + eventsLength: number; + hasMore: boolean; + status: TimelinePaginationStatus; + onRetry: () => void; + hidden?: boolean; +}; + +export function TimelinePaginationStatusRow({ + direction, + eventsLength, + hasMore, + status, + onRetry, + hidden, +}: Readonly) { + if (hidden) return null; + if (!hasMore && status === 'idle') return null; + + if (status === 'error') { + return ( + + + {direction === 'backward' ? 'Failed to load history.' : 'Failed to load messages.'} + + + Retry + + + ); + } + + if (status === 'loading' && eventsLength > 0) { + return ( + + + + ); + } + + return null; +} diff --git a/src/app/features/room/TimelineViewport.tsx b/src/app/features/room/TimelineViewport.tsx new file mode 100644 index 000000000..5d130c529 --- /dev/null +++ b/src/app/features/room/TimelineViewport.tsx @@ -0,0 +1,196 @@ +import { useRef, type KeyboardEvent, type ReactNode, type RefObject, type TouchEvent } from 'react'; +import type { Room } from '$types/matrix-sdk'; +import type { VListHandle } from 'virtua'; +import { VList } from 'virtua'; +import classNames from 'classnames'; +import { as, Box, Chip, Icon, Icons, Text, config } from 'folds'; +import type { ProcessedEvent } from '$hooks/timeline/useProcessedTimeline'; +import type { MessageLayout, MessageSpacing } from '$state/settings'; +import type { TimelineScrollDirection } from './timelineViewportModel'; +import * as css from './RoomTimeline.css'; +import { TimelineEventRow } from './TimelineEventRow'; +import { TimelineLoadingPlaceholders } from './TimelineLoadingPlaceholders'; + +const TimelineFloat = as<'div', css.TimelineFloatVariants>( + ({ position, className, ...props }, ref) => ( + + ) +); + +const getDirectionFromDelta = (deltaY: number): TimelineScrollDirection | undefined => { + if (deltaY > 0) return 'forward'; + if (deltaY < 0) return 'backward'; + return undefined; +}; + +export type TimelineViewportProps = { + room: Room; + isReady: boolean; + atBottom: boolean; + unreadBanner?: ReactNode; + messageListRef: RefObject; + vListRef: RefObject; + data: Array; + bufferSize: number; + shift: boolean; + topSpacerHeight: number; + messageLayout: MessageLayout; + messageSpacing: MessageSpacing; + canPaginateBack: boolean; + backPagination: ReactNode; + frontPagination: ReactNode; + onScroll: (offset: number) => void; + onUserScrollIntent: (direction?: TimelineScrollDirection) => void; + onJumpLatest: () => void; + renderMatrixEvent: ( + eventType: string, + isStateEvent: boolean, + eventId: string, + event: ProcessedEvent['mEvent'], + itemIndex: number, + timelineSet: ProcessedEvent['timelineSet'], + collapsed: boolean + ) => ReactNode; +}; + +export function TimelineViewport({ + room, + isReady, + atBottom, + unreadBanner, + messageListRef, + vListRef, + data, + bufferSize, + shift, + topSpacerHeight, + messageLayout, + messageSpacing, + canPaginateBack, + backPagination, + frontPagination, + onScroll, + onUserScrollIntent, + onJumpLatest, + renderMatrixEvent, +}: Readonly) { + const lastTouchYRef = useRef(undefined); + + const handleTouchStart = (event: TouchEvent) => { + lastTouchYRef.current = event.touches[0]?.clientY; + }; + + const handleTouchMoveIntent = (event: TouchEvent) => { + const nextY = event.touches[0]?.clientY; + const prevY = lastTouchYRef.current; + lastTouchYRef.current = nextY; + if (nextY === undefined || prevY === undefined) { + onUserScrollIntent(); + return; + } + const delta = prevY - nextY; + onUserScrollIntent(getDirectionFromDelta(delta)); + }; + + const handleKeyboardScrollIntent = (event: KeyboardEvent) => { + if (event.key === 'ArrowUp' || event.key === 'PageUp' || event.key === 'Home') { + onUserScrollIntent('backward'); + return; + } + if ( + event.key === 'ArrowDown' || + event.key === 'PageDown' || + event.key === 'End' || + event.key === ' ' || + event.key === 'Spacebar' + ) { + onUserScrollIntent('forward'); + } + }; + + return ( + + {unreadBanner} + +
onUserScrollIntent(getDirectionFromDelta(event.deltaY))} + onTouchStartCapture={handleTouchStart} + onTouchMoveCapture={handleTouchMoveIntent} + onKeyDownCapture={handleKeyboardScrollIntent} + style={{ + flex: 1, + minHeight: 0, + overflow: 'hidden', + position: 'relative', + opacity: isReady ? 1 : 0, + }} + > + + ref={vListRef} + data={data} + bufferSize={bufferSize} + shift={shift} + className={css.messageList} + style={{ + flex: 1, + minHeight: 0, + display: 'flex', + flexDirection: 'column', + paddingTop: + topSpacerHeight > 0 + ? `calc(${config.space.S600} + ${topSpacerHeight}px)` + : config.space.S600, + paddingBottom: config.space.S600, + }} + onScroll={onScroll} + > + {(eventData, index) => ( + + )} + +
+ + {!isReady && ( + + )} + + {frontPagination} + + {!atBottom && isReady && ( + + } + onClick={onJumpLatest} + > + Jump to Latest + + + )} +
+ ); +} + +export { TimelineFloat }; diff --git a/src/app/features/room/timelineViewportModel.test.ts b/src/app/features/room/timelineViewportModel.test.ts new file mode 100644 index 000000000..68a0147f2 --- /dev/null +++ b/src/app/features/room/timelineViewportModel.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { + getBottomClampSpacer, + getCenterAnchorAdjustment, + getTimelineVisibleRange, + type TimelineViewportGeometry, +} from './timelineViewportModel'; + +const createGeometry = ( + offset: number, + viewportSize: number, + itemSize: number, + itemCount: number +): TimelineViewportGeometry => ({ + scrollOffset: offset, + scrollSize: itemSize * itemCount, + viewportSize, + findItemIndex: (itemOffset) => + Math.max(0, Math.min(itemCount - 1, Math.floor(itemOffset / itemSize))), + getItemOffset: (index) => index * itemSize, +}); + +describe('timelineViewportModel', () => { + it('derives visible virtual-list edges from item geometry', () => { + expect(getTimelineVisibleRange(createGeometry(0, 200, 100, 5), 5)).toEqual({ + firstIndex: 0, + lastIndex: 1, + atStart: true, + atEnd: false, + atScrollEnd: false, + }); + + expect(getTimelineVisibleRange(createGeometry(300, 200, 100, 5), 5)).toEqual({ + firstIndex: 3, + lastIndex: 4, + atStart: false, + atEnd: true, + atScrollEnd: true, + }); + }); + + it('treats an empty list as both edges without inventing scroll distance', () => { + expect(getTimelineVisibleRange(createGeometry(0, 800, 100, 0), 0)).toEqual({ + firstIndex: -1, + lastIndex: -1, + atStart: true, + atEnd: true, + atScrollEnd: true, + }); + }); + + it('bottom-clamps short content while excluding the existing spacer', () => { + expect(getBottomClampSpacer(800, 400, 0)).toBe(400); + expect(getBottomClampSpacer(800, 700, 300)).toBe(400); + expect(getBottomClampSpacer(800, 1000, 0)).toBe(0); + }); + + it('returns the scroll delta needed to keep a target centered', () => { + expect(getCenterAnchorAdjustment({ top: 450, height: 100 }, { top: 100, height: 800 })).toBe(0); + expect(getCenterAnchorAdjustment({ top: 550, height: 100 }, { top: 100, height: 800 })).toBe( + 100 + ); + expect(getCenterAnchorAdjustment({ top: 350, height: 100 }, { top: 100, height: 800 })).toBe( + -100 + ); + }); +}); diff --git a/src/app/features/room/timelineViewportModel.ts b/src/app/features/room/timelineViewportModel.ts new file mode 100644 index 000000000..424ea76e5 --- /dev/null +++ b/src/app/features/room/timelineViewportModel.ts @@ -0,0 +1,79 @@ +export type TimelineAnchor = + | { kind: 'none' } + | { kind: 'bottom' } + | { kind: 'message-center'; eventId: string }; + +export type TimelineScrollDirection = 'backward' | 'forward'; + +export type RectLike = Pick; + +export type TimelineViewportGeometry = { + scrollOffset: number; + scrollSize: number; + viewportSize: number; + findItemIndex: (offset: number) => number; + getItemOffset: (index: number) => number; +}; + +export type TimelineVisibleRange = { + firstIndex: number; + lastIndex: number; + atStart: boolean; + atEnd: boolean; + atScrollEnd: boolean; +}; + +const clamp = (value: number, min: number, max: number): number => + Math.max(min, Math.min(max, value)); + +export const getTimelineVisibleRange = ( + viewport: TimelineViewportGeometry, + itemCount: number, + offset = viewport.scrollOffset +): TimelineVisibleRange => { + if (itemCount <= 0) { + return { + firstIndex: -1, + lastIndex: -1, + atStart: true, + atEnd: true, + atScrollEnd: true, + }; + } + + const maxIndex = itemCount - 1; + const scrollEnd = offset + viewport.viewportSize; + const maxScrollOffset = Math.max(0, viewport.scrollSize - viewport.viewportSize); + const firstIndex = clamp(viewport.findItemIndex(offset), 0, maxIndex); + let lastIndex = clamp( + viewport.findItemIndex(Math.min(scrollEnd, viewport.scrollSize)), + firstIndex, + maxIndex + ); + while (lastIndex > firstIndex && viewport.getItemOffset(lastIndex) >= scrollEnd) { + lastIndex -= 1; + } + + return { + firstIndex, + lastIndex, + atStart: firstIndex === 0, + atEnd: lastIndex === maxIndex, + atScrollEnd: offset >= maxScrollOffset, + }; +}; + +export const getBottomClampSpacer = ( + viewportSize: number, + scrollSize: number, + currentSpacer: number +): number => { + const contentHeight = Math.max(0, scrollSize - currentSpacer); + return Math.max(0, viewportSize - contentHeight); +}; + +export const getCenterAnchorAdjustment = (targetRect: RectLike, viewportRect: RectLike): number => { + const targetCenter = targetRect.top + targetRect.height / 2; + const viewportCenter = viewportRect.top + viewportRect.height / 2; + return targetCenter - viewportCenter; +}; diff --git a/src/app/features/room/useTimelineViewportController.test.tsx b/src/app/features/room/useTimelineViewportController.test.tsx new file mode 100644 index 000000000..2149ff96c --- /dev/null +++ b/src/app/features/room/useTimelineViewportController.test.tsx @@ -0,0 +1,496 @@ +import type { MutableRefObject, RefObject } from 'react'; +import { act, renderHook } from '@testing-library/react'; +import type { VListHandle } from 'virtua'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { useTimelineSync } from '$hooks/timeline/useTimelineSync'; +import type { ProcessedEvent } from '$hooks/timeline/useProcessedTimeline'; +import { useTimelineViewportController } from './useTimelineViewportController'; + +type TimelineSyncController = ReturnType; + +const nativeRequestAnimationFrame = globalThis.requestAnimationFrame; +const nativeCancelAnimationFrame = globalThis.cancelAnimationFrame; +const noop = () => {}; + +const createVList = (): VListHandle => + ({ + scrollOffset: 0, + scrollSize: 3000, + viewportSize: 800, + findItemIndex: vi.fn<(offset: number) => number>((offset) => + Math.max(0, Math.min(29, Math.floor(offset / 100))) + ), + getItemOffset: vi.fn<(index: number) => number>((index) => index * 100), + scrollTo: vi.fn<(offset: number) => void>(), + scrollBy: vi.fn<(offset: number) => void>(), + scrollToIndex: vi.fn<(index: number, options?: { align?: string }) => void>(), + }) as unknown as VListHandle; + +const createProcessedEvent = (id: string, itemIndex: number): ProcessedEvent => + ({ + id, + itemIndex, + mEvent: { getId: () => id }, + timelineSet: {}, + eventSender: null, + collapsed: false, + willRenderNewDivider: false, + willRenderDayDivider: false, + }) as unknown as ProcessedEvent; + +const createProcessedEvents = (count: number): ProcessedEvent[] => + Array.from({ length: count }, (_, index) => createProcessedEvent(`$${index}`, index)); + +const createTimelineSync = ( + overrides: Partial = {} +): TimelineSyncController => + ({ + timeline: { linkedTimelines: [] }, + setTimeline: vi.fn<(next: unknown) => void>(), + eventsLength: 3, + liveTimelineLinked: true, + canPaginateBack: true, + backwardStatus: 'idle', + forwardStatus: 'idle', + handleTimelinePagination: vi.fn<(backward: boolean) => void>(), + loadEventTimeline: vi.fn<(eventId: string) => Promise>(() => Promise.resolve()), + focusItem: undefined, + setFocusItem: vi.fn<(next: unknown) => void>(), + ...overrides, + }) as unknown as TimelineSyncController; + +const createRefs = (vList = createVList()) => { + const processedEventsRef: MutableRefObject = { + current: [ + createProcessedEvent('$a', 0), + createProcessedEvent('$b', 1), + createProcessedEvent('$c', 2), + ], + }; + + return { + vList, + vListRef: { current: vList } as RefObject, + messageListRef: { current: document.createElement('div') } as RefObject, + processedEventsRef, + atBottomRef: { current: true }, + }; +}; + +const createEmptyProcessedRefs = (vList = createVList()) => { + const refs = createRefs(vList); + refs.processedEventsRef.current = []; + return refs; +}; + +const renderController = ({ + eventId, + timelineSync = createTimelineSync(), + refs = createRefs(), + setAtBottom = vi.fn<(val: boolean) => void>((val: boolean) => { + refs.atBottomRef.current = val; + }), +}: { + eventId?: string; + timelineSync?: TimelineSyncController; + refs?: ReturnType; + setAtBottom?: (val: boolean) => void; +} = {}) => { + const timelineSyncRef: MutableRefObject = { current: timelineSync }; + + const hook = renderHook( + ({ sync, roomEventId }: { sync: TimelineSyncController; roomEventId?: string }) => { + timelineSyncRef.current = sync; + return useTimelineViewportController({ + roomId: '!room:test', + eventId: roomEventId, + timelineSync: sync, + timelineSyncRef, + vListRef: refs.vListRef, + messageListRef: refs.messageListRef, + processedEventsRef: refs.processedEventsRef, + atBottomRef: refs.atBottomRef, + setAtBottom, + getRawIndexToProcessedIndex: (rawIndex) => { + const index = refs.processedEventsRef.current.findIndex( + (event) => event.itemIndex === rawIndex + ); + return index < 0 ? undefined : index; + }, + }); + }, + { initialProps: { sync: timelineSync, roomEventId: eventId } } + ); + + return { ...hook, refs, setAtBottom, timelineSyncRef }; +}; + +describe('useTimelineViewportController', () => { + beforeEach(() => { + globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => { + cb(0); + return 1; + }) as typeof globalThis.requestAnimationFrame; + globalThis.cancelAnimationFrame = vi.fn< + (id: number) => void + >() as unknown as typeof globalThis.cancelAnimationFrame; + }); + + afterEach(() => { + globalThis.requestAnimationFrame = nativeRequestAnimationFrame; + globalThis.cancelAnimationFrame = nativeCancelAnimationFrame; + vi.useRealTimers(); + }); + + it('lands the initial live timeline at the latest event and reveals the viewport', () => { + const { result, refs } = renderController(); + + expect(refs.vList.scrollToIndex).toHaveBeenCalledWith(2, { align: 'end' }); + expect(refs.vList.scrollTo).toHaveBeenCalledWith(refs.vList.scrollSize); + expect(result.current.isReady).toBe(true); + }); + + it('re-anchors to latest when renderable rows appear after an all-state initial slice', () => { + const refs = createEmptyProcessedRefs(); + const timelineSync = createTimelineSync({ eventsLength: 20 }); + const { rerender, refs: renderedRefs } = renderController({ timelineSync, refs }); + + expect(renderedRefs.vList.scrollToIndex).not.toHaveBeenCalled(); + expect(renderedRefs.vList.scrollTo).toHaveBeenCalledWith(renderedRefs.vList.scrollSize); + + refs.processedEventsRef.current = [ + createProcessedEvent('$x', 10), + createProcessedEvent('$y', 11), + ]; + + rerender({ sync: timelineSync, roomEventId: undefined }); + + expect(renderedRefs.vList.scrollToIndex).toHaveBeenCalledWith(1, { align: 'end' }); + }); + + it('keeps underfilled live rooms hidden while bootstrap prefill starts', () => { + const vList = createVList() as unknown as { + scrollOffset: number; + scrollSize: number; + viewportSize: number; + }; + vList.scrollSize = 200; + vList.viewportSize = 800; + const refs = createRefs(vList as unknown as VListHandle); + const timelineSync = createTimelineSync({ canPaginateBack: true, backwardStatus: 'idle' }); + + const { result } = renderController({ timelineSync, refs }); + + expect(result.current.isReady).toBe(false); + expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true); + }); + + it('reveals an initially underfilled live room once bootstrap prefill fills the viewport', () => { + const vList = createVList() as unknown as { + scrollOffset: number; + scrollSize: number; + viewportSize: number; + }; + vList.scrollSize = 200; + vList.viewportSize = 800; + const refs = createRefs(vList as unknown as VListHandle); + const timelineSync = createTimelineSync({ canPaginateBack: true, backwardStatus: 'idle' }); + + const { result, rerender } = renderController({ timelineSync, refs }); + expect(result.current.isReady).toBe(false); + + const loadingSync = createTimelineSync({ + ...timelineSync, + backwardStatus: 'loading', + }); + rerender({ sync: loadingSync, roomEventId: undefined }); + + vList.scrollSize = 1500; + const idleSync = createTimelineSync({ + ...timelineSync, + backwardStatus: 'idle', + eventsLength: 10, + }); + rerender({ sync: idleSync, roomEventId: undefined }); + + expect(result.current.isReady).toBe(true); + }); + + it('does not paginate from layout-only scroll changes', () => { + const refs = createRefs(); + const timelineSync = createTimelineSync(); + const { result } = renderController({ timelineSync, refs }); + + vi.mocked(timelineSync.handleTimelinePagination).mockClear(); + act(() => { + result.current.handleVListScroll(0); + }); + + expect(timelineSync.handleTimelinePagination).not.toHaveBeenCalled(); + }); + + it('treats the live timeline as bottom when the latest row is visible', () => { + const refs = createRefs(); + refs.atBottomRef.current = false; + refs.processedEventsRef.current = createProcessedEvents(30); + const setAtBottom = vi.fn<(val: boolean) => void>((val) => { + refs.atBottomRef.current = val; + }); + const timelineSync = createTimelineSync({ eventsLength: 30, liveTimelineLinked: true }); + const { result } = renderController({ timelineSync, refs, setAtBottom }); + refs.atBottomRef.current = false; + setAtBottom.mockClear(); + + act(() => { + result.current.handleVListScroll(2101); + }); + + expect(setAtBottom).toHaveBeenCalledWith(true); + }); + + it('paginates older history when the user scrolls at the start edge', () => { + const refs = createRefs(); + const timelineSync = createTimelineSync(); + const { result } = renderController({ timelineSync, refs }); + + act(() => { + result.current.markUserScrollIntent('backward'); + }); + + expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true); + }); + + it('paginates newer history when a detached jump window reaches the end edge', () => { + const refs = createRefs(); + const mutableVList = refs.vList as unknown as { scrollOffset: number }; + mutableVList.scrollOffset = 2200; + const timelineSync = createTimelineSync({ liveTimelineLinked: false }); + const { result } = renderController({ timelineSync, refs }); + + act(() => { + result.current.markUserScrollIntent('forward'); + }); + + expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(false); + }); + + it('requests one page per edge crossing from scroll events', () => { + const refs = createRefs(); + const mutableVList = refs.vList as unknown as { scrollOffset: number }; + mutableVList.scrollOffset = 1200; + const timelineSync = createTimelineSync({ liveTimelineLinked: false }); + const { result } = renderController({ timelineSync, refs }); + + act(() => { + result.current.markUserScrollIntent('forward'); + }); + mutableVList.scrollOffset = 2200; + act(() => { + result.current.handleVListScroll(2200); + }); + act(() => { + result.current.handleVListScroll(2200); + }); + + expect(timelineSync.handleTimelinePagination).toHaveBeenCalledTimes(1); + expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(false); + }); + + it('paginates as soon as scrolling lands on the start edge', () => { + const refs = createRefs(); + const mutableVList = refs.vList as unknown as { scrollOffset: number }; + mutableVList.scrollOffset = 1200; + const timelineSync = createTimelineSync(); + const { result } = renderController({ timelineSync, refs }); + + vi.mocked(timelineSync.handleTimelinePagination).mockClear(); + act(() => { + result.current.handleVListScroll(1200); + }); + mutableVList.scrollOffset = 0; + act(() => { + result.current.handleVListScroll(0); + }); + + expect(timelineSync.handleTimelinePagination).toHaveBeenCalledTimes(1); + expect(timelineSync.handleTimelinePagination).toHaveBeenCalledWith(true); + }); + + it('does not treat a programmatic jump landing as edge pagination', () => { + const refs = createRefs(); + const mutableVList = refs.vList as unknown as { scrollOffset: number }; + mutableVList.scrollOffset = 1200; + const initialSync = createTimelineSync({ + liveTimelineLinked: false, + focusItem: { index: 1, scrollTo: true, highlight: true }, + }); + const { result, rerender } = renderController({ timelineSync: initialSync, refs }); + const landedSync = createTimelineSync({ + ...initialSync, + liveTimelineLinked: false, + focusItem: { index: 1, scrollTo: false, highlight: true }, + }); + rerender({ sync: landedSync, roomEventId: undefined }); + + vi.mocked(landedSync.handleTimelinePagination).mockClear(); + act(() => { + result.current.handleVListScroll(1200); + }); + mutableVList.scrollOffset = 2200; + act(() => { + result.current.handleVListScroll(2200); + }); + + expect(landedSync.handleTimelinePagination).not.toHaveBeenCalled(); + }); + + it('queues one additional page while loading if the user keeps scrolling at an edge', () => { + const refs = createRefs(); + const loadingSync = createTimelineSync({ backwardStatus: 'loading' }); + const { result, rerender } = renderController({ timelineSync: loadingSync, refs }); + + act(() => { + result.current.markUserScrollIntent('backward'); + }); + expect(loadingSync.handleTimelinePagination).not.toHaveBeenCalled(); + + const idleSync = createTimelineSync({ ...loadingSync, backwardStatus: 'idle' }); + rerender({ sync: idleSync, roomEventId: undefined }); + + expect(idleSync.handleTimelinePagination).toHaveBeenCalledTimes(1); + expect(idleSync.handleTimelinePagination).toHaveBeenCalledWith(true); + }); + + it('rearms edge pagination after a page settles at the same edge', () => { + const refs = createRefs(); + const timelineSync = createTimelineSync({ backwardStatus: 'idle' }); + const { result, rerender } = renderController({ timelineSync, refs }); + + act(() => { + result.current.markUserScrollIntent('backward'); + }); + expect(timelineSync.handleTimelinePagination).toHaveBeenCalledTimes(1); + + const loadingSync = createTimelineSync({ + ...timelineSync, + backwardStatus: 'loading', + }); + rerender({ sync: loadingSync, roomEventId: undefined }); + + const settledSync = createTimelineSync({ + ...timelineSync, + backwardStatus: 'idle', + }); + rerender({ sync: settledSync, roomEventId: undefined }); + + act(() => { + result.current.markUserScrollIntent('backward'); + }); + + expect(settledSync.handleTimelinePagination).toHaveBeenCalledTimes(2); + expect(settledSync.handleTimelinePagination).toHaveBeenLastCalledWith(true); + }); + + it('enables virtua shift only while backward pagination loads away from bottom', () => { + const refs = createRefs(); + refs.atBottomRef.current = false; + const idleSync = createTimelineSync(); + const { result, rerender } = renderController({ timelineSync: idleSync, refs }); + refs.atBottomRef.current = false; + + const loadingSync = createTimelineSync({ ...idleSync, backwardStatus: 'loading' }); + rerender({ sync: loadingSync, roomEventId: undefined }); + expect(result.current.shift).toBe(true); + + const settledSync = createTimelineSync({ ...idleSync, backwardStatus: 'idle' }); + rerender({ sync: settledSync, roomEventId: undefined }); + expect(result.current.shift).toBe(false); + }); + + it('centers a loaded focus item and releases bottom following', () => { + const setAtBottom = vi.fn<(val: boolean) => void>(); + const setFocusItem = vi.fn<(next: unknown) => void>(); + const timelineSync = createTimelineSync({ + focusItem: { index: 1, scrollTo: true, highlight: true }, + setFocusItem, + }); + const { refs } = renderController({ timelineSync, setAtBottom }); + + expect(refs.vList.scrollToIndex).toHaveBeenCalledWith(1, { align: 'center' }); + expect(setAtBottom).toHaveBeenCalledWith(false); + expect(setFocusItem).toHaveBeenCalledWith(expect.any(Function)); + }); + + it('does not paginate from the programmatic landing scroll after a jump', () => { + const refs = createRefs(); + const initialSync = createTimelineSync({ + liveTimelineLinked: false, + focusItem: { index: 1, scrollTo: true, highlight: true }, + }); + const { result, rerender } = renderController({ timelineSync: initialSync, refs }); + const landedSync = createTimelineSync({ + ...initialSync, + liveTimelineLinked: false, + focusItem: { index: 1, scrollTo: false, highlight: true }, + }); + rerender({ sync: landedSync, roomEventId: undefined }); + + vi.mocked(landedSync.handleTimelinePagination).mockClear(); + act(() => { + result.current.handleVListScroll(2200); + }); + + expect(landedSync.handleTimelinePagination).not.toHaveBeenCalled(); + }); + + it('allows normal forward pagination after the user scrolls away from a landed jump', () => { + const refs = createRefs(); + const mutableVList = refs.vList as unknown as { scrollOffset: number }; + mutableVList.scrollOffset = 2200; + const initialSync = createTimelineSync({ + liveTimelineLinked: false, + focusItem: { index: 1, scrollTo: true, highlight: true }, + }); + const { result, rerender } = renderController({ timelineSync: initialSync, refs }); + const landedSync = createTimelineSync({ + ...initialSync, + liveTimelineLinked: false, + focusItem: { index: 1, scrollTo: false, highlight: true }, + }); + rerender({ sync: landedSync, roomEventId: undefined }); + + act(() => { + result.current.markUserScrollIntent('forward'); + }); + act(() => { + result.current.handleVListScroll(2200); + }); + + expect(landedSync.handleTimelinePagination).toHaveBeenCalledTimes(1); + expect(landedSync.handleTimelinePagination).toHaveBeenCalledWith(false); + }); + + it('loads a missing jump target and suppresses pagination until it resolves', () => { + let resolveLoad: () => void = noop; + const timelineSync = createTimelineSync({ + loadEventTimeline: vi.fn<(eventId: string) => Promise>( + () => + new Promise((resolve) => { + resolveLoad = resolve; + }) + ), + }); + const { result } = renderController({ eventId: '$target', timelineSync }); + + act(() => { + result.current.markUserScrollIntent('backward'); + }); + + expect(timelineSync.loadEventTimeline).toHaveBeenCalledWith('$target'); + expect(timelineSync.handleTimelinePagination).not.toHaveBeenCalled(); + + act(() => { + resolveLoad(); + }); + }); +}); diff --git a/src/app/features/room/useTimelineViewportController.ts b/src/app/features/room/useTimelineViewportController.ts new file mode 100644 index 000000000..fc6ff14da --- /dev/null +++ b/src/app/features/room/useTimelineViewportController.ts @@ -0,0 +1,671 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type MutableRefObject, + type RefObject, +} from 'react'; +import type { VListHandle } from 'virtua'; +import type { useTimelineSync } from '$hooks/timeline/useTimelineSync'; +import { + getProcessedRowIndexForRawTimelineIndex, + type ProcessedEvent, +} from '$hooks/timeline/useProcessedTimeline'; +import { + getBottomClampSpacer, + getCenterAnchorAdjustment, + getTimelineVisibleRange, + type TimelineAnchor, + type TimelineScrollDirection, + type TimelineVisibleRange, +} from './timelineViewportModel'; + +const INITIAL_BACKFILL_PAGE_BUDGET = 6; +const READY_FALLBACK_MS = 1500; +const FOCUS_HIGHLIGHT_MS = 2000; + +type TimelineSyncController = ReturnType; + +export type UseTimelineViewportControllerOptions = { + roomId: string; + eventId?: string; + timelineSync: TimelineSyncController; + timelineSyncRef: MutableRefObject; + vListRef: RefObject; + messageListRef: RefObject; + processedEventsRef: MutableRefObject; + atBottomRef: MutableRefObject; + setAtBottom: (val: boolean) => void; + getRawIndexToProcessedIndex: (rawIndex: number) => number | undefined; +}; + +const getScrollDirection = ( + previousOffset: number, + nextOffset: number +): TimelineScrollDirection | undefined => { + if (nextOffset < previousOffset) return 'backward'; + if (nextOffset > previousOffset) return 'forward'; + return undefined; +}; + +export function useTimelineViewportController({ + roomId, + eventId, + timelineSync, + timelineSyncRef, + vListRef, + messageListRef, + processedEventsRef, + atBottomRef, + setAtBottom, + getRawIndexToProcessedIndex, +}: UseTimelineViewportControllerOptions) { + const [shift, setShift] = useState(false); + const [topSpacerHeight, setTopSpacerHeight] = useState(0); + const [isReady, setIsReady] = useState(false); + const [jumpInFlight, setJumpInFlight] = useState(false); + const [bootstrapViewportTick, setBootstrapViewportTick] = useState(0); + + const topSpacerHeightRef = useRef(0); + const hasInitialScrolledRef = useRef(false); + const pendingReadyRef = useRef(false); + const anchorRef = useRef({ kind: 'bottom' }); + const edgeArmedRef = useRef({ backward: true, forward: true }); + const queuedPaginationRef = useRef({ backward: false, forward: false }); + const pendingUserDirectionRef = useRef(undefined); + const pendingUserScrollRef = useRef(false); + const pendingBootstrapRevealRef = useRef(false); + const remainingInitialBackfillPagesRef = useRef(INITIAL_BACKFILL_PAGE_BUDGET); + const lastScrollOffsetRef = useRef(0); + const pendingBootstrapFrameRef = useRef(undefined); + const settleAnchorFrameRef = useRef(undefined); + const readyFallbackTimerRef = useRef | undefined>(undefined); + const currentRoomIdRef = useRef(roomId); + const jumpInFlightRef = useRef(jumpInFlight); + jumpInFlightRef.current = jumpInFlight; + + const canPaginateBackRef = useRef(timelineSync.canPaginateBack); + canPaginateBackRef.current = timelineSync.canPaginateBack; + + const liveTimelineLinkedRef = useRef(timelineSync.liveTimelineLinked); + liveTimelineLinkedRef.current = timelineSync.liveTimelineLinked; + + const backwardStatusRef = useRef(timelineSync.backwardStatus); + backwardStatusRef.current = timelineSync.backwardStatus; + + const forwardStatusRef = useRef(timelineSync.forwardStatus); + forwardStatusRef.current = timelineSync.forwardStatus; + + useLayoutEffect(() => { + if (currentRoomIdRef.current === roomId) return; + + currentRoomIdRef.current = roomId; + hasInitialScrolledRef.current = false; + pendingReadyRef.current = false; + anchorRef.current = eventId ? { kind: 'none' } : { kind: 'bottom' }; + edgeArmedRef.current = { backward: true, forward: true }; + queuedPaginationRef.current = { backward: false, forward: false }; + pendingUserDirectionRef.current = undefined; + pendingUserScrollRef.current = false; + pendingBootstrapRevealRef.current = false; + remainingInitialBackfillPagesRef.current = INITIAL_BACKFILL_PAGE_BUDGET; + lastScrollOffsetRef.current = 0; + topSpacerHeightRef.current = 0; + + if (pendingBootstrapFrameRef.current !== undefined) { + cancelAnimationFrame(pendingBootstrapFrameRef.current); + pendingBootstrapFrameRef.current = undefined; + } + if (settleAnchorFrameRef.current !== undefined) { + cancelAnimationFrame(settleAnchorFrameRef.current); + settleAnchorFrameRef.current = undefined; + } + if (readyFallbackTimerRef.current !== undefined) { + clearTimeout(readyFallbackTimerRef.current); + readyFallbackTimerRef.current = undefined; + } + + setShift(false); + setTopSpacerHeight(0); + setJumpInFlight(false); + setIsReady(false); + }, [eventId, roomId]); + + const getRenderedItemCount = useCallback(() => { + const processedCount = processedEventsRef.current.length; + if (processedCount > 0) return processedCount; + return timelineSyncRef.current.eventsLength > 0 ? 1 : 0; + }, [processedEventsRef, timelineSyncRef]); + + const getVisibleRange = useCallback( + (offset?: number): TimelineVisibleRange | undefined => { + const v = vListRef.current; + if (!v) return undefined; + return getTimelineVisibleRange(v, getRenderedItemCount(), offset); + }, + [getRenderedItemCount, vListRef] + ); + + const setBottomStateFromRange = useCallback( + (range: TimelineVisibleRange) => { + const nextAtBottom = range.atEnd && liveTimelineLinkedRef.current; + if (nextAtBottom !== atBottomRef.current) setAtBottom(nextAtBottom); + }, + [atBottomRef, setAtBottom] + ); + + const updateEdgeArming = useCallback((range: TimelineVisibleRange) => { + if (!range.atStart) edgeArmedRef.current.backward = true; + if (!range.atEnd) edgeArmedRef.current.forward = true; + }, []); + + const recalcTopSpacer = useCallback(() => { + const v = vListRef.current; + if (!v) return; + const prev = topSpacerHeightRef.current; + const next = getBottomClampSpacer(v.viewportSize, v.scrollSize, prev); + if (prev !== next) { + topSpacerHeightRef.current = next; + setTopSpacerHeight(next); + } + }, [vListRef]); + + const revealBootstrapIfReady = useCallback((): boolean => { + if (!pendingBootstrapRevealRef.current) return false; + + const v = vListRef.current; + if (!v || v.viewportSize <= 0) return false; + + const contentHeight = Math.max(0, v.scrollSize - topSpacerHeightRef.current); + const done = + contentHeight > v.viewportSize || + !canPaginateBackRef.current || + remainingInitialBackfillPagesRef.current <= 0; + + if (!done) return false; + + pendingBootstrapRevealRef.current = false; + setIsReady(true); + return true; + }, [vListRef]); + + const findMessageElement = useCallback( + (eventIdToFind: string): HTMLElement | undefined => { + const root = messageListRef.current; + if (!root) return undefined; + const messageEls = root.querySelectorAll('[data-message-id]'); + return Array.from(messageEls).find((el) => el.dataset.messageId === eventIdToFind); + }, + [messageListRef] + ); + + const applyTimelineAnchor = useCallback( + (anchor = anchorRef.current): boolean => { + const v = vListRef.current; + if (!v) return false; + + if (anchor.kind === 'bottom') { + v.scrollTo(v.scrollSize); + if (liveTimelineLinkedRef.current && !atBottomRef.current) setAtBottom(true); + return true; + } + + if (anchor.kind === 'message-center') { + const viewport = messageListRef.current; + const target = findMessageElement(anchor.eventId); + if (!viewport || !target) return false; + + const adjustment = getCenterAnchorAdjustment( + target.getBoundingClientRect(), + viewport.getBoundingClientRect() + ); + if (adjustment !== 0) v.scrollBy(adjustment); + return true; + } + + return false; + }, + [atBottomRef, findMessageElement, messageListRef, setAtBottom, vListRef] + ); + + const settleTimelineAnchor = useCallback( + (anchor: TimelineAnchor, reveal = false) => { + anchorRef.current = anchor; + if (settleAnchorFrameRef.current !== undefined) { + cancelAnimationFrame(settleAnchorFrameRef.current); + } + + settleAnchorFrameRef.current = requestAnimationFrame(() => { + recalcTopSpacer(); + applyTimelineAnchor(anchor); + settleAnchorFrameRef.current = undefined; + if (reveal) setIsReady(true); + }); + }, + [applyTimelineAnchor, recalcTopSpacer] + ); + + const requestPagination = useCallback( + (direction: TimelineScrollDirection, fromQueuedIntent = false): boolean => { + if (jumpInFlightRef.current || timelineSyncRef.current.focusItem?.scrollTo) return false; + + if (direction === 'backward') { + if (!canPaginateBackRef.current) return false; + if (backwardStatusRef.current === 'loading') { + if (!fromQueuedIntent) queuedPaginationRef.current.backward = true; + return false; + } + if (backwardStatusRef.current !== 'idle') return false; + edgeArmedRef.current.backward = false; + timelineSyncRef.current.handleTimelinePagination(true); + return true; + } + + if (liveTimelineLinkedRef.current) return false; + if (forwardStatusRef.current === 'loading') { + if (!fromQueuedIntent) queuedPaginationRef.current.forward = true; + return false; + } + if (forwardStatusRef.current !== 'idle') return false; + edgeArmedRef.current.forward = false; + timelineSyncRef.current.handleTimelinePagination(false); + return true; + }, + [timelineSyncRef] + ); + + const requestPaginationAtVisibleEdge = useCallback( + (direction: TimelineScrollDirection, range = getVisibleRange()) => { + if (!range) return false; + if (direction === 'backward' && range.atStart && edgeArmedRef.current.backward) { + return requestPagination('backward'); + } + if (direction === 'forward' && range.atEnd && edgeArmedRef.current.forward) { + return requestPagination('forward'); + } + return false; + }, + [getVisibleRange, requestPagination] + ); + + const releaseAnchorsForUserScroll = useCallback( + (direction: TimelineScrollDirection | undefined) => { + if (!direction) return; + + if (anchorRef.current.kind === 'message-center') { + anchorRef.current = { kind: 'none' }; + } + + if (anchorRef.current.kind === 'bottom' && direction === 'backward') { + anchorRef.current = { kind: 'none' }; + if (atBottomRef.current) setAtBottom(false); + } + }, + [atBottomRef, setAtBottom] + ); + + const beginJumpLoad = useCallback( + (targetEventId: string) => { + pendingUserDirectionRef.current = undefined; + pendingUserScrollRef.current = false; + anchorRef.current = { kind: 'none' }; + setJumpInFlight(true); + void Promise.resolve(timelineSyncRef.current.loadEventTimeline(targetEventId)).finally(() => { + setJumpInFlight(false); + }); + }, + [timelineSyncRef] + ); + + useEffect( + () => () => { + if (settleAnchorFrameRef.current !== undefined) { + cancelAnimationFrame(settleAnchorFrameRef.current); + } + if (pendingBootstrapFrameRef.current !== undefined) { + cancelAnimationFrame(pendingBootstrapFrameRef.current); + } + if (readyFallbackTimerRef.current !== undefined) { + clearTimeout(readyFallbackTimerRef.current); + } + }, + [] + ); + + useEffect(() => { + if (isReady) return undefined; + if (pendingBootstrapRevealRef.current) return undefined; + if (eventId || jumpInFlight) return undefined; + readyFallbackTimerRef.current = setTimeout(() => { + setIsReady(true); + readyFallbackTimerRef.current = undefined; + }, READY_FALLBACK_MS); + return () => { + if (readyFallbackTimerRef.current !== undefined) { + clearTimeout(readyFallbackTimerRef.current); + readyFallbackTimerRef.current = undefined; + } + }; + }, [eventId, isReady, jumpInFlight, roomId]); + + useLayoutEffect(() => { + if (eventId || hasInitialScrolledRef.current || timelineSync.eventsLength === 0) return; + if (!timelineSync.liveTimelineLinked || !vListRef.current) return; + + const lastIndex = processedEventsRef.current.length - 1; + if (lastIndex >= 0) { + vListRef.current.scrollToIndex(lastIndex, { align: 'end' }); + } else { + pendingReadyRef.current = true; + } + + const shouldBootstrapBeforeReveal = timelineSync.canPaginateBack; + pendingBootstrapRevealRef.current = shouldBootstrapBeforeReveal; + settleTimelineAnchor({ kind: 'bottom' }, !shouldBootstrapBeforeReveal); + hasInitialScrolledRef.current = true; + }, [ + eventId, + timelineSync.eventsLength, + timelineSync.liveTimelineLinked, + timelineSync.canPaginateBack, + processedEventsRef, + settleTimelineAnchor, + vListRef, + ]); + + useLayoutEffect(() => { + if (eventId || isReady) return; + if (!timelineSync.liveTimelineLinked) return; + if (timelineSync.eventsLength > 0) return; + if (timelineSync.canPaginateBack || timelineSync.backwardStatus === 'loading') return; + settleTimelineAnchor({ kind: 'bottom' }, true); + }, [ + eventId, + isReady, + timelineSync.liveTimelineLinked, + timelineSync.eventsLength, + timelineSync.canPaginateBack, + timelineSync.backwardStatus, + settleTimelineAnchor, + ]); + + const prevBackwardStatusRef = useRef(timelineSync.backwardStatus); + const prevForwardStatusRef = useRef(timelineSync.forwardStatus); + const wasAtBottomBeforePaginationRef = useRef(false); + + useLayoutEffect(() => { + const previous = prevBackwardStatusRef.current; + prevBackwardStatusRef.current = timelineSync.backwardStatus; + + if (timelineSync.backwardStatus === 'loading') { + wasAtBottomBeforePaginationRef.current = atBottomRef.current; + if (!atBottomRef.current) setShift(true); + return; + } + + if (previous === 'loading') { + setShift(false); + edgeArmedRef.current.backward = true; + if (wasAtBottomBeforePaginationRef.current) settleTimelineAnchor({ kind: 'bottom' }); + revealBootstrapIfReady(); + + if (queuedPaginationRef.current.backward) { + queuedPaginationRef.current.backward = false; + requestPagination('backward', true); + } + } + }, [ + atBottomRef, + requestPagination, + revealBootstrapIfReady, + settleTimelineAnchor, + timelineSync.backwardStatus, + ]); + + useLayoutEffect(() => { + const previous = prevForwardStatusRef.current; + prevForwardStatusRef.current = timelineSync.forwardStatus; + + if (previous === 'loading' && timelineSync.forwardStatus !== 'loading') { + edgeArmedRef.current.forward = true; + if (queuedPaginationRef.current.forward) { + queuedPaginationRef.current.forward = false; + requestPagination('forward', true); + } + } + }, [requestPagination, timelineSync.forwardStatus]); + + useEffect(() => { + let timeoutId: ReturnType | undefined; + + if (timelineSync.focusItem) { + if (timelineSync.focusItem.scrollTo && vListRef.current) { + let processedIndex = getRawIndexToProcessedIndex(timelineSync.focusItem.index); + let focusRawIndex = timelineSync.focusItem.index; + if (processedIndex === undefined) { + const nearest = getProcessedRowIndexForRawTimelineIndex( + processedEventsRef.current, + timelineSync.focusItem.index + ); + if (nearest) { + processedIndex = nearest.rowIndex; + focusRawIndex = nearest.focusRawIndex; + } + } + + if (processedIndex !== undefined) { + vListRef.current.scrollToIndex(processedIndex, { align: 'center' }); + const focusEventId = processedEventsRef.current[processedIndex]?.id; + if (focusEventId) { + settleTimelineAnchor({ kind: 'message-center', eventId: focusEventId }, true); + } else { + setIsReady(true); + } + if (atBottomRef.current) setAtBottom(false); + timelineSync.setFocusItem((prev) => + prev ? { ...prev, index: focusRawIndex, scrollTo: false } : undefined + ); + } + } + + timeoutId = setTimeout(() => { + timelineSync.setFocusItem(undefined); + }, FOCUS_HIGHLIGHT_MS); + } + + return () => { + if (timeoutId !== undefined) clearTimeout(timeoutId); + }; + }, [ + atBottomRef, + getRawIndexToProcessedIndex, + processedEventsRef, + setAtBottom, + settleTimelineAnchor, + timelineSync, + timelineSync.focusItem, + vListRef, + ]); + + useEffect(() => { + if (timelineSync.focusItem) setIsReady(true); + }, [timelineSync.focusItem]); + + useEffect(() => { + if (!eventId) return; + setIsReady(false); + beginJumpLoad(eventId); + }, [beginJumpLoad, eventId, roomId]); + + useLayoutEffect(() => { + if (!isReady) return; + recalcTopSpacer(); + applyTimelineAnchor(); + + const range = getVisibleRange(); + if (range) setBottomStateFromRange(range); + }, [ + applyTimelineAnchor, + getVisibleRange, + isReady, + recalcTopSpacer, + setBottomStateFromRange, + timelineSync.backwardStatus, + timelineSync.eventsLength, + timelineSync.forwardStatus, + ]); + + useEffect(() => { + if (!isReady) return undefined; + const viewport = messageListRef.current; + if (!viewport) return undefined; + if (typeof ResizeObserver === 'undefined') return undefined; + + const observer = new ResizeObserver(() => { + recalcTopSpacer(); + applyTimelineAnchor(); + }); + + observer.observe(viewport); + return () => observer.disconnect(); + }, [applyTimelineAnchor, isReady, messageListRef, recalcTopSpacer]); + + useLayoutEffect(() => { + if (!pendingReadyRef.current) return; + if (processedEventsRef.current.length === 0) return; + pendingReadyRef.current = false; + vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); + const shouldBootstrapBeforeReveal = timelineSync.canPaginateBack; + pendingBootstrapRevealRef.current = shouldBootstrapBeforeReveal; + settleTimelineAnchor({ kind: 'bottom' }, !shouldBootstrapBeforeReveal); + }, [ + processedEventsRef, + processedEventsRef.current.length, + settleTimelineAnchor, + timelineSync.canPaginateBack, + timelineSync.eventsLength, + vListRef, + ]); + + useEffect(() => { + const v = vListRef.current; + if (!v || eventId || jumpInFlight) return; + if (!isReady && !pendingBootstrapRevealRef.current) return; + if (anchorRef.current.kind !== 'bottom') return; + if (remainingInitialBackfillPagesRef.current <= 0) return; + if (v.viewportSize <= 0) { + if (pendingBootstrapFrameRef.current === undefined) { + pendingBootstrapFrameRef.current = requestAnimationFrame(() => { + pendingBootstrapFrameRef.current = undefined; + setBootstrapViewportTick((prev) => prev + 1); + }); + } + return; + } + + if (revealBootstrapIfReady()) return; + if (!canPaginateBackRef.current || backwardStatusRef.current !== 'idle') return; + + const contentHeight = Math.max(0, v.scrollSize - topSpacerHeightRef.current); + if (contentHeight > v.viewportSize) return; + + remainingInitialBackfillPagesRef.current -= 1; + requestPagination('backward'); + }, [ + eventId, + isReady, + jumpInFlight, + revealBootstrapIfReady, + requestPagination, + timelineSync.backwardStatus, + timelineSync.eventsLength, + bootstrapViewportTick, + vListRef, + ]); + + const handleVListScroll = useCallback( + (offset: number) => { + const v = vListRef.current; + if (!v) return; + + const previousOffset = lastScrollOffsetRef.current; + lastScrollOffsetRef.current = offset; + + const range = getVisibleRange(offset); + if (!range) return; + const previousRange = getVisibleRange(previousOffset); + updateEdgeArming(range); + setBottomStateFromRange(range); + + const userDirection = pendingUserDirectionRef.current; + const userScroll = pendingUserScrollRef.current; + pendingUserDirectionRef.current = undefined; + pendingUserScrollRef.current = false; + + const inferredDirection = getScrollDirection(previousOffset, offset); + const direction = userDirection ?? inferredDirection; + const isUserInitiated = userScroll || userDirection !== undefined; + const arrivedAtEdge = + Boolean(previousRange) && + ((direction === 'backward' && !previousRange?.atStart && range.atStart) || + (direction === 'forward' && !previousRange?.atEnd && range.atEnd)); + + if (!direction) { + if (anchorRef.current.kind === 'none' && range.atEnd && liveTimelineLinkedRef.current) { + anchorRef.current = { kind: 'bottom' }; + } + return; + } + + if (isUserInitiated) releaseAnchorsForUserScroll(direction); + if (isUserInitiated || anchorRef.current.kind !== 'message-center') { + if (isUserInitiated || arrivedAtEdge) requestPaginationAtVisibleEdge(direction, range); + } + + if (anchorRef.current.kind === 'none' && range.atEnd && liveTimelineLinkedRef.current) { + anchorRef.current = { kind: 'bottom' }; + } + }, + [ + getVisibleRange, + releaseAnchorsForUserScroll, + requestPaginationAtVisibleEdge, + setBottomStateFromRange, + updateEdgeArming, + vListRef, + ] + ); + + const markUserScrollIntent = useCallback( + (direction?: TimelineScrollDirection) => { + pendingUserScrollRef.current = true; + pendingUserDirectionRef.current = direction; + + releaseAnchorsForUserScroll(direction); + if (!direction) return; + + const range = getVisibleRange(); + if (!range) return; + updateEdgeArming(range); + + if (direction === 'backward' && range.atStart) { + requestPagination('backward'); + } else if (direction === 'forward' && range.atEnd) { + requestPagination('forward'); + } + }, + [getVisibleRange, releaseAnchorsForUserScroll, requestPagination, updateEdgeArming] + ); + + return { + shift, + topSpacerHeight, + isReady, + beginJumpLoad, + settleTimelineAnchor, + handleVListScroll, + markUserScrollIntent, + }; +} diff --git a/src/app/features/search/Search.test.ts b/src/app/features/search/Search.test.ts new file mode 100644 index 000000000..4e6284839 --- /dev/null +++ b/src/app/features/search/Search.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import type { RoomToParents } from '$types/matrix/room'; +import { sortRoomsBySelectedSpace } from './searchUtils'; + +describe('sortRoomsBySelectedSpace', () => { + it('returns original list when no space is selected', () => { + const roomToParents: RoomToParents = new Map(); + const items = ['!a:example.org', '!b:example.org', '!c:example.org']; + + expect(sortRoomsBySelectedSpace(items, undefined, roomToParents)).toEqual(items); + }); + + it('prioritizes rooms in selected space', () => { + const roomToParents: RoomToParents = new Map([ + ['!room-a:example.org', new Set(['!space-1:example.org'])], + ['!room-b:example.org', new Set(['!space-2:example.org'])], + ['!room-c:example.org', new Set(['!space-1:example.org'])], + ]); + const items = ['!room-b:example.org', '!room-a:example.org', '!room-c:example.org']; + + expect(sortRoomsBySelectedSpace(items, '!space-1:example.org', roomToParents)).toEqual([ + '!room-a:example.org', + '!room-c:example.org', + '!room-b:example.org', + ]); + }); +}); diff --git a/src/app/features/search/Search.tsx b/src/app/features/search/Search.tsx index 3ca25fbb6..eae55c6af 100644 --- a/src/app/features/search/Search.tsx +++ b/src/app/features/search/Search.tsx @@ -50,6 +50,7 @@ import { KeySymbol } from '$utils/key-symbol'; import { isMacOS } from '$utils/user-agent'; import { useSelectedSpace } from '$hooks/router/useSelectedSpace'; import { getMxIdServer } from '$utils/mxIdHelper'; +import { sortRoomsBySelectedSpace } from './searchUtils'; enum SearchRoomType { Rooms = '#', @@ -166,14 +167,16 @@ export function Search({ requestClose }: SearchProps) { const roomsToRender = useMemo(() => { const items = result ? result.items : topActiveRooms; - if (!selectedSpaceId) return items; + return sortRoomsBySelectedSpace(items, selectedSpaceId, roomToParents); + }, [result, topActiveRooms, selectedSpaceId, roomToParents]); - return [...items].toSorted((a, b) => { - const aInSpace = getAllParents(roomToParents, a)?.has(selectedSpaceId) ? 1 : 0; - const bInSpace = getAllParents(roomToParents, b)?.has(selectedSpaceId) ? 1 : 0; - return bInSpace - aInSpace; + const roomParentsCache = useMemo(() => { + const cache = new Map>(); + roomsToRender.forEach((roomId) => { + cache.set(roomId, getAllParents(roomToParents, roomId)); }); - }, [result, topActiveRooms, selectedSpaceId, roomToParents]); + return cache; + }, [roomsToRender, roomToParents]); const listFocus = useListFocusIndex(roomsToRender.length, 0); @@ -315,7 +318,7 @@ export function Search({ requestClose }: SearchProps) { const dmUsername = dmUserId && getMxIdLocalPart(dmUserId); const dmUserServer = dmUserId && getMxIdServer(dmUserId); - const allParents = getAllParents(roomToParents, roomId); + const allParents = roomParentsCache.get(roomId); const orphanParents = allParents && orphanSpaces.filter((o) => allParents.has(o)); const perfectOrphanParent = diff --git a/src/app/features/search/SearchModalRenderer.tsx b/src/app/features/search/SearchModalRenderer.tsx new file mode 100644 index 000000000..6687fe7b0 --- /dev/null +++ b/src/app/features/search/SearchModalRenderer.tsx @@ -0,0 +1,46 @@ +import { lazy, Suspense, useCallback } from 'react'; +import { isKeyHotkey } from 'is-hotkey'; +import { useAtom } from 'jotai'; +import { useKeyDown } from '$hooks/useKeyDown'; +import { searchModalAtom } from '$state/searchModal'; +import { prefetchSearchModal } from '$pages/routePrefetch'; + +const Search = lazy(async () => { + const mod = await import('./Search'); + return { default: mod.Search }; +}); + +export function SearchModalRenderer() { + const [opened, setOpen] = useAtom(searchModalAtom); + + useKeyDown( + window, + useCallback( + (event) => { + if (isKeyHotkey('mod+k', event) || isKeyHotkey('mod+f', event)) { + event.preventDefault(); + void prefetchSearchModal(); + if (opened) { + setOpen(false); + return; + } + + const portalContainer = document.getElementById('portalContainer'); + if (portalContainer && portalContainer.children.length > 0) { + return; + } + setOpen(true); + } + }, + [opened, setOpen] + ) + ); + + if (!opened) return null; + + return ( + + setOpen(false)} /> + + ); +} diff --git a/src/app/features/search/index.ts b/src/app/features/search/index.ts deleted file mode 100644 index addd53308..000000000 --- a/src/app/features/search/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Search'; diff --git a/src/app/features/search/searchUtils.ts b/src/app/features/search/searchUtils.ts new file mode 100644 index 000000000..5e79e1fa4 --- /dev/null +++ b/src/app/features/search/searchUtils.ts @@ -0,0 +1,22 @@ +import type { RoomToParents } from '$types/matrix/room'; +import { hasRecursiveParent } from '$utils/room'; + +export const sortRoomsBySelectedSpace = ( + items: string[], + selectedSpaceId: string | undefined, + roomToParents: RoomToParents +): string[] => { + if (!selectedSpaceId) return items; + + const inSelectedSpaceCache = new Map(); + const getInSelectedSpaceScore = (roomId: string): number => { + const cached = inSelectedSpaceCache.get(roomId); + if (cached !== undefined) return cached; + + const score = hasRecursiveParent(roomToParents, roomId, selectedSpaceId) ? 1 : 0; + inSelectedSpaceCache.set(roomId, score); + return score; + }; + + return [...items].toSorted((a, b) => getInSelectedSpaceScore(b) - getInSelectedSpaceScore(a)); +}; diff --git a/src/app/features/settings/Settings.test.tsx b/src/app/features/settings/Settings.test.tsx index abc8fa997..4b9eb5e7a 100644 --- a/src/app/features/settings/Settings.test.tsx +++ b/src/app/features/settings/Settings.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; import { ClientConfigProvider } from '$hooks/useClientConfig'; import { ScreenSize, ScreenSizeProvider } from '$hooks/useScreenSize'; @@ -150,12 +150,10 @@ describe('Settings', () => { ); fireEvent.click(screen.getByRole('button', { name: /copy settings link/i })); - - await waitFor(() => { - expect(writeText).toHaveBeenCalledWith( - 'https://app.example/settings/appearance?focus=message-link-preview&moe.sable.client.action=settings' - ); - }); + await Promise.resolve(); + expect(writeText).toHaveBeenCalledWith( + 'https://app.example/settings/appearance?focus=message-link-preview&moe.sable.client.action=settings' + ); }); it('preserves the configured hash-router basename in copied settings links', async () => { @@ -170,11 +168,9 @@ describe('Settings', () => { ); fireEvent.click(screen.getByRole('button', { name: /copy settings link/i })); - - await waitFor(() => { - expect(writeText).toHaveBeenCalledWith( - 'https://app.example/#/app/settings/appearance?focus=message-link-preview&moe.sable.client.action=settings' - ); - }); + await Promise.resolve(); + expect(writeText).toHaveBeenCalledWith( + 'https://app.example/#/app/settings/appearance?focus=message-link-preview&moe.sable.client.action=settings' + ); }); }); diff --git a/src/app/features/settings/SettingsRoute.test.tsx b/src/app/features/settings/SettingsRoute.test.tsx index 5a991494d..505784d12 100644 --- a/src/app/features/settings/SettingsRoute.test.tsx +++ b/src/app/features/settings/SettingsRoute.test.tsx @@ -655,6 +655,9 @@ describe('Settings shallow route shell', () => { fireEvent.click(screen.getByRole('button', { name: 'Open focused general settings' })); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); expect(screen.getByRole('heading', { name: 'Home route' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'General section' })).toBeInTheDocument(); expect(screen.getByTestId('location-probe')).toHaveTextContent('?focus=message-layout'); diff --git a/src/app/features/settings/SettingsShallowRouteRenderer.tsx b/src/app/features/settings/SettingsShallowRouteRenderer.tsx index 2fd7053ce..4d7165db0 100644 --- a/src/app/features/settings/SettingsShallowRouteRenderer.tsx +++ b/src/app/features/settings/SettingsShallowRouteRenderer.tsx @@ -1,10 +1,15 @@ +import { lazy, Suspense } from 'react'; import { matchPath, useLocation, useNavigate } from 'react-router-dom'; import { useScreenSizeContext } from '$hooks/useScreenSize'; import { Modal500 } from '$components/Modal500'; import { isShallowSettingsRoute } from '$pages/client/ClientRouteOutlet'; import { SETTINGS_PATH } from '$pages/paths'; import { getSettingsCloseTarget, type SettingsRouteState } from './navigation'; -import { SettingsRoute } from './SettingsRoute'; + +const SettingsRoute = lazy(async () => { + const mod = await import('./SettingsRoute'); + return { default: mod.SettingsRoute }; +}); export function SettingsShallowRouteRenderer() { const navigate = useNavigate(); @@ -24,7 +29,9 @@ export function SettingsShallowRouteRenderer() { return ( - + + + ); } diff --git a/src/app/features/settings/cosmetics/Themes.test.tsx b/src/app/features/settings/cosmetics/Themes.test.tsx index 9e769f74b..42780e25a 100644 --- a/src/app/features/settings/cosmetics/Themes.test.tsx +++ b/src/app/features/settings/cosmetics/Themes.test.tsx @@ -138,7 +138,7 @@ describe('Appearance settings', () => { expect(screen.getByRole('button', { name: 'GitHub Light' })).toBeInTheDocument(); expect(screen.getAllByRole('button', { name: 'Dracula' })).toHaveLength(2); expect(screen.getAllByRole('button', { name: 'Dracula' }).at(-1)).toBeDisabled(); - }); + }, 15000); it('updates the manual app and code block theme settings when system theme is disabled', () => { currentSettings = { diff --git a/src/app/features/space-settings/SpaceSettingsRenderer.tsx b/src/app/features/space-settings/SpaceSettingsRenderer.tsx index 7f5bd1e5f..d6de384e6 100644 --- a/src/app/features/space-settings/SpaceSettingsRenderer.tsx +++ b/src/app/features/space-settings/SpaceSettingsRenderer.tsx @@ -1,10 +1,15 @@ +import { lazy, Suspense } from 'react'; import { Modal500 } from '$components/Modal500'; import { useCloseSpaceSettings, useSpaceSettingsState } from '$state/hooks/spaceSettings'; import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom'; import type { SpaceSettingsState } from '$state/spaceSettings'; import { RoomProvider } from '$hooks/useRoom'; import { SpaceProvider } from '$hooks/useSpace'; -import { SpaceSettings } from './SpaceSettings'; + +const SpaceSettings = lazy(async () => { + const mod = await import('./SpaceSettings'); + return { default: mod.SpaceSettings }; +}); type RenderSettingsProps = { state: SpaceSettingsState; @@ -23,7 +28,9 @@ function RenderSettings({ state }: RenderSettingsProps) { - + + + diff --git a/src/app/features/space-settings/index.ts b/src/app/features/space-settings/index.ts deleted file mode 100644 index e01a1b268..000000000 --- a/src/app/features/space-settings/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './SpaceSettings'; -export * from './SpaceSettingsRenderer'; diff --git a/src/app/hooks/router/useSelectedSpace.test.ts b/src/app/hooks/router/useSelectedSpace.test.ts new file mode 100644 index 000000000..877506a8a --- /dev/null +++ b/src/app/hooks/router/useSelectedSpace.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { decodeSpaceIdOrAlias } from './useSelectedSpace'; + +describe('decodeSpaceIdOrAlias', () => { + it('returns undefined for empty input', () => { + expect(decodeSpaceIdOrAlias(undefined)).toBeUndefined(); + }); + + it('decodes encoded values', () => { + expect(decodeSpaceIdOrAlias('%21space%3Aexample.org')).toBe('!space:example.org'); + }); + + it('returns stable cached value for repeated input', () => { + const encoded = '%23space%3Aexample.org'; + const first = decodeSpaceIdOrAlias(encoded); + const second = decodeSpaceIdOrAlias(encoded); + + expect(first).toBe('#space:example.org'); + expect(second).toBe(first); + }); +}); diff --git a/src/app/hooks/router/useSelectedSpace.ts b/src/app/hooks/router/useSelectedSpace.ts index 33bbe4308..f593cf02e 100644 --- a/src/app/hooks/router/useSelectedSpace.ts +++ b/src/app/hooks/router/useSelectedSpace.ts @@ -3,11 +3,33 @@ import { getCanonicalAliasRoomId, isRoomAlias } from '$utils/matrix'; import { getSpaceLobbyPath, getSpaceSearchPath } from '$pages/pathUtils'; import { useMatrixClient } from '$hooks/useMatrixClient'; +const DECODED_SPACE_PARAM_CACHE_MAX = 128; +const decodedSpaceParamCache = new Map(); + +export const decodeSpaceIdOrAlias = (encoded?: string): string | undefined => { + if (!encoded) return undefined; + + const cached = decodedSpaceParamCache.get(encoded); + if (cached !== undefined) return cached; + + const decoded = decodeURIComponent(encoded); + decodedSpaceParamCache.set(encoded, decoded); + + if (decodedSpaceParamCache.size > DECODED_SPACE_PARAM_CACHE_MAX) { + const firstKey = decodedSpaceParamCache.keys().next().value; + if (firstKey !== undefined) { + decodedSpaceParamCache.delete(firstKey); + } + } + + return decoded; +}; + export const useSelectedSpace = (): string | undefined => { const mx = useMatrixClient(); const { spaceIdOrAlias: encodedSpaceIdOrAlias } = useParams(); - const spaceIdOrAlias = encodedSpaceIdOrAlias && decodeURIComponent(encodedSpaceIdOrAlias); + const spaceIdOrAlias = decodeSpaceIdOrAlias(encodedSpaceIdOrAlias); const spaceId = spaceIdOrAlias && isRoomAlias(spaceIdOrAlias) diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index 9609dafc0..f0b9e8677 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -45,9 +45,11 @@ export function getProcessedRowIndexForRawTimelineIndex( startRawIndex: number ): { rowIndex: number; focusRawIndex: number } | undefined { if (startRawIndex < 0) return undefined; - for (let i = startRawIndex; i >= 0; i -= 1) { - const rowIndex = processedEvents.findIndex((e) => e.itemIndex === i); - if (rowIndex >= 0) return { rowIndex, focusRawIndex: i }; + for (let rowIndex = processedEvents.length - 1; rowIndex >= 0; rowIndex -= 1) { + const event = processedEvents[rowIndex]; + if (event && event.itemIndex <= startRawIndex) { + return { rowIndex, focusRawIndex: event.itemIndex }; + } } return undefined; } diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index b9d253c6a..b0fdcd1f5 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -2,7 +2,7 @@ import { EventEmitter } from 'events'; import { act, renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { Room } from '$types/matrix-sdk'; -import { RoomEvent } from '$types/matrix-sdk'; +import { Direction, RoomEvent } from '$types/matrix-sdk'; import { useTimelineSync } from './useTimelineSync'; vi.mock('@sentry/react', () => ({ @@ -17,8 +17,8 @@ vi.mock('@sentry/react', () => ({ type FakeTimeline = { getEvents: () => unknown[]; - getNeighbouringTimeline: () => undefined; - getPaginationToken: () => undefined; + getNeighbouringTimeline: (direction: Direction) => FakeTimeline | undefined; + getPaginationToken: (direction: Direction) => string | undefined; getRoomId: () => string; }; @@ -32,25 +32,31 @@ type FakeRoom = Room & emit: EventEmitter['emit']; }; -function createTimeline(events: unknown[] = [{}]): FakeTimeline { +function createTimeline( + events: unknown[] = [{}], + paginationToken?: string, + neighbour?: FakeTimeline +): FakeTimeline { return { getEvents: () => events, - getNeighbouringTimeline: () => undefined, - getPaginationToken: () => undefined, + getNeighbouringTimeline: (direction: Direction) => + direction === Direction.Forward ? neighbour : undefined, + getPaginationToken: () => paginationToken, getRoomId: () => '!room:test', }; } function createRoom( roomId = '!room:test', - events: unknown[] = [{}] + events: unknown[] = [{}], + paginationToken?: string ): { room: FakeRoom; timelineSet: FakeTimelineSet; events: unknown[]; } { const timeline = { - ...createTimeline(events), + ...createTimeline(events, paginationToken), getRoomId: () => roomId, }; const timelineSet = new EventEmitter() as FakeTimelineSet; @@ -238,4 +244,67 @@ describe('useTimelineSync', () => { expect(result.current.timeline.linkedTimelines[0]).toBe(roomOne.timelineSet.getLiveTimeline()); }); + + it('does not auto-scroll to bottom while a jump focus item is active', async () => { + const { room, events } = createRoom(); + const scrollToBottom = vi.fn<() => void>(); + + const { result } = renderHook(() => + useTimelineSync({ + room: room as Room, + mx: { getUserId: () => '@alice:test' } as never, + isAtBottom: true, + isAtBottomRef: { current: true }, + scrollToBottom, + unreadInfo: undefined, + setUnreadInfo: vi.fn<() => void>(), + hideReadsRef: { current: false }, + readUptoEventIdRef: { current: undefined }, + }) + ); + + await act(async () => { + result.current.setFocusItem({ index: 0, scrollTo: true, highlight: true }); + await Promise.resolve(); + }); + + await act(async () => { + events.push({}); + room.emit(RoomEvent.LocalEchoUpdated, {}, room); + await Promise.resolve(); + }); + + expect(scrollToBottom).not.toHaveBeenCalled(); + }); + + it('does not recursively paginate sparse pages from a single pagination request', async () => { + const { room, events } = createRoom('!room:test', [{}], 'token'); + const paginateEventTimeline = vi.fn<() => Promise>(async () => { + events.push({}); + }); + + const { result } = renderHook(() => + useTimelineSync({ + room: room as Room, + mx: { + getUserId: () => '@alice:test', + paginateEventTimeline, + getRoom: () => null, + } as never, + isAtBottom: false, + isAtBottomRef: { current: false }, + scrollToBottom: vi.fn<() => void>(), + unreadInfo: undefined, + setUnreadInfo: vi.fn<() => void>(), + hideReadsRef: { current: false }, + readUptoEventIdRef: { current: undefined }, + }) + ); + + await act(async () => { + await result.current.handleTimelinePagination(true); + }); + + expect(paginateEventTimeline).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index c10b762b8..503ad7886 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -121,7 +121,7 @@ const useTimelinePagination = ( setTimeline(() => ({ linkedTimelines: newLTimelines })); }; - return async (backwards: boolean) => { + return async (backwards: boolean, limitOverride?: number) => { const directionKey = backwards ? 'backward' : 'forward'; if (fetchingRef.current[directionKey]) return; @@ -147,17 +147,15 @@ const useTimelinePagination = ( (backwards ? setBackwardStatus : setForwardStatus)('loading'); } - // `continuing` tracks whether we hand the fetchingRef lock to a recursive - // continuation call below. The finally block must NOT reset the lock if - // the recursive call has already claimed it, otherwise there is a brief - // window where fetchingRef is false while the recursive paginate is in - // flight, allowing a third overlapping call to start on sparse pages. - let continuing = false; + const requestLimit = + typeof limitOverride === 'number' && Number.isFinite(limitOverride) + ? Math.max(10, Math.min(300, Math.floor(limitOverride))) + : limit; try { - const countBefore = getTimelinesEventsCount(lTimelines); - - const [err] = await to(mx.paginateEventTimeline(timelineToPaginate, { backwards, limit })); + const [err] = await to( + mx.paginateEventTimeline(timelineToPaginate, { backwards, limit: requestLimit }) + ); if (err) { if (alive()) { @@ -189,38 +187,9 @@ const useTimelinePagination = ( if (!firstTimeline) return; recalibratePagination(freshLTimelines); (backwards ? setBackwardStatus : setForwardStatus)('idle'); - - const countAfter = getTimelinesEventsCount(getLinkedTimelines(firstTimeline)); - const fetched = countAfter - countBefore; - - if (fetched > 0 && fetched < 5) { - const checkTimeline = backwards - ? freshLTimelines[0] - : freshLTimelines[freshLTimelines.length - 1]; - if (!checkTimeline) return; - const checkDirection = backwards ? Direction.Backward : Direction.Forward; - const stillHasToken = - typeof getLinkedTimelines(checkTimeline)[0]?.getPaginationToken(checkDirection) === - 'string'; - if (stillHasToken) { - // Release lock so inner paginate can claim it, then mark continuing - // so the finally block below does NOT reset it after inner claims. - fetchingRef.current[directionKey] = false; - continuing = true; - paginate(backwards); - // At this point the inner paginate has synchronously set - // fetchingRef.current[directionKey] = true before hitting its own - // await. The finally below will skip the reset. - } - } } } finally { - // Only release the lock if we did NOT hand it to a recursive continuation. - // If `continuing` is true the recursive call owns the lock and will release - // it in its own finally block. - if (!continuing) { - fetchingRef.current[directionKey] = false; - } + fetchingRef.current[directionKey] = false; } }; }, [mx, alive, setTimeline, limit, setBackwardStatus, setForwardStatus]); @@ -447,7 +416,6 @@ export function useTimelineSync({ useCallback( (evtId, lTimelines, evtAbsIndex) => { if (!alive()) return; - setTimeline({ linkedTimelines: lTimelines }); setFocusItem({ @@ -474,6 +442,9 @@ export function useTimelineSync({ room, useCallback( (mEvt: MatrixEvent) => { + if (focusItem?.scrollTo) { + return; + } const { threadRootId } = mEvt; if (threadRootId !== undefined) return; @@ -501,7 +472,16 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } }, - [mx, room, isAtBottomRef, unreadInfo, scrollToBottom, setUnreadInfo, hideReadsRef] + [ + focusItem?.scrollTo, + mx, + room, + isAtBottomRef, + unreadInfo, + scrollToBottom, + setUnreadInfo, + hideReadsRef, + ] ) ); @@ -547,6 +527,9 @@ export function useTimelineSync({ ); useEffect(() => { + if (focusItem?.scrollTo) { + return; + } const resetAutoScrollPending = resetAutoScrollPendingRef.current; if (resetAutoScrollPending) resetAutoScrollPendingRef.current = false; @@ -565,7 +548,14 @@ export function useTimelineSync({ lastScrolledAtEventsLengthRef.current = eventsLength; scrollToBottom('instant'); - }, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]); + }, [ + focusItem?.scrollTo, + isAtBottom, + liveTimelineLinked, + eventsLength, + scrollToBottom, + room.roomId, + ]); useEffect(() => { if (eventId) return; diff --git a/src/app/hooks/useGroupDMMembers.ts b/src/app/hooks/useGroupDMMembers.ts index f4369fe70..0e1787cf2 100644 --- a/src/app/hooks/useGroupDMMembers.ts +++ b/src/app/hooks/useGroupDMMembers.ts @@ -27,94 +27,80 @@ const isBridgeBot = (userId: string): boolean => { export const useGroupDMMembers = ( mx: MatrixClient, room: Room, - maxMembers = 3 + maxMembers = 3, + enabled = true ): GroupMemberInfo[] => { const [members, setMembers] = useState([]); useEffect(() => { + if (!enabled) { + setMembers([]); + return () => {}; + } + + let disposed = false; + + const collectMembers = () => { + const currentUserId = mx.getUserId(); + const allMembers = room.getMembers(); + + const timeline = room.getLiveTimeline(); + const events = timeline.getEvents(); + const recentSenderOrder = new Map(); + + for (let i = events.length - 1; i >= 0; i -= 1) { + const sender = events[i]?.getSender(); + if ( + sender && + sender !== currentUserId && + !isBridgeBot(sender) && + !recentSenderOrder.has(sender) + ) { + recentSenderOrder.set(sender, recentSenderOrder.size); + } + } + + return allMembers + .filter( + (m) => m.membership === 'join' && m.userId !== currentUserId && !isBridgeBot(m.userId) + ) + .toSorted((a, b) => { + const aIndex = recentSenderOrder.get(a.userId); + const bIndex = recentSenderOrder.get(b.userId); + + if (aIndex !== undefined && bIndex !== undefined) return aIndex - bIndex; + if (aIndex !== undefined) return -1; + if (bIndex !== undefined) return 1; + return 0; + }) + .slice(0, maxMembers) + .map((member) => ({ + userId: member.userId, + displayName: member.name || member.userId, + avatarUrl: member.getMxcAvatarUrl() ?? undefined, + })); + }; + const fetchMembers = async () => { try { - const currentUserId = mx.getUserId(); + setMembers(collectMembers()); - // Load members from server if needed (handles lazy-loading) + // Load members from server if needed (handles lazy-loading), then refresh + // with fuller local room-state data without blocking the first paint. await room.loadMembersIfNeeded(); - // Now get all members - const allMembers = room.getMembers(); - - const allUserIds = allMembers - .filter( - (m) => m.membership === 'join' && m.userId !== currentUserId && !isBridgeBot(m.userId) - ) - .map((m) => m.userId); - - // Get last message senders from timeline for sorting - const timeline = room.getLiveTimeline(); - const events = timeline.getEvents(); - - // Extract senders in reverse chronological order (most recent first) - const recentSenders: string[] = []; - for (let i = events.length - 1; i >= 0; i -= 1) { - const evt = events[i]; - if (!evt) continue; - const sender = evt.getSender(); - if ( - sender && - sender !== currentUserId && - !isBridgeBot(sender) && - !recentSenders.includes(sender) - ) { - recentSenders.push(sender); - } - } - - // Sort allUserIds by who appears first in recentSenders - const sortedUserIds = allUserIds.toSorted((a, b) => { - const aIndex = recentSenders.indexOf(a); - const bIndex = recentSenders.indexOf(b); - - // If both are in recent senders, sort by recency - if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex; - // If only a is in recent senders, it comes first - if (aIndex !== -1) return -1; - // If only b is in recent senders, it comes first - if (bIndex !== -1) return 1; - // Neither in recent senders, maintain original order - return 0; - }); - - // Slice to max members - const limitedUserIds = sortedUserIds.slice(0, maxMembers); - - // Fetch profiles for each user - const memberPromises = limitedUserIds.map(async (userId) => { - try { - const profile = await mx.getProfileInfo(userId); - return { - userId, - displayName: profile.displayname || userId, - avatarUrl: profile.avatar_url, - }; - } catch { - // If profile fetch fails, return basic info - return { - userId, - displayName: userId, - avatarUrl: undefined, - }; - } - }); - - const fetchedMembers = await Promise.all(memberPromises); - setMembers(fetchedMembers); + if (!disposed) setMembers(collectMembers()); } catch { // If fetching fails, set empty array - setMembers([]); + if (!disposed) setMembers([]); } }; fetchMembers(); - }, [mx, room, maxMembers]); + return () => { + disposed = true; + }; + }, [mx, room, maxMembers, enabled]); return members; }; diff --git a/src/app/hooks/useRoomAbbreviations.ts b/src/app/hooks/useRoomAbbreviations.ts index b6bd30ba1..0e0029a1d 100644 --- a/src/app/hooks/useRoomAbbreviations.ts +++ b/src/app/hooks/useRoomAbbreviations.ts @@ -4,7 +4,7 @@ import type { Room } from '$types/matrix-sdk'; import type { RoomAbbreviationsContent } from '$utils/abbreviations'; import { buildAbbreviationsMap } from '$utils/abbreviations'; -import { getAllParents, getStateEvent } from '$utils/room'; +import { getAllParents, getStateEvent, hasRecursiveParent } from '$utils/room'; import { roomToParentsAtom } from '$state/room/roomToParents'; import { useMatrixClient } from './useMatrixClient'; import { useStateEvent } from './useStateEvent'; @@ -46,7 +46,7 @@ export const useMergedAbbreviations = (room: Room): Map => { if (!eventRoomId) return; if ( eventRoomId === room.roomId || - getAllParents(roomToParents, room.roomId).has(eventRoomId) + hasRecursiveParent(roomToParents, room.roomId, eventRoomId) ) { forceUpdate(); } diff --git a/src/app/hooks/useRoomMembers.ts b/src/app/hooks/useRoomMembers.ts index 46640040e..d36633aee 100644 --- a/src/app/hooks/useRoomMembers.ts +++ b/src/app/hooks/useRoomMembers.ts @@ -2,10 +2,15 @@ import type { MatrixClient, MatrixEvent, RoomMember } from '$types/matrix-sdk'; import { EventType, RoomMemberEvent, RoomStateEvent } from '$types/matrix-sdk'; import { useEffect, useState } from 'react'; -export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] => { +export const useRoomMembers = (mx: MatrixClient, roomId: string, enabled = true): RoomMember[] => { const [members, setMembers] = useState([]); useEffect(() => { + if (!enabled) { + setMembers([]); + return () => {}; + } + const room = mx.getRoom(roomId); let loadingMembers = true; let disposed = false; @@ -40,7 +45,7 @@ export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] = mx.removeListener(RoomMemberEvent.PowerLevel, updateMemberList); mx.removeListener(RoomStateEvent.Events, handleStateEvent); }; - }, [mx, roomId]); + }, [mx, roomId, enabled]); return members; }; diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index 6c4ebcec7..639849271 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -7,10 +7,11 @@ import { useInfiniteQuery } from '@tanstack/react-query'; import type { MSpaceChildContent } from '$types/matrix/room'; import { roomToParentsAtom } from '$state/room/roomToParents'; -import { getAllParents, getStateEvents, isValidChild } from '$utils/room'; +import { getStateEvents, hasRecursiveParent, isValidChild } from '$utils/room'; import { isRoomId } from '$utils/matrix'; import type { SortFunc } from '$utils/sort'; import { byOrderKey, byTsOldToNew, factoryRoomIdByActivity } from '$utils/sort'; +import { createLogger, isDebug } from '$utils/debug'; import { useMatrixClient } from './useMatrixClient'; import { makeLobbyCategoryId } from '../state/closedLobbyCategories'; import { useStateEventCallback } from './useStateEventCallback'; @@ -44,6 +45,25 @@ const hierarchyItemByOrder: SortFunc = (a, b) => const childEventTs: SortFunc = (a, b) => byTsOldToNew(a.getTs(), b.getTs()); const childEventByOrder: SortFunc = (a, b) => byOrderKey(a.getContent().order, b.getContent().order); +const hierarchyItemOrderThenTs: SortFunc = (a, b) => + hierarchyItemByOrder(a, b) || hierarchyItemTs(a, b); +const childEventOrderThenTs: SortFunc = (a, b) => + childEventByOrder(a, b) || childEventTs(a, b); +const spaceHierarchyProfiler = createLogger('space-hierarchy-profiler'); + +const profileHierarchyBuild = (label: string, build: () => T): T => { + if (!isDebug()) return build(); + + const start = performance.now(); + const result = build(); + const elapsed = performance.now() - start; + + if (elapsed >= 8) { + spaceHierarchyProfiler.log(`${label} took ${elapsed.toFixed(2)}ms`); + } + + return result; +}; const getHierarchySpaces = ( rootSpaceId: string, @@ -87,8 +107,7 @@ const getHierarchySpaces = ( // cache which we maintain as we load summary in UI. return getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId); }) - .toSorted(childEventTs) - .toSorted(childEventByOrder); + .toSorted(childEventOrderThenTs); childEvents.forEach((childEvent) => { const childId = childEvent.getStateKey(); @@ -155,7 +174,7 @@ const getSpaceHierarchy = ( return { space: spaceItem, - rooms: childItems.toSorted(hierarchyItemTs).toSorted(hierarchyItemByOrder), + rooms: childItems.toSorted(hierarchyItemOrderThenTs), }; }); @@ -173,12 +192,20 @@ export const useSpaceHierarchy = ( const roomToParents = useAtomValue(roomToParentsAtom); const [hierarchyAtom] = useState(() => - atom(getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory)) + atom( + profileHierarchyBuild('space-hierarchy:init', () => + getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory) + ) + ) ); const [hierarchy, setHierarchy] = useAtom(hierarchyAtom); useEffect(() => { - setHierarchy(getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory)); + setHierarchy( + profileHierarchyBuild('space-hierarchy:effect', () => + getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory) + ) + ); }, [mx, spaceId, spaceRooms, setHierarchy, getRoom, closedCategory, excludeRoom]); useStateEventCallback( @@ -189,9 +216,11 @@ export const useSpaceHierarchy = ( const eventRoomId = mEvent.getRoomId(); if (!eventRoomId) return; - if (spaceId === eventRoomId || getAllParents(roomToParents, eventRoomId).has(spaceId)) { + if (spaceId === eventRoomId || hasRecursiveParent(roomToParents, eventRoomId, spaceId)) { setHierarchy( - getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory) + profileHierarchyBuild('space-hierarchy:event', () => + getSpaceHierarchy(spaceId, spaceRooms, getRoom, excludeRoom, closedCategory) + ) ); } }, @@ -214,6 +243,7 @@ const getSpaceJoinedHierarchy = ( excludeRoom, new Set() ); + const containsRoomCache = new Map(); /** * Recursively checks if the given space or any of its descendants contain non-space rooms. @@ -223,16 +253,22 @@ const getSpaceJoinedHierarchy = ( * @returns True if the space or any descendant contains non-space rooms. */ const getContainsRoom = (spaceId: string, visited: Set = new Set()) => { + const cached = containsRoomCache.get(spaceId); + if (cached !== undefined) return cached; + // Prevent infinite recursion if (visited.has(spaceId)) return false; visited.add(spaceId); const space = getRoom(spaceId); - if (!space) return false; + if (!space) { + containsRoomCache.set(spaceId, false); + return false; + } const childEvents = getStateEvents(space, EventType.SpaceChild); - return childEvents.some((childEvent): boolean => { + const contains = childEvents.some((childEvent): boolean => { if (!isValidChild(childEvent)) return false; const childId = childEvent.getStateKey(); if (!childId || !isRoomId(childId)) return false; @@ -242,6 +278,9 @@ const getSpaceJoinedHierarchy = ( if (!room.isSpaceRoom()) return true; return getContainsRoom(childId, visited); }); + visited.delete(spaceId); + containsRoomCache.set(spaceId, contains); + return contains; }; const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => { @@ -298,18 +337,26 @@ export const useSpaceJoinedHierarchy = ( items.sort((a, b) => factoryRoomIdByActivity(mx)(a.roomId, b.roomId)); return items; } - return items.toSorted(hierarchyItemTs).toSorted(hierarchyItemByOrder); + return items.toSorted(hierarchyItemOrderThenTs); }, [mx, sortByActivity] ); const [hierarchyAtom] = useState(() => - atom(getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems)) + atom( + profileHierarchyBuild('space-joined-hierarchy:init', () => + getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems) + ) + ) ); const [hierarchy, setHierarchy] = useAtom(hierarchyAtom); useEffect(() => { - setHierarchy(getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems)); + setHierarchy( + profileHierarchyBuild('space-joined-hierarchy:effect', () => + getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems) + ) + ); }, [mx, spaceId, setHierarchy, getRoom, excludeRoom, sortRoomItems]); useStateEventCallback( @@ -320,8 +367,12 @@ export const useSpaceJoinedHierarchy = ( const eventRoomId = mEvent.getRoomId(); if (!eventRoomId) return; - if (spaceId === eventRoomId || getAllParents(roomToParents, eventRoomId).has(spaceId)) { - setHierarchy(getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems)); + if (spaceId === eventRoomId || hasRecursiveParent(roomToParents, eventRoomId, spaceId)) { + setHierarchy( + profileHierarchyBuild('space-joined-hierarchy:event', () => + getSpaceJoinedHierarchy(spaceId, getRoom, excludeRoom, sortRoomItems) + ) + ); } }, [spaceId, roomToParents, setHierarchy, getRoom, excludeRoom, sortRoomItems] diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index e4bf0d773..cbaf3523d 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useRef } from 'react'; +import { lazy, Suspense, useMemo, useRef } from 'react'; import { Provider as JotaiProvider } from 'jotai'; import { createStore } from 'jotai/vanilla'; import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds'; @@ -39,12 +39,13 @@ function BootstrappedAppShell({ clientConfig, screenSize }: BootstrappedAppShell } bootstrapSettingsStore(jotaiStoreRef.current, clientConfig.settingsDefaults); const reactQueryDevtoolsEnabled = isReactQueryDevtoolsEnabled(); + const router = useMemo(() => createRouter(clientConfig, screenSize), [clientConfig, screenSize]); return ( - + {reactQueryDevtoolsEnabled && ( diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index aec62cc86..82bff3949 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from 'react'; import { Outlet, Route, @@ -10,27 +11,25 @@ import * as Sentry from '@sentry/react'; import type { ClientConfig } from '$hooks/useClientConfig'; import { ErrorPage } from '$components/DefaultErrorPage'; -import { SettingsRoute } from '$features/settings'; -import { SettingsShallowRouteRenderer } from '$features/settings/SettingsShallowRouteRenderer'; import { Room } from '$features/room'; -import { Lobby } from '$features/lobby'; import { PageRoot } from '$components/page'; import { ScreenSize } from '$hooks/useScreenSize'; import { ReceiveSelfDeviceVerification } from '$components/DeviceVerification'; import { AutoRestoreBackupOnVerification } from '$components/BackupRestore'; -import { RoomSettingsRenderer } from '$features/room-settings'; -import { SpaceSettingsRenderer } from '$features/space-settings'; -import { UserRoomProfileRenderer } from '$components/UserRoomProfileRenderer'; -import { CreateRoomModalRenderer } from '$features/create-room'; -import { CreateSpaceModalRenderer } from '$features/create-space'; -import { BugReportModalRenderer } from '$features/bug-report'; import type { Sessions } from '$state/sessions'; import { getFallbackSession, MATRIX_SESSIONS_KEY } from '$state/sessions'; import { getLocalStorageItem } from '$state/utils/atomWithLocalStorage'; import { NotificationJumper } from '$hooks/useNotificationJumper'; -import { SearchModalRenderer } from '$features/search'; import { GlobalKeyboardShortcuts } from '$components/GlobalKeyboardShortcuts'; import { CallEmbedProvider } from '$components/CallEmbedProvider'; +import { SearchModalRenderer } from '$features/search/SearchModalRenderer'; +import { UserRoomProfileRenderer } from '$components/UserRoomProfileRenderer'; +import { CreateRoomModalRenderer } from '$features/create-room/CreateRoomModal'; +import { CreateSpaceModalRenderer } from '$features/create-space/CreateSpaceModal'; +import { BugReportModalRenderer } from '$features/bug-report/BugReportModalRenderer'; +import { SettingsShallowRouteRenderer } from '$features/settings/SettingsShallowRouteRenderer'; +import { RoomSettingsRenderer } from '$features/room-settings/RoomSettingsRenderer'; +import { SpaceSettingsRenderer } from '$features/space-settings/SpaceSettingsRenderer'; import { AuthLayout, Login, Register, ResetPassword } from './auth'; import { DIRECT_PATH, @@ -68,8 +67,6 @@ import { HandleNotificationClick, ClientNonUIFeatures } from './client/ClientNon import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; -import { Explore, FeaturedRooms, PublicRooms } from './client/explore'; -import { Notifications, Inbox, Invites } from './client/inbox'; import { setAfterLoginRedirectPath } from './afterLoginRedirectPath'; import { WelcomePage } from './client/WelcomePage'; import { SidebarNav } from './client/SidebarNav'; @@ -77,10 +74,54 @@ import { MobileFriendlyPageNav, MobileFriendlyClientNav } from './MobileFriendly import { ClientInitStorageAtom } from './client/ClientInitStorageAtom'; import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager'; import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotificationPreferences'; -import { HomeCreateRoom } from './client/home/CreateRoom'; -import { Create } from './client/create'; -import { ToRoomEvent } from './client/ToRoomEvent'; import { CallStatusRenderer } from './CallStatusRenderer'; +import { ConfigConfigLoading } from './ConfigConfig'; + +const SettingsRoute = lazy(async () => { + const mod = await import('$features/settings/SettingsRoute'); + return { default: mod.SettingsRoute }; +}); +const Lobby = lazy(async () => { + const mod = await import('$features/lobby/Lobby'); + return { default: mod.Lobby }; +}); +const Explore = lazy(async () => { + const mod = await import('./client/explore/Explore'); + return { default: mod.Explore }; +}); +const FeaturedRooms = lazy(async () => { + const mod = await import('./client/explore/Featured'); + return { default: mod.FeaturedRooms }; +}); +const PublicRooms = lazy(async () => { + const mod = await import('./client/explore/Server'); + return { default: mod.PublicRooms }; +}); +const Inbox = lazy(async () => { + const mod = await import('./client/inbox/Inbox'); + return { default: mod.Inbox }; +}); +const Notifications = lazy(async () => { + const mod = await import('./client/inbox/Notifications'); + return { default: mod.Notifications }; +}); +const Invites = lazy(async () => { + const mod = await import('./client/inbox/Invites'); + return { default: mod.Invites }; +}); +const Create = lazy(async () => { + const mod = await import('./client/create/Create'); + return { default: mod.Create }; +}); +const ToRoomEvent = lazy(async () => { + const mod = await import('./client/ToRoomEvent'); + return { default: mod.ToRoomEvent }; +}); +const HomeCreateRoom = lazy(async () => { + const mod = await import('./client/home/CreateRoom'); + return { default: mod.HomeCreateRoom }; +}); +const routeFallback = ; /** * Returns true if there is at least one stored session. @@ -190,14 +231,30 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + {/* Screen reader live region — populated by announce() in utils/announce.ts */}
{mobile ? null : } />} - } /> + + + + } + /> join

} /> } /> } /> )} - } /> + + + + } + /> } /> - + + + } > @@ -341,18 +414,48 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) element={} /> )} - } /> - } /> + + + + } + /> + + + + } + /> - } /> - } /> + + + + } + /> + + + + } + /> - + + + } > @@ -367,10 +470,31 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) element={} /> )} - } /> - } /> + + + + } + /> + + + + } + /> - } /> + + + + } + /> Page not found

} />
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index b089cf8f8..82a090ae6 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -1,104 +1,49 @@ import { useAtomValue, useSetAtom } from 'jotai'; import * as Sentry from '@sentry/react'; -import type { ReactNode } from 'react'; -import { useCallback, useEffect, useRef } from 'react'; +import { lazy, Suspense, type ReactNode, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import type { RoomEventHandlerMap } from '$types/matrix-sdk'; -import { - MatrixEvent, - MatrixEventEvent, - PushProcessor, - RoomEvent, - SetPresence, - SyncState, - EventType, -} from '$types/matrix-sdk'; -import parse from 'html-react-parser'; -import { getReactCustomHtmlParser, LINKIFY_OPTS } from '$plugins/react-custom-html-parser'; -import { sanitizeCustomHtml } from '$utils/sanitize'; +import { SetPresence } from '$types/matrix-sdk'; import { roomToUnreadAtom } from '$state/room/roomToUnread'; import LogoSVG from '$public/res/svg/cinny-logo.svg'; import LogoUnreadSVG from '$public/res/svg/cinny-unread.svg'; import LogoHighlightSVG from '$public/res/svg/cinny-highlight.svg'; -import NotificationSound from '$public/sound/notification.ogg'; -import InviteSound from '$public/sound/invite.ogg'; -import { notificationPermission, setFavicon } from '$utils/dom'; +import { setFavicon } from '$utils/dom'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; -import { nicknamesAtom } from '$state/nicknames'; import { mDirectAtom } from '$state/mDirectList'; -import { allInvitesAtom } from '$state/room-list/inviteList'; -import { usePreviousValue } from '$hooks/usePreviousValue'; import { useMatrixClient } from '$hooks/useMatrixClient'; -import { - getMemberDisplayName, - getNotificationType, - getStateEvent, - isDMRoom, - isNotificationEvent, -} from '$utils/room'; -import { NotificationType } from '$types/matrix/room'; -import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix'; -import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; -import { useInboxNotificationsSelected } from '$hooks/router/useInbox'; -import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; -import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { registrationAtom } from '$state/serviceWorkerRegistration'; -import { pendingNotificationAtom, inAppBannerAtom, activeSessionIdAtom } from '$state/sessions'; -import { - buildRoomMessageNotification, - resolveNotificationPreviewText, -} from '$utils/notificationStyle'; -import { mobileOrTablet } from '$utils/user-agent'; -import { createDebugLogger } from '$utils/debugLogger'; +import { pendingNotificationAtom, activeSessionIdAtom } from '$state/sessions'; import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom'; import { getSlidingSyncManager } from '$client/initMatrix'; -import { NotificationBanner } from '$components/notification-banner'; -import { ThemeMigrationBanner } from '$components/theme/ThemeMigrationBanner'; -import { TelemetryConsentBanner } from '$components/telemetry-consent'; import { useCallSignaling } from '$hooks/useCallSignaling'; -import { getBlobCacheStats } from '$hooks/useBlobCache'; import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { useSettingsSyncEffect } from '$hooks/useSettingsSync'; import { getInboxInvitesPath } from '../pathUtils'; -import { BackgroundNotifications } from './BackgroundNotifications'; - -const pushRelayLog = createDebugLogger('push-relay'); - -function clearMediaSessionQuickly(): void { - if (!('mediaSession' in navigator)) return; - // iOS registers the lock screen media player as a side-effect of - // HTMLAudioElement.play(). We delay slightly so iOS has finished updating - // the media session before we clear it — clearing too early is a no-op. - // We only clear if no real in-app media (video/audio in a room) has since - // registered meaningful metadata; if it has, leave it alone. - setTimeout(() => { - if (navigator.mediaSession.metadata !== null) return; - navigator.mediaSession.playbackState = 'none'; - }, 500); -} +import { scheduleDeferredFeatureMount } from './scheduleDeferredFeatureMount'; + +const DeferredNotificationFeatures = lazy(async () => { + const mod = await import('./DeferredNotificationFeatures'); + return { default: mod.DeferredNotificationFeatures }; +}); function SystemEmojiFeature() { const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); - if (twitterEmoji) { document.documentElement.style.setProperty('--font-emoji', 'Twemoji'); } else { document.documentElement.style.setProperty('--font-emoji', 'Twemoji_DISABLED'); } - return null; } function PageZoomFeature() { const [pageZoom] = useSetting(settingsAtom, 'pageZoom'); - if (pageZoom === 100) { document.documentElement.style.removeProperty('font-size'); } else { document.documentElement.style.setProperty('font-size', `calc(1em * ${pageZoom / 100})`); } - return null; } @@ -118,490 +63,38 @@ function FaviconUpdater() { total += unread.total; highlightTotal += unread.highlight; } - if (unread.total > 0) { - notification = true; - } - if (unread.highlight > 0) { - highlight = true; - } + if (unread.total > 0) notification = true; + if (unread.highlight > 0) highlight = true; }); - if (highlight) { - setFavicon(LogoHighlightSVG); - } else if (!faviconForMentionsOnly && notification) { - setFavicon(LogoUnreadSVG); - } else { - setFavicon(LogoSVG); - } + if (highlight) setFavicon(LogoHighlightSVG); + else if (!faviconForMentionsOnly && notification) setFavicon(LogoUnreadSVG); + else setFavicon(LogoSVG); + try { - // Only badge with highlight (mention) counts — total unread is too noisy - // for an OS-level app badge. - if (highlightTotal > 0) { - navigator.setAppBadge(highlightTotal); - } else { - navigator.clearAppBadge(); - } + if (highlightTotal > 0) navigator.setAppBadge(highlightTotal); + else navigator.clearAppBadge(); + if (usePushNotifications && registration) { if (total === 0) { - // All rooms read — clear every notification. registration.getNotifications().then((notifs) => notifs.forEach((n) => n.close())); } else { - // Dismiss notifications for individual rooms that are now fully read. registration.getNotifications().then((notifs) => { notifs.forEach((n) => { const notifRoomId = n.data?.room_id; if (!notifRoomId) return; const roomUnread = roomToUnread.get(notifRoomId); - if (!roomUnread || (roomUnread.total === 0 && roomUnread.highlight === 0)) { - n.close(); - } + if (!roomUnread || (roomUnread.total === 0 && roomUnread.highlight === 0)) n.close(); }); }); } } - } catch { - // Likely Firefox/Gecko-based and doesn't support badging API - } + } catch {} }, [roomToUnread, usePushNotifications, registration, faviconForMentionsOnly]); return null; } -function InviteNotifications() { - const audioRef = useRef(null); - const invites = useAtomValue(allInvitesAtom); - const perviousInviteLen = usePreviousValue(invites.length, 0); - const mx = useMatrixClient(); - - const navigate = useNavigate(); - const [showSystemNotifications] = useSetting(settingsAtom, 'useSystemNotifications'); - const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); - const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); - - const notify = useCallback( - (count: number) => { - const noti = new window.Notification('Invitation', { - icon: LogoSVG, - badge: LogoSVG, - body: `You have ${count} new invitation request.`, - silent: true, - }); - - noti.addEventListener('click', () => { - if (!window.closed) navigate(getInboxInvitesPath()); - noti.close(); - }); - }, - [navigate] - ); - - const playSound = useCallback(() => { - const audioElement = audioRef.current; - audioElement?.play(); - clearMediaSessionQuickly(); - }, []); - - useEffect(() => { - if (invites.length <= perviousInviteLen || mx.getSyncState() !== SyncState.Syncing) return; - - // SW push (via Sygnal) handles invite notifications when the app is backgrounded. - if (document.visibilityState !== 'visible' && usePushNotifications) return; - - // OS notification for invites — desktop only. - if (!mobileOrTablet() && showSystemNotifications && notificationPermission('granted')) { - try { - notify(invites.length - perviousInviteLen); - } catch { - // window.Notification may be unavailable in sandboxed environments. - } - } - // Audio API requires a visible document; skip when hidden. - if (document.visibilityState === 'visible' && notificationSound) { - playSound(); - } - }, [ - mx, - invites, - perviousInviteLen, - showSystemNotifications, - usePushNotifications, - notificationSound, - notify, - playSound, - ]); - - return ( - // oxlint-disable-next-line jsx-a11y/media-has-caption - - ); -} - -function MessageNotifications() { - const audioRef = useRef(null); - const notifiedEventsRef = useRef(new Set()); - // Record mount time so we can distinguish live events from historical backfill - // on sliding sync proxies that don't set num_live (which causes liveEvent=false - // for all events, including actually-new messages). - const clientStartTimeRef = useRef(Date.now()); - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const appBaseUrl = useSettingsLinkBaseUrl(); - const [showNotifications] = useSetting(settingsAtom, 'useInAppNotifications'); - const [showSystemNotifications] = useSetting(settingsAtom, 'useSystemNotifications'); - const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); - const [notificationSound] = useSetting(settingsAtom, 'isNotificationSounds'); - const [showMessageContent] = useSetting(settingsAtom, 'showMessageContentInNotifications'); - const [showEncryptedMessageContent] = useSetting( - settingsAtom, - 'showMessageContentInEncryptedNotifications' - ); - const nicknames = useAtomValue(nicknamesAtom); - const nicknamesRef = useRef(nicknames); - nicknamesRef.current = nicknames; - const mDirects = useAtomValue(mDirectAtom); - const mDirectsRef = useRef(mDirects); - mDirectsRef.current = mDirects; - - const setPending = useSetAtom(pendingNotificationAtom); - const setInAppBanner = useSetAtom(inAppBannerAtom); - const selectedRoomId = useSelectedRoom(); - const notificationSelected = useInboxNotificationsSelected(); - - const playSound = useCallback(() => { - const audioElement = audioRef.current; - audioElement?.play(); - clearMediaSessionQuickly(); - }, []); - - useEffect(() => { - const pushProcessor = new PushProcessor(mx); - // Track encrypted events that should skip focus check when decrypted (because we - // already checked focus when the encrypted event arrived, and want to use that - // original state rather than re-checking after decryption completes). - const skipFocusCheckEvents = new Set(); - // Tracks when each event first arrived so we can measure notification delivery latency - const notifyTimerMap = new Map(); - - const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = ( - mEvent, - room, - _toStartOfTimeline, - _removed, - data - ) => { - if (mx.getSyncState() !== SyncState.Syncing) return; - - const eventId = mEvent.getId(); - // Record event arrival time once per eventId (re-entry via handleDecrypted must not reset it) - if (eventId && !notifyTimerMap.has(eventId)) { - notifyTimerMap.set(eventId, performance.now()); - } - const shouldSkipFocusCheck = eventId && skipFocusCheckEvents.has(eventId); - if (!shouldSkipFocusCheck) { - if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) - return; - } - - // Older sliding sync proxies (e.g. matrix-sliding-sync) omit num_live, - // which causes every event to arrive with fromCache=true and therefore - // liveEvent=false — silently blocking all notifications. Fall back to an - // age check: treat the event as potentially live only when it was sent - // within 60 s of this component mounting (tight enough to avoid phantom - // notifications for pre-existing unread messages, generous enough for - // messages that arrived during a brief offline window). - // Additionally, skip the event if the user already has a read receipt - // covering it (message was read on another device before this session). - const isHistoricalEvent = - !data.liveEvent && - (mEvent.getTs() < clientStartTimeRef.current - 60 * 1000 || - (!!room && room.hasUserReadEvent(mx.getSafeUserId(), mEvent.getId()!))); - - // For encrypted events that haven't been decrypted yet, wait for decryption - // before processing the notification. The SDK's Timeline re-emission after - // decryption comes with data.liveEvent=false which would wrongly block it. - if (mEvent.getType() === 'm.room.encrypted' && mEvent.isEncrypted()) { - if (eventId) { - // Mark this event to skip focus check when decrypted, so we use the focus - // state from when the encrypted event originally arrived, not when it decrypts. - skipFocusCheckEvents.add(eventId); - } - - const handleDecrypted = () => { - // After decryption, run the notification logic with the decrypted event - handleTimelineEvent(mEvent, room, undefined, true, data); - // Clean up the skip-focus marker - if (eventId) { - skipFocusCheckEvents.delete(eventId); - } - }; - mEvent.once(MatrixEventEvent.Decrypted, handleDecrypted); - return; - } - - if (!room || isHistoricalEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) { - return; - } - - const notificationType = getNotificationType(mx, room.roomId); - if (notificationType === NotificationType.Mute) { - return; - } - - const sender = mEvent.getSender(); - if (!sender || !eventId || mEvent.getSender() === mx.getUserId()) return; - - // Deduplicate: don't show a second banner if this event fires twice - // (e.g., decrypted events re-emitted by the SDK). - if (notifiedEventsRef.current.has(eventId)) return; - - // Check if this is a DM using multiple signals for robustness - const isDM = isDMRoom(room, mDirectsRef.current); - - // Measure total notification delivery latency (includes decryption wait for E2EE events) - const arrivalMs = notifyTimerMap.get(eventId); - if (arrivalMs !== undefined) { - Sentry.metrics.distribution( - 'sable.notification.delivery_ms', - performance.now() - arrivalMs, - { - attributes: { - encrypted: String(mEvent.isEncrypted()), - dm: String(isDM), - }, - } - ); - notifyTimerMap.delete(eventId); - } - const pushActions = pushProcessor.actionsForEvent(mEvent); - - // For DMs with "All Messages" or "Default" notification settings: - // Always notify even if push rules fail to match due to sliding sync limitations. - // For "Mention & Keywords": respect the push rule (only notify if it matches). - const shouldForceDMNotification = - isDM && notificationType !== NotificationType.MentionsAndKeywords; - const shouldNotify = pushActions?.notify || shouldForceDMNotification; - - // If we shouldn't notify based on rules/settings, skip everything - if (!shouldNotify) return; - - const loudByRule = Boolean(pushActions.tweaks?.sound); - const isHighlightByRule = Boolean(pushActions.tweaks?.highlight); - - // With sliding sync we only load m.room.member/$ME in required_state, so - // PushProcessor cannot evaluate the room_member_count == 2 condition on - // .m.rule.room_one_to_one. That rule therefore fails to match, and DM - // messages fall through to .m.rule.message which carries no sound tweak — - // leaving loudByRule=false. Treat known DMs as inherently loud so that - // the OS notification and badge are consistent with the DM context. - const isLoud = loudByRule || isDM; - - // Record as notified to prevent duplicate banners (e.g. re-emitted decrypted events). - notifiedEventsRef.current.add(eventId); - if (notifiedEventsRef.current.size > 200) { - const first = notifiedEventsRef.current.values().next().value; - if (first) notifiedEventsRef.current.delete(first); - } - - // On desktop: fire an OS notification whenever system notifications are - // enabled and permission is granted — regardless of whether the window is - // focused. When the window is also visible the in-app banner fires too, - // mirroring the behaviour of apps like Discord. - // The whole block is wrapped in try/catch: window.Notification() can throw - // in sandboxed environments, browsers with DnD active, or Electron — and - // an uncaught exception here would abort the handler before setInAppBanner - // is reached, causing in-app notifications to silently vanish too. - if (!mobileOrTablet() && showSystemNotifications && notificationPermission('granted')) { - try { - const isEncryptedRoom = !!getStateEvent(room, EventType.RoomEncryption); - const avatarMxc = - room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); - const osPayload = buildRoomMessageNotification({ - roomName: room.name ?? 'Unknown', - roomAvatar: avatarMxc - ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) - : undefined, - username: - getMemberDisplayName(room, sender, nicknamesRef.current) ?? - getMxIdLocalPart(sender) ?? - sender, - previewText: resolveNotificationPreviewText({ - content: mEvent.getContent(), - eventType: mEvent.getType(), - isEncryptedRoom, - showMessageContent, - showEncryptedMessageContent, - }), - silent: !notificationSound || !isLoud, - eventId, - }); - const noti = new window.Notification(osPayload.title, osPayload.options); - const { roomId } = room; - noti.addEventListener('click', () => { - window.focus(); - setPending({ - roomId, - eventId, - targetSessionId: mx.getUserId() ?? undefined, - }); - noti.close(); - }); - } catch { - // window.Notification unavailable or blocked (sandboxed context, DnD, etc.) - } - } - - // Everything below requires the page to be visible (in-app UI + audio). - if (document.visibilityState !== 'visible') return; - - // Page is visible — show the themed in-app notification banner. - // For non-DM rooms, only show banner for highlighted messages (mentions/keywords). - // For DMs, show banner for all messages. - if (showNotifications && (isHighlightByRule || isDM)) { - const avatarMxc = - room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); - const roomAvatar = avatarMxc - ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) - : undefined; - const resolvedSenderName = - getMemberDisplayName(room, sender, nicknamesRef.current) ?? - getMxIdLocalPart(sender) ?? - sender; - const content = mEvent.getContent(); - // Events reaching here are already decrypted (m.room.encrypted is skipped - // above). Pass isEncryptedRoom:false so the preview always shows the actual - // message body when showMessageContent is enabled. - const previewText = resolveNotificationPreviewText({ - content: mEvent.getContent(), - eventType: mEvent.getType(), - isEncryptedRoom: false, - showMessageContent, - showEncryptedMessageContent, - }); - - // Build a rich ReactNode body using the same HTML parser as the room - // timeline — mxc images, mention pills, linkify, spoilers, code blocks. - let bodyNode: ReactNode; - if ( - showMessageContent && - content.format === 'org.matrix.custom.html' && - content.formatted_body - ) { - const htmlParserOpts = getReactCustomHtmlParser(mx, room.roomId, { - settingsLinkBaseUrl: appBaseUrl, - linkifyOpts: LINKIFY_OPTS, - useAuthentication, - nicknames: nicknamesRef.current, - }); - bodyNode = parse(sanitizeCustomHtml(content.formatted_body), htmlParserOpts) as ReactNode; - } - - const payload = buildRoomMessageNotification({ - roomName: room.name ?? 'Unknown', - roomAvatar, - username: resolvedSenderName, - previewText, - silent: !notificationSound || !isLoud, - eventId, - }); - const { roomId } = room; - const capturedEventId = eventId; - const capturedUserId = mx.getUserId() ?? undefined; - const canonicalAlias = room.getCanonicalAlias(); - const serverName = canonicalAlias?.split(':')[1] ?? room.roomId.split(':')[1] ?? undefined; - setInAppBanner({ - id: eventId, - title: payload.title, - roomName: room.name ?? undefined, - serverName, - senderName: resolvedSenderName, - body: previewText, - bodyNode, - icon: roomAvatar, - onClick: () => { - window.focus(); - setPending({ - roomId, - eventId: capturedEventId, - targetSessionId: capturedUserId, - }); - }, - }); - } - - // In-app audio: play when notification sounds are enabled AND this notification is loud. - if (notificationSound && isLoud) { - playSound(); - } - }; - mx.on(RoomEvent.Timeline, handleTimelineEvent); - return () => { - mx.removeListener(RoomEvent.Timeline, handleTimelineEvent); - }; - }, [ - mx, - notificationSound, - notificationSelected, - showNotifications, - showSystemNotifications, - showMessageContent, - showEncryptedMessageContent, - usePushNotifications, - playSound, - setInAppBanner, - setPending, - selectedRoomId, - appBaseUrl, - useAuthentication, - ]); - - return ( - // oxlint-disable-next-line jsx-a11y/media-has-caption - - ); -} - -function PrivacyBlurFeature() { - const [blurMedia] = useSetting(settingsAtom, 'privacyBlur'); - const [blurAvatars] = useSetting(settingsAtom, 'privacyBlurAvatars'); - const [blurEmotes] = useSetting(settingsAtom, 'privacyBlurEmotes'); - - useEffect(() => { - document.body.classList.toggle('sable-blur-media', blurMedia); - document.body.classList.toggle('sable-blur-avatars', blurAvatars); - document.body.classList.toggle('sable-blur-emotes', blurEmotes); - }, [blurMedia, blurAvatars, blurEmotes]); - - return null; -} - -// Periodically emits memory-health gauges so Sentry dashboards can surface -// unbounded growth (e.g. blob cache never evicted, stale inflight requests). -function HealthMonitor() { - useEffect(() => { - const id = window.setInterval(() => { - const { cacheSize, inflightCount } = getBlobCacheStats(); - Sentry.metrics.gauge('sable.media.blob_cache_size', cacheSize); - if (inflightCount > 0) { - Sentry.metrics.gauge('sable.media.inflight_requests', inflightCount); - if (inflightCount >= 10) { - Sentry.addBreadcrumb({ - category: 'media', - message: `High inflight request count: ${inflightCount}`, - level: 'warning', - data: { inflight_count: inflightCount }, - }); - } - } - }, 60_000); - return () => window.clearInterval(id); - }, []); - return null; -} - type ClientNonUIFeaturesProps = { children: ReactNode; }; @@ -613,25 +106,20 @@ export function HandleNotificationClick() { useEffect(() => { if (!('serviceWorker' in navigator)) return undefined; - const handleMessage = (ev: MessageEvent) => { const { data } = ev; if (!data || data.type !== 'notificationClick') return; - const { userId, roomId, eventId, isInvite } = data as { userId?: string; roomId?: string; eventId?: string; isInvite?: boolean; }; - if (userId) setActiveSessionId(userId); - if (isInvite) { navigate(getInboxInvitesPath()); return; } - if (!roomId) return; setPending({ roomId, eventId, targetSessionId: userId }); }; @@ -653,15 +141,12 @@ function SyncNotificationSettingsWithServiceWorker() { useEffect(() => { if (!('serviceWorker' in navigator)) return undefined; - const postVisibility = () => { const visible = document.visibilityState === 'visible'; const msg = { type: 'setAppVisible', visible }; navigator.serviceWorker.controller?.postMessage(msg); navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg)); }; - - // Report initial visibility immediately, then track changes. postVisibility(); document.addEventListener('visibilitychange', postVisibility); return () => document.removeEventListener('visibilitychange', postVisibility); @@ -669,20 +154,14 @@ function SyncNotificationSettingsWithServiceWorker() { useEffect(() => { if (!('serviceWorker' in navigator)) return; - // notificationSoundEnabled is intentionally excluded: push notification sound - // is governed by the push rule's tweakSound alone (OS/Sygnal handles it). - // The in-app sound setting only controls the in-page