diff --git a/js/desktop/src/components/ui/scroll-area.tsx b/js/desktop/src/components/ui/scroll-area.tsx index 611a5ce..972bb92 100644 --- a/js/desktop/src/components/ui/scroll-area.tsx +++ b/js/desktop/src/components/ui/scroll-area.tsx @@ -6,8 +6,12 @@ import { cn } from '@/lib/utils'; function ScrollArea({ className, children, + viewportRef, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + /** Ref to the scrollable viewport, e.g. for scroll anchoring or observers. */ + viewportRef?: React.Ref; +}) { return ( diff --git a/js/desktop/src/features/particles/playback-page-indicator.tsx b/js/desktop/src/features/particles/playback-page-indicator.tsx index 71879a0..00ae608 100644 --- a/js/desktop/src/features/particles/playback-page-indicator.tsx +++ b/js/desktop/src/features/particles/playback-page-indicator.tsx @@ -7,58 +7,87 @@ import { import type { HumanPresence } from '@/hooks/use-presence-positions'; const MAX_VISIBLE_AVATARS = 3; -const PAGE_SIZE = 10; +const VISIBLE_SEGMENTS = 10; interface PlaybackPageIndicatorProps { - total: number; + /** Number of particles currently loaded in the window. */ + loadedCount: number; current: number; progress: number; onGoTo: (index: number) => void; presenceBySegment?: Map; /** Set of humanIds currently online in the stream channel. */ onlineHumanIds?: Set; + /** More (older) particles exist before the loaded window. */ + hasMoreOlder?: boolean; + /** Pull in older history when scrubbing past the oldest loaded segment. */ + onLoadOlder?: () => void; /** Render only avatars or only tracks. Omit to render both. */ layer?: 'avatars' | 'tracks'; } +/** + * A streaming, count-free progress scrubber. The stream is paginated, so the + * true particle count is unknown — instead this shows a sliding window of + * segments around the current position. Segments map to the loaded particles + * (newest on the right); the edge stubs scrub within the window and pull in + * older history when you reach the oldest loaded segment. + */ export function PlaybackPageIndicator({ - total, + loadedCount, current, progress, onGoTo, presenceBySegment, onlineHumanIds, + hasMoreOlder, + onLoadOlder, layer, }: PlaybackPageIndicatorProps) { - if (total === 0) return null; + if (loadedCount === 0) return null; const showAvatars = layer !== 'tracks'; const showTracks = layer !== 'avatars'; - const paginated = total > PAGE_SIZE; const safeCurrent = current < 0 ? 0 : current; - const pageStart = paginated - ? Math.floor(safeCurrent / PAGE_SIZE) * PAGE_SIZE - : 0; - const visibleCount = paginated - ? Math.min(PAGE_SIZE, total - pageStart) - : total; - const hasPrevPage = paginated && pageStart > 0; - const hasNextPage = paginated && pageStart + PAGE_SIZE < total; + // Slide the visible window so the current segment stays in view with a bit of + // context on either side, clamped to the loaded range. + const sliceStart = Math.min( + Math.max(0, safeCurrent - Math.floor(VISIBLE_SEGMENTS / 2)), + Math.max(0, loadedCount - VISIBLE_SEGMENTS), + ); + const sliceEnd = Math.min(loadedCount, sliceStart + VISIBLE_SEGMENTS); + const visibleCount = sliceEnd - sliceStart; + + const paginated = loadedCount > VISIBLE_SEGMENTS || !!hasMoreOlder; + // Older = lower indices (left); newer = higher indices (right). + const hasOlder = sliceStart > 0 || !!hasMoreOlder; + const hasNewer = sliceEnd < loadedCount; + + // Stubs jump a page at a time; reaching the oldest loaded pulls in history. + const goOlder = () => { + const target = safeCurrent - VISIBLE_SEGMENTS; + if (target >= 0) onGoTo(target); + else if (sliceStart > 0) onGoTo(0); + else if (hasMoreOlder) onLoadOlder?.(); + }; + const goNewer = () => { + onGoTo(Math.min(loadedCount - 1, safeCurrent + VISIBLE_SEGMENTS)); + }; return (
{paginated && ( onGoTo(pageStart - 1)} + onClick={goOlder} /> )}
{Array.from({ length: visibleCount }, (_, j) => { - const i = pageStart + j; + const i = sliceStart + j; const presence = presenceBySegment?.get(i); return (
@@ -100,17 +129,12 @@ export function PlaybackPageIndicator({
{paginated && ( onGoTo(pageStart + PAGE_SIZE)} + onClick={goNewer} /> )}
- {paginated && showTracks && current >= 0 && ( -
- {current + 1} / {total} -
- )}
); } diff --git a/js/desktop/src/features/particles/stream-bottom-bar.tsx b/js/desktop/src/features/particles/stream-bottom-bar.tsx index 93c6492..b8642d7 100644 --- a/js/desktop/src/features/particles/stream-bottom-bar.tsx +++ b/js/desktop/src/features/particles/stream-bottom-bar.tsx @@ -7,24 +7,28 @@ import type { HumanPresence } from '@/hooks/use-presence-positions'; export function BottomBar({ visible, - total, + loadedCount, current, progress, onGoTo, presenceBySegment, onlineHumanIds, + hasMoreOlder, + onLoadOlder, exitRemainingMs, onOpenKeybindings, onOpenHuddle, onExit, }: { visible: boolean; - total: number; + loadedCount: number; current: number; progress: number; onGoTo: (index: number) => void; presenceBySegment: Map; onlineHumanIds: Set; + hasMoreOlder: boolean; + onLoadOlder: () => void; exitRemainingMs: number | null; onOpenKeybindings: () => void; onOpenHuddle: () => void; @@ -41,21 +45,25 @@ export function BottomBar({ > {/* Presence avatars — above the blurred background */} {/* Blurred background container — tracks + controls */}
diff --git a/js/desktop/src/features/particles/stream-list-sidebar.tsx b/js/desktop/src/features/particles/stream-list-sidebar.tsx index a1da25e..b456dbf 100644 --- a/js/desktop/src/features/particles/stream-list-sidebar.tsx +++ b/js/desktop/src/features/particles/stream-list-sidebar.tsx @@ -1,5 +1,13 @@ -import { useEffect, useRef } from 'react'; -import { CircleCheck, FileText, Image, List, Mic, Video } from 'lucide-react'; +import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; +import { + CircleCheck, + FileText, + Image, + List, + Loader2, + Mic, + Video, +} from 'lucide-react'; import { isParticleDeleted, type Human, type Particle } from '@/api/types'; import { cn } from '@/lib/utils'; import { useNetwork } from '@/hooks/use-networks'; @@ -15,12 +23,18 @@ interface StreamListSidebarProps { currentIndex: number; onSelect: (index: number) => void; onToggle: () => void; + /** More (older) particles exist before the loaded window. */ + hasMoreOlder: boolean; + /** Load the next page of older particles. */ + onLoadOlder: () => void; + isLoadingOlder: boolean; } /** - * Browse-mode panel beside the stream: a chat-like timeline of every - * particle. Selecting a message plays it in the immersive stream view; - * nothing auto-advances. + * Browse-mode panel beside the stream: a chat-like timeline of the loaded + * particles. Selecting a message plays it in the immersive stream view; + * nothing auto-advances. Older history is fetched automatically as the user + * scrolls toward the top. */ export function StreamListSidebar({ items, @@ -28,22 +42,67 @@ export function StreamListSidebar({ currentIndex, onSelect, onToggle, + hasMoreOlder, + onLoadOlder, + isLoadingOlder, }: StreamListSidebarProps) { const network = useNetwork(networkId); const rowRefs = useRef>([]); + const viewportRef = useRef(null); + const sentinelRef = useRef(null); + // Scroll into view only when the selection genuinely changes — not when the + // current index shifts because older particles were prepended. + const selectedId = currentIndex >= 0 ? items[currentIndex]?.id : undefined; + const prevSelectedIdRef = useRef(undefined); useEffect(() => { - if (currentIndex >= 0) { + if (selectedId && selectedId !== prevSelectedIdRef.current) { rowRefs.current[currentIndex]?.scrollIntoView({ block: 'nearest' }); } - }, [currentIndex]); + prevSelectedIdRef.current = selectedId; + }, [selectedId, currentIndex]); + + // Anchor the viewport when older particles are prepended so the content the + // user is looking at stays put instead of jumping. + const pendingAnchorRef = useRef<{ height: number; top: number } | null>(null); + const requestOlder = useCallback(() => { + const vp = viewportRef.current; + if (!vp) return; + pendingAnchorRef.current = { height: vp.scrollHeight, top: vp.scrollTop }; + onLoadOlder(); + }, [onLoadOlder]); + + useLayoutEffect(() => { + const vp = viewportRef.current; + const anchor = pendingAnchorRef.current; + if (!vp || !anchor) return; + const delta = vp.scrollHeight - anchor.height; + if (delta > 0) vp.scrollTop = anchor.top + delta; + pendingAnchorRef.current = null; + }, [items]); + + // Auto-fetch older history when the top sentinel scrolls into view. + useEffect(() => { + const vp = viewportRef.current; + const sentinel = sentinelRef.current; + if (!vp || !sentinel || !hasMoreOlder) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && !isLoadingOlder) requestOlder(); + }, + { root: vp, rootMargin: '120px 0px 0px 0px' }, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [hasMoreOlder, isLoadingOlder, requestOlder]); return (