diff --git a/ui/src/chat/ChatScroller/ChatScroller.tsx b/ui/src/chat/ChatScroller/ChatScroller.tsx index 167da40fef..2759e3ab46 100644 --- a/ui/src/chat/ChatScroller/ChatScroller.tsx +++ b/ui/src/chat/ChatScroller/ChatScroller.tsx @@ -1,6 +1,7 @@ import { Virtualizer, useVirtualizer } from '@tanstack/react-virtual'; import { daToUnix } from '@urbit/api'; import React, { + PropsWithChildren, ReactElement, useCallback, useEffect, @@ -27,10 +28,10 @@ import { ChatMessageListItemData, useMessageData, } from '@/logic/useScrollerMessages'; -import { useChatState } from '@/state/chat/chat'; import { createDevLogger, useObjectChangeLogging } from '@/logic/utils'; import EmptyPlaceholder from '@/components/EmptyPlaceholder'; import { ChatWrit } from '@/types/chat'; +import { useChatState } from '@/state/chat'; import ChatMessage from '../ChatMessage/ChatMessage'; import ChatNotice from '../ChatNotice'; import { useChatStore } from '../useChatStore'; @@ -73,12 +74,25 @@ const ChatScrollerItem = React.memo( } ); -function Loader({ show }: { show: boolean }) { - return show ? ( -
- +function Loader({ + className, + scaleY, + children, +}: PropsWithChildren<{ className?: string; scaleY: number }>) { + return ( +
+
+
+ +
+ + {children} +
- ) : null; + ); } function useBigInt(value?: BigInteger) { @@ -132,6 +146,11 @@ const thresholds = { overscan: 6, }; +const loaderPadding = { + top: 40, + bottom: 0, +}; + export interface ChatScrollerProps { whom: string; messages: BTree; @@ -212,14 +231,6 @@ export default function ChatScroller({ }, [activeMessageKeys, activeMessageEntries, topItem]); const count = messageKeys.length; - const isEmpty = count === 0 && hasLoadedNewest && hasLoadedOldest; - const isInverted = !isEmpty && loadDirection === 'older'; - // We want to render newest messages first, but we receive them oldest-first. - // This is a simple way to reverse the order without having to reverse a big array. - const transformIndex = useCallback( - (index: number) => (isInverted ? count - 1 - index : index), - [count, isInverted] - ); const anchorIndex = useMemo(() => { if (count === 0) { @@ -246,6 +257,22 @@ export default function ChatScroller({ virt.scrollElement?.scrollTo?.({ top: offset }); }, []); + const isEmpty = count === 0 && hasLoadedNewest && hasLoadedOldest; + const contentHeight = virtualizerRef.current?.getTotalSize() ?? 0; + const scrollElementHeight = scrollElementRef.current?.clientHeight ?? 0; + const isScrollable = contentHeight > scrollElementHeight; + const isInverted = isEmpty + ? false + : !isScrollable + ? true + : loadDirection === 'older'; + // We want to render newest messages first, but we receive them oldest-first. + // This is a simple way to reverse the order without having to reverse a big array. + const transformIndex = useCallback( + (index: number) => (isInverted ? count - 1 - index : index), + [count, isInverted] + ); + /** * Scroll to current anchor index */ @@ -271,6 +298,19 @@ export default function ChatScroller({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [scrollTo]); + const isLoadingAtStart = fetchState === (isInverted ? 'bottom' : 'top'); + const isLoadingAtEnd = fetchState === (isInverted ? 'top' : 'bottom'); + const paddingStart = isLoadingAtStart + ? isInverted + ? loaderPadding.bottom + : loaderPadding.top + : 0; + const paddingEnd = isLoadingAtEnd + ? isInverted + ? loaderPadding.top + : loaderPadding.bottom + : 0; + const virtualizer = useVirtualizer({ count, getScrollElement: useCallback( @@ -306,6 +346,8 @@ export default function ChatScroller({ (index: number) => messageKeys[transformIndex(index)].toString(), [messageKeys, transformIndex] ), + paddingStart, + paddingEnd, scrollToFn: useCallback( ( offset: number, @@ -372,10 +414,10 @@ export default function ChatScroller({ // Load more content if there's not enough to fill the scroller + there's more to load. // The main place this happens is when there are a bunch of replies in the recent chat history. - const contentHeight = virtualizer.getTotalSize(); + const contentIsShort = contentHeight < scrollElementHeight; useEffect(() => { if ( - contentHeight < window.innerHeight && + contentIsShort && fetchState === 'initial' && // don't try to load more in threads, because their content is already fetched by main window !replying @@ -391,7 +433,7 @@ export default function ChatScroller({ } }, [ replying, - contentHeight, + contentIsShort, fetchMessages, fetchState, loadDirection, @@ -437,6 +479,9 @@ export default function ChatScroller({ const scaleY = isInverted ? -1 : 1; const virtualItems = virtualizer.getVirtualItems(); + // On first run, virtualizerRef will be empty, so contentHeight will be undefined. + // TODO: Distentangle virtualizer init to avoid this. + const finalHeight = contentHeight ?? virtualizer.getTotalSize(); return (
)} - -
- -
-
+ {isLoadingAtStart && !isInverted && ( + + Loading {isInverted ? 'Newer' : 'Older'} + + )} {virtualItems.map((virtualItem) => { const item = messageEntries[transformIndex(virtualItem.index)]; return ( @@ -483,10 +528,11 @@ export default function ChatScroller({
); })} -
- -
- + {isLoadingAtEnd && isInverted && ( + + Loading {isInverted ? 'Older' : 'Newer'} + + )}
); diff --git a/ui/src/logic/scroll.ts b/ui/src/logic/scroll.ts index 07cef5f8ef..204a0bc70f 100644 --- a/ui/src/logic/scroll.ts +++ b/ui/src/logic/scroll.ts @@ -29,7 +29,7 @@ export function useIsScrolling( }, 50); el.addEventListener('scroll', handleScroll, { passive: true }); return () => el.removeEventListener('scroll', handleScroll); - }, [scrollElementRef]); + }); // This performs a bit better than setting and clearing a million // setTimeouts, even debounced, but in the worst case takes 2 * checkInterval @@ -43,7 +43,6 @@ export function useIsScrolling( return () => clearInterval(interval); }, [isScrolling, checkInterval, scrollStopDelay]); - return isScrolling; }