From f3cf836fcd8d58ff7763aa27724522a6c5322ea7 Mon Sep 17 00:00:00 2001 From: Alois Date: Sat, 13 Jun 2026 11:39:47 +0200 Subject: [PATCH] (fix): a bunch or weird scroll behaviour --- packages/chat/src/components/message.tsx | 102 ++-- packages/chat/src/screen.tsx | 646 ++++++++++------------- packages/chat/src/values.ts | 4 +- 3 files changed, 337 insertions(+), 415 deletions(-) diff --git a/packages/chat/src/components/message.tsx b/packages/chat/src/components/message.tsx index 824c287..dc785a6 100644 --- a/packages/chat/src/components/message.tsx +++ b/packages/chat/src/components/message.tsx @@ -3,6 +3,7 @@ import type { RawMessage } from "../values"; import Text from "@tensamin/markdown/text"; import { AlertTriangle } from "lucide-react"; import { useEffect, useState } from "react"; +import type { User } from "@tensamin/user/context"; import { Avatar, @@ -13,21 +14,20 @@ import { TooltipContent, TooltipTrigger, } from "@tensamin/ui"; -import Wrapper from "@tensamin/user/wrapper"; -import { useChat } from "../context"; import MessageContextMenu from "./messageContextMenu"; import Media from "./media"; function MessageComponent({ grouped, message, + user, }: { grouped: boolean; message: RawMessage & { failed?: boolean; }; + user: User | null; }) { - const { userId } = useChat(); const actuallyFailed = message.failed && message.message_state === "awaiting"; const [isValidURL, setIsValidURL] = useState(false); @@ -60,55 +60,53 @@ function MessageComponent({ }, )} > - ( - <> - {grouped ? ( -

- {new Date(message.send_time).toLocaleString([], { - hour: "2-digit", - minute: "2-digit", - })} -

+ {user ? ( + <> + {grouped ? ( +

+ {new Date(message.send_time).toLocaleString([], { + hour: "2-digit", + minute: "2-digit", + })} +

+ ) : ( + + + + {user.display.slice(0, 2).toUpperCase()} + + + )} + {message.failed && message.message_state === "awaiting" && ( + + +

Failed to send message

+
+ } /> +
+ )} +
+ {!grouped && ( +
+

{user.display}

+

+ {new Date(message.send_time).toLocaleString([], { + hour: "2-digit", + minute: "2-digit", + })} +

+
+ )} + {isValidURL ? ( + ) : ( - - - - {user.display.slice(0, 2).toUpperCase()} - - + )} - {message.failed && message.message_state === "awaiting" && ( - - -

Failed to send message

-
- } /> -
- )} -
- {!grouped && ( -
-

{user.display}

-

- {new Date(message.send_time).toLocaleString([], { - hour: "2-digit", - minute: "2-digit", - })} -

-
- )} - {isValidURL ? ( - - ) : ( - - )} -
- - )} - /> +
+ + ) : ( + "Loading" + )} @@ -122,6 +120,8 @@ export default React.memo(MessageComponent, (prev, next) => { prev.message.height === next.message.height && prev.message.sent_by_self === next.message.sent_by_self && prev.message.message_state === next.message.message_state && - prev.message.failed === next.message.failed + prev.message.failed === next.message.failed && + prev.grouped === next.grouped && + prev.user === next.user ); }); diff --git a/packages/chat/src/screen.tsx b/packages/chat/src/screen.tsx index 44e581b..eabb2ac 100644 --- a/packages/chat/src/screen.tsx +++ b/packages/chat/src/screen.tsx @@ -1,56 +1,68 @@ import * as React from "react"; import { useInfiniteQuery } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { useChat } from "./context"; +import { failedUser } from "@tensamin/shared/data"; +import { useStorage } from "@tensamin/storage/context"; +import { Loader2 } from "lucide-react"; +import { useUser, type User } from "@tensamin/user/context"; import InputComponent from "./components/input"; import Message from "./components/message"; +import { useChat } from "./context"; +import { + PAGE_SIZE, + FALLBACK_MESSAGE_HEIGHT, + MESSAGES_PER_VIRTUAL_ROW, +} from "./values"; -import { PAGE_SIZE } from "./values"; -import { useIsMobile } from "@tensamin/ui"; -import { Loader2 } from "lucide-react"; - -function getDistanceFromBottom(element: HTMLDivElement) { - return element.scrollHeight - (element.scrollTop + element.clientHeight); +function getEstimatedMessageHeight(message: { height?: number }) { + return typeof message.height === "number" && Number.isFinite(message.height) + ? Math.max(1, Math.ceil(message.height)) + : FALLBACK_MESSAGE_HEIGHT; } -const FALLBACK_MESSAGE_HEIGHT = 56; +function shouldFetchPreviousPage({ + entry, + hasNextPage, + isFetchingNextPage, + userScrolledUp, +}: { + entry: IntersectionObserverEntry | undefined; + hasNextPage: boolean; + isFetchingNextPage: boolean; + userScrolledUp: boolean; +}) { + return [ + entry?.isIntersecting === true, + userScrolledUp, + hasNextPage, + !isFetchingNextPage, + ].every(Boolean); +} /** * Renders the chat screen with virtualized history and live message updates. * @returns Chat screen JSX. */ export default function Screen() { - const { - getMessages, - liveMessages, - clearLiveMessages, - userId, - sharedSecret, - inputBoxRef, - } = useChat(); + const { getMessages, liveMessages, clearLiveMessages, userId, sharedSecret } = + useChat(); + const { get: getUser } = useUser(); + const { load } = useStorage(); const scrollRef = React.useRef(null); - const isMobile = useIsMobile(); - const stickyBottomThreshold = isMobile ? 220 : 140; - - const [hasScrolledToBottomInitially, setHasScrolledToBottomInitially] = - React.useState(false); - const [lastLiveMessageCount, setLastLiveMessageCount] = React.useState(0); - const [prependAnchor, setPrependAnchor] = React.useState<{ - totalSize: number; - scrollTop: number; - } | null>(null); - const [scrollWidth, setScrollWidth] = React.useState(0); - const [scrollHeight, setScrollHeight] = React.useState(0); - const [inputHeight, setInputHeight] = React.useState(0); - const [measuredHeights, setMeasuredHeights] = React.useState< - Record - >({}); - const measurementRefs = React.useRef(new Map()); - const shouldRestoreBottomOnResizeRef = React.useRef(false); + const topSentinelRef = React.useRef(null); + const didInitialScrollRef = React.useRef(false); + const userScrolledUpRef = React.useRef(false); const isAtBottomRef = React.useRef(true); - const shouldRestoreBottomOnFocusResizeRef = React.useRef(false); + + const [messageUsers, setMessageUsers] = React.useState< + Record + >({}); + const [lastLiveMessageCount, setLastLiveMessageCount] = React.useState(0); + const [didInitialScroll, setDidInitialScroll] = React.useState(false); + const [viewportHeight, setViewportHeight] = React.useState(0); + const [value, setValue] = React.useState(""); const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0; const hasSharedSecret = sharedSecret.length > 0; @@ -69,18 +81,55 @@ export default function Screen() { }, }); + React.useEffect(() => { + clearLiveMessages(); + didInitialScrollRef.current = false; + userScrolledUpRef.current = false; + isAtBottomRef.current = true; + setDidInitialScroll(false); + setLastLiveMessageCount(0); + }, [clearLiveMessages, userId]); + + React.useEffect(() => { + if (!hasValidChatUser) { + setMessageUsers({}); + return; + } + + let active = true; + + const loadMessageUser = async ( + key: string, + resolveUserId: () => Promise, + ) => { + try { + const resolvedUserId = await resolveUserId(); + const value = await getUser(resolvedUserId); + + if (active) { + setMessageUsers((prev) => ({ ...prev, [key]: value })); + } + } catch { + if (active) { + setMessageUsers((prev) => ({ ...prev, [key]: failedUser })); + } + } + }; + + setMessageUsers({}); + void loadMessageUser("peer", async () => userId); + void loadMessageUser("own", async () => Number(await load("user_id"))); + + return () => { + active = false; + }; + }, [getUser, hasValidChatUser, load, userId]); + const historicalMessages = React.useMemo(() => { const pages = messagesQuery.data?.pages ?? []; return [...pages].reverse().flat(); }, [messagesQuery.data]); - React.useEffect(() => { - clearLiveMessages(); - setHasScrolledToBottomInitially(false); - setLastLiveMessageCount(0); - setPrependAnchor(null); - }, [userId, clearLiveMessages]); - const liveMessagesSnapshot = liveMessages(); const messages = React.useMemo(() => { @@ -97,51 +146,67 @@ export default function Screen() { return [...historicalMessages, ...liveWithoutDuplicates]; }, [historicalMessages, liveMessagesSnapshot]); + + const messageChunks = React.useMemo(() => { + const chunks: Array<{ + key: number; + messages: typeof messages; + startIndex: number; + }> = []; + + for (let end = messages.length; end > 0; end -= MESSAGES_PER_VIRTUAL_ROW) { + const start = Math.max(0, end - MESSAGES_PER_VIRTUAL_ROW); + const chunkMessages = messages.slice(start, end); + const firstMessage = chunkMessages[0]; + + if (firstMessage) { + chunks.push({ + key: firstMessage.send_time, + messages: chunkMessages, + startIndex: start, + }); + } + } + + return chunks; + }, [messages]); + const shouldShowConversationStart = !!messagesQuery.data && !messagesQuery.hasNextPage; const virtualRowCount = - messages.length + (shouldShowConversationStart ? 1 : 0); + messageChunks.length + (shouldShowConversationStart ? 1 : 0); const getItemKey = React.useCallback( (index: number) => { - if (shouldShowConversationStart && index === 0) { + if (shouldShowConversationStart && index === messageChunks.length) { return "conversation-start"; } - const messageIndex = shouldShowConversationStart ? index - 1 : index; - return messages[messageIndex]?.send_time ?? index; + return messageChunks[index]?.key ?? index; }, - [shouldShowConversationStart, messages], + [messageChunks, shouldShowConversationStart], ); - const setMeasurementRef = React.useCallback( - (sendTime: number, element: HTMLDivElement | null) => { - if (!element) { - measurementRefs.current.delete(sendTime); - return; - } - - measurementRefs.current.set(sendTime, element); - }, - [], - ); - - const estimateMessageSize = React.useCallback( + const estimateSize = React.useCallback( (index: number) => { - if (shouldShowConversationStart && index === 0) { + const isConversationStart = + shouldShowConversationStart && index === messageChunks.length; + if (isConversationStart) { return FALLBACK_MESSAGE_HEIGHT; } - const messageIndex = shouldShowConversationStart ? index - 1 : index; - const sendTime = messages[messageIndex]?.send_time; + const chunk = messageChunks[index]; - if (sendTime === undefined) { + if (!chunk) { return FALLBACK_MESSAGE_HEIGHT; } - return measuredHeights[sendTime] ?? FALLBACK_MESSAGE_HEIGHT; + return chunk.messages.reduce( + (total, message) => total + getEstimatedMessageHeight(message), + 0, + ); }, - [measuredHeights, messages, shouldShowConversationStart], + [messageChunks, shouldShowConversationStart], ); // eslint-disable-next-line react-hooks/incompatible-library @@ -149,9 +214,12 @@ export default function Screen() { count: virtualRowCount, getScrollElement: () => scrollRef.current, getItemKey, - estimateSize: estimateMessageSize, - overscan: isMobile ? 10 : 6, + estimateSize, + overscan: 2, }); + const totalSize = virtualizer.getTotalSize(); + const contentHeight = Math.max(totalSize, viewportHeight); + const verticalOffset = Math.max(0, viewportHeight - totalSize); React.useLayoutEffect(() => { const element = scrollRef.current; @@ -159,14 +227,13 @@ export default function Screen() { return; } - const updateDimensions = () => { - setScrollWidth(element.clientWidth); - setScrollHeight(element.clientHeight); + const updateViewportHeight = () => { + setViewportHeight(element.clientHeight); }; - updateDimensions(); + updateViewportHeight(); - const observer = new ResizeObserver(updateDimensions); + const observer = new ResizeObserver(updateViewportHeight); observer.observe(element); return () => { @@ -175,277 +242,134 @@ export default function Screen() { }, []); React.useLayoutEffect(() => { - const frame = requestAnimationFrame(() => { - virtualizer.measure(); - }); - - return () => { - cancelAnimationFrame(frame); - }; - }, [inputHeight, measuredHeights, scrollWidth, virtualizer]); - - React.useLayoutEffect(() => { - if (typeof ResizeObserver === "undefined") { + if (didInitialScrollRef.current || virtualRowCount === 0) { return; } - const measureHeights = () => { - setMeasuredHeights((prev) => { - let changed = false; - const next: Record = {}; - - for (const message of messages) { - const element = measurementRefs.current.get(message.send_time); - const measuredHeight = element?.getBoundingClientRect().height; - const nextHeight = - typeof measuredHeight === "number" && - Number.isFinite(measuredHeight) - ? Math.ceil(measuredHeight) - : (prev[message.send_time] ?? FALLBACK_MESSAGE_HEIGHT); - - next[message.send_time] = nextHeight; - - if (prev[message.send_time] !== nextHeight) { - changed = true; - } - } - - if (!changed && Object.keys(prev).length === messages.length) { - return prev; - } - - return next; - }); - }; - - measureHeights(); - - const observer = new ResizeObserver(() => { - measureHeights(); - }); - - for (const message of messages) { - const element = measurementRefs.current.get(message.send_time); - if (element) { - observer.observe(element); + requestAnimationFrame(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = 0; } - } - - return () => { - observer.disconnect(); - }; - }, [messages, scrollWidth]); - - React.useEffect(() => { - if (typeof window === "undefined") { - return; - } - - const restoreBottomAfterResize = () => { - shouldRestoreBottomOnResizeRef.current = - isAtBottomRef.current || shouldRestoreBottomOnFocusResizeRef.current; requestAnimationFrame(() => { - virtualizer.measure(); - - requestAnimationFrame(() => { - if (!scrollRef.current || !shouldRestoreBottomOnResizeRef.current) { - return; - } - - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - isAtBottomRef.current = true; - shouldRestoreBottomOnFocusResizeRef.current = false; - shouldRestoreBottomOnResizeRef.current = false; - }); + didInitialScrollRef.current = true; + setDidInitialScroll(true); }); - }; - - window.addEventListener("resize", restoreBottomAfterResize); - window.visualViewport?.addEventListener("resize", restoreBottomAfterResize); - - return () => { - window.removeEventListener("resize", restoreBottomAfterResize); - window.visualViewport?.removeEventListener( - "resize", - restoreBottomAfterResize, - ); - }; - }, [virtualizer]); - - React.useLayoutEffect(() => { - const element = inputBoxRef.current; - if (!element || typeof ResizeObserver === "undefined") { - return; - } - - const updateHeight = () => { - setInputHeight(element.getBoundingClientRect().height); - }; - - updateHeight(); - - const observer = new ResizeObserver(updateHeight); - observer.observe(element); - - return () => { - observer.disconnect(); - }; - }, [inputBoxRef]); - - React.useEffect(() => { - const element = inputBoxRef.current; - if (!element) { - return; - } - - const handleFocusIn = () => { - if (!scrollRef.current) { - return; - } - - shouldRestoreBottomOnFocusResizeRef.current = - getDistanceFromBottom(scrollRef.current) <= stickyBottomThreshold; - }; - - const handleFocusOut = () => { - shouldRestoreBottomOnFocusResizeRef.current = false; - }; - - element.addEventListener("focusin", handleFocusIn); - element.addEventListener("focusout", handleFocusOut); - - return () => { - element.removeEventListener("focusin", handleFocusIn); - element.removeEventListener("focusout", handleFocusOut); - }; - }, [inputBoxRef, stickyBottomThreshold]); - - /** - * Loads the next page when the scroll container reaches the top. - * @returns Promise that resolves once pagination handling completes. - */ - const onScroll = React.useCallback(async () => { - if (!scrollRef.current) { - return; - } - - if (scrollRef.current.scrollTop > 96) { - return; - } - - if (messagesQuery.isFetchingNextPage || !messagesQuery.hasNextPage) { - return; - } - - setPrependAnchor({ - totalSize: virtualizer.getTotalSize(), - scrollTop: scrollRef.current.scrollTop, }); - - await messagesQuery.fetchNextPage(); - }, [messagesQuery, virtualizer]); - - React.useLayoutEffect(() => { - if ( - !scrollRef.current || - hasScrolledToBottomInitially || - virtualRowCount === 0 - ) { - return; - } - - let rafId: number; - let stableFrames = 0; - let lastScrollHeight = 0; - - const settleToBottom = () => { - if (!scrollRef.current || hasScrolledToBottomInitially) { - return; - } - - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - const currentScrollHeight = scrollRef.current.scrollHeight; - - if (currentScrollHeight === lastScrollHeight) { - stableFrames++; - } else { - stableFrames = 0; - lastScrollHeight = currentScrollHeight; - } - - if (stableFrames >= 2) { - isAtBottomRef.current = true; - setHasScrolledToBottomInitially(true); - } else { - rafId = requestAnimationFrame(settleToBottom); - } - }; - - settleToBottom(); - - return () => { - cancelAnimationFrame(rafId); - }; - }, [hasScrolledToBottomInitially, virtualRowCount]); + }, [virtualizer, virtualRowCount]); React.useLayoutEffect(() => { const count = liveMessagesSnapshot.length; - if (!scrollRef.current) { - return; - } - - if (count > lastLiveMessageCount) { - const shouldStickToBottom = - getDistanceFromBottom(scrollRef.current) <= stickyBottomThreshold; - - if (shouldStickToBottom) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - isAtBottomRef.current = true; - } + if (count > lastLiveMessageCount && isAtBottomRef.current) { + requestAnimationFrame(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = 0; + } + }); } setLastLiveMessageCount(count); }, [ lastLiveMessageCount, liveMessagesSnapshot.length, - stickyBottomThreshold, + virtualRowCount, + virtualizer, ]); - React.useLayoutEffect(() => { + React.useEffect(() => { if ( - !scrollRef.current || - !prependAnchor || - messagesQuery.isFetchingNextPage + viewportHeight === 0 || + totalSize > viewportHeight || + messagesQuery.isFetchingNextPage || + !messagesQuery.hasNextPage ) { return; } - const delta = virtualizer.getTotalSize() - prependAnchor.totalSize; - scrollRef.current.scrollTop = prependAnchor.scrollTop + delta; - setPrependAnchor(null); - }, [messagesQuery.isFetchingNextPage, prependAnchor, virtualizer]); + void messagesQuery.fetchNextPage(); + }, [didInitialScroll, messagesQuery, totalSize, viewportHeight]); - /** - * Triggers asynchronous scroll pagination without returning a promise to JSX. - * @returns Void. - */ - const handleContainerScroll = React.useCallback((): void => { - if (scrollRef.current) { - isAtBottomRef.current = - getDistanceFromBottom(scrollRef.current) <= stickyBottomThreshold; + React.useLayoutEffect(() => { + if ( + !didInitialScroll || + userScrolledUpRef.current || + virtualRowCount === 0 + ) { + return; } - void onScroll(); - }, [onScroll, stickyBottomThreshold]); + requestAnimationFrame(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = 0; + } + }); + }, [didInitialScroll, virtualRowCount]); - const [value, setValue] = React.useState(""); - const totalSize = virtualizer.getTotalSize(); - const verticalOffset = Math.max(0, scrollHeight - totalSize); - const contentHeight = Math.max(totalSize, scrollHeight); + React.useEffect(() => { + const root = scrollRef.current; + const sentinel = topSentinelRef.current; + + if (!root || !sentinel || !didInitialScroll) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if ( + shouldFetchPreviousPage({ + entry, + hasNextPage: messagesQuery.hasNextPage, + isFetchingNextPage: messagesQuery.isFetchingNextPage, + userScrolledUp: userScrolledUpRef.current, + }) + ) { + void messagesQuery.fetchNextPage(); + } + }, + { root, rootMargin: "240px 0px 0px 0px" }, + ); + + observer.observe(sentinel); + + return () => { + observer.disconnect(); + }; + }, [didInitialScroll, messagesQuery, virtualRowCount]); + + const handleContainerScroll = React.useCallback(() => { + if (!scrollRef.current) { + return; + } + + isAtBottomRef.current = scrollRef.current.scrollTop <= 140; + + if (didInitialScrollRef.current && scrollRef.current.scrollTop > 140) { + userScrolledUpRef.current = true; + } + }, []); + + React.useEffect(() => { + const element = scrollRef.current; + if (!element) { + return; + } + + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); + event.stopPropagation(); + element.scrollTop -= event.deltaY; + handleContainerScroll(); + }; + + element.addEventListener("wheel", handleWheel, { passive: false }); + + return () => { + element.removeEventListener("wheel", handleWheel); + }; + }, [handleContainerScroll]); - // Render if (!hasValidChatUser) { return (
@@ -462,12 +386,13 @@ export default function Screen() { className="min-h-0 flex-1 overflow-y-auto" style={{ overflowAnchor: "none", - paddingBottom: "22px", + paddingTop: "22px", + transform: "scaleY(-1)", }} onScroll={handleContainerScroll} > {messagesQuery.isPending && ( -
+

Loading... @@ -475,18 +400,23 @@ export default function Screen() {

)} {messagesQuery.isFetchingNextPage && ( -
+
)}
+
{virtualizer.getVirtualItems().map((virtualRow) => { - if (shouldShowConversationStart && virtualRow.index === 0) { + if ( + shouldShowConversationStart && + virtualRow.index === messageChunks.length + ) { return (
-
+
Conversation start
@@ -509,35 +439,50 @@ export default function Screen() { ); } - const messageIndex = shouldShowConversationStart - ? virtualRow.index - 1 - : virtualRow.index; - const message = messages[messageIndex]; - if (!message) { + const chunkIndex = virtualRow.index; + const chunk = messageChunks[chunkIndex]; + if (!chunk) { return null; } - const lastMessage = messages[messageIndex - 1]; - const isGrouped = - lastMessage && - lastMessage.sent_by_self === message.sent_by_self && - Math.round(lastMessage.send_time / 10000) === - Math.round(message.send_time / 10000); - return (
- +
+ {chunk.messages.map((message, chunkMessageIndex) => { + const messageIndex = chunk.startIndex + chunkMessageIndex; + const lastMessage = messages[messageIndex - 1]; + const isGrouped = + lastMessage && + lastMessage.sent_by_self === message.sent_by_self && + Math.round(lastMessage.send_time / 10000) === + Math.round(message.send_time / 10000); + + return ( + + ); + })} +
); })} @@ -546,31 +491,6 @@ export default function Screen() {
-
); } diff --git a/packages/chat/src/values.ts b/packages/chat/src/values.ts index 880091c..8a97ef0 100644 --- a/packages/chat/src/values.ts +++ b/packages/chat/src/values.ts @@ -10,4 +10,6 @@ export type LiveMessage = RawMessage & { localId: string; }; -export const PAGE_SIZE = 50; +export const PAGE_SIZE = 30; +export const FALLBACK_MESSAGE_HEIGHT = 28; +export const MESSAGES_PER_VIRTUAL_ROW = 30;