(fix): a bunch or weird scroll behaviour
All checks were successful
/ build-web (push) Successful in 1m27s
/ build-desktop (linux) (push) Successful in 6m46s
/ build-mobile (push) Successful in 16m16s
/ release (push) Successful in 30s

This commit is contained in:
Alois 2026-06-13 11:39:47 +02:00
commit f3cf836fcd
3 changed files with 320 additions and 398 deletions

View file

@ -3,6 +3,7 @@ import type { RawMessage } from "../values";
import Text from "@tensamin/markdown/text"; import Text from "@tensamin/markdown/text";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { User } from "@tensamin/user/context";
import { import {
Avatar, Avatar,
@ -13,21 +14,20 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@tensamin/ui"; } from "@tensamin/ui";
import Wrapper from "@tensamin/user/wrapper";
import { useChat } from "../context";
import MessageContextMenu from "./messageContextMenu"; import MessageContextMenu from "./messageContextMenu";
import Media from "./media"; import Media from "./media";
function MessageComponent({ function MessageComponent({
grouped, grouped,
message, message,
user,
}: { }: {
grouped: boolean; grouped: boolean;
message: RawMessage & { message: RawMessage & {
failed?: boolean; failed?: boolean;
}; };
user: User | null;
}) { }) {
const { userId } = useChat();
const actuallyFailed = message.failed && message.message_state === "awaiting"; const actuallyFailed = message.failed && message.message_state === "awaiting";
const [isValidURL, setIsValidURL] = useState(false); const [isValidURL, setIsValidURL] = useState(false);
@ -60,10 +60,7 @@ function MessageComponent({
}, },
)} )}
> >
<Wrapper {user ? (
loading={"State not achievable"}
userId={message.sent_by_self ? "own" : userId}
component={(user) => (
<> <>
{grouped ? ( {grouped ? (
<p className="w-9 text-xs group-hover:visible invisible text-muted-foreground"> <p className="w-9 text-xs group-hover:visible invisible text-muted-foreground">
@ -107,8 +104,9 @@ function MessageComponent({
)} )}
</div> </div>
</> </>
) : (
"Loading"
)} )}
/>
</div> </div>
</MessageContextMenu> </MessageContextMenu>
</div> </div>
@ -122,6 +120,8 @@ export default React.memo(MessageComponent, (prev, next) => {
prev.message.height === next.message.height && prev.message.height === next.message.height &&
prev.message.sent_by_self === next.message.sent_by_self && prev.message.sent_by_self === next.message.sent_by_self &&
prev.message.message_state === next.message.message_state && 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
); );
}); });

View file

@ -1,56 +1,68 @@
import * as React from "react"; import * as React from "react";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual"; 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 InputComponent from "./components/input";
import Message from "./components/message"; 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"; function getEstimatedMessageHeight(message: { height?: number }) {
import { useIsMobile } from "@tensamin/ui"; return typeof message.height === "number" && Number.isFinite(message.height)
import { Loader2 } from "lucide-react"; ? Math.max(1, Math.ceil(message.height))
: FALLBACK_MESSAGE_HEIGHT;
function getDistanceFromBottom(element: HTMLDivElement) {
return element.scrollHeight - (element.scrollTop + element.clientHeight);
} }
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. * Renders the chat screen with virtualized history and live message updates.
* @returns Chat screen JSX. * @returns Chat screen JSX.
*/ */
export default function Screen() { export default function Screen() {
const { const { getMessages, liveMessages, clearLiveMessages, userId, sharedSecret } =
getMessages, useChat();
liveMessages, const { get: getUser } = useUser();
clearLiveMessages, const { load } = useStorage();
userId,
sharedSecret,
inputBoxRef,
} = useChat();
const scrollRef = React.useRef<HTMLDivElement | null>(null); const scrollRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useIsMobile(); const topSentinelRef = React.useRef<HTMLDivElement | null>(null);
const stickyBottomThreshold = isMobile ? 220 : 140; const didInitialScrollRef = React.useRef(false);
const userScrolledUpRef = React.useRef(false);
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<number, number>
>({});
const measurementRefs = React.useRef(new Map<number, HTMLDivElement>());
const shouldRestoreBottomOnResizeRef = React.useRef(false);
const isAtBottomRef = React.useRef(true); const isAtBottomRef = React.useRef(true);
const shouldRestoreBottomOnFocusResizeRef = React.useRef(false);
const [messageUsers, setMessageUsers] = React.useState<
Record<string, User | undefined>
>({});
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 hasValidChatUser = Number.isSafeInteger(userId) && userId > 0;
const hasSharedSecret = sharedSecret.length > 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<number>,
) => {
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 historicalMessages = React.useMemo(() => {
const pages = messagesQuery.data?.pages ?? []; const pages = messagesQuery.data?.pages ?? [];
return [...pages].reverse().flat(); return [...pages].reverse().flat();
}, [messagesQuery.data]); }, [messagesQuery.data]);
React.useEffect(() => {
clearLiveMessages();
setHasScrolledToBottomInitially(false);
setLastLiveMessageCount(0);
setPrependAnchor(null);
}, [userId, clearLiveMessages]);
const liveMessagesSnapshot = liveMessages(); const liveMessagesSnapshot = liveMessages();
const messages = React.useMemo(() => { const messages = React.useMemo(() => {
@ -97,51 +146,67 @@ export default function Screen() {
return [...historicalMessages, ...liveWithoutDuplicates]; return [...historicalMessages, ...liveWithoutDuplicates];
}, [historicalMessages, liveMessagesSnapshot]); }, [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 = const shouldShowConversationStart =
!!messagesQuery.data && !messagesQuery.hasNextPage; !!messagesQuery.data && !messagesQuery.hasNextPage;
const virtualRowCount = const virtualRowCount =
messages.length + (shouldShowConversationStart ? 1 : 0); messageChunks.length + (shouldShowConversationStart ? 1 : 0);
const getItemKey = React.useCallback( const getItemKey = React.useCallback(
(index: number) => { (index: number) => {
if (shouldShowConversationStart && index === 0) { if (shouldShowConversationStart && index === messageChunks.length) {
return "conversation-start"; return "conversation-start";
} }
const messageIndex = shouldShowConversationStart ? index - 1 : index; return messageChunks[index]?.key ?? index;
return messages[messageIndex]?.send_time ?? index;
}, },
[shouldShowConversationStart, messages], [messageChunks, shouldShowConversationStart],
); );
const setMeasurementRef = React.useCallback( const estimateSize = React.useCallback(
(sendTime: number, element: HTMLDivElement | null) => {
if (!element) {
measurementRefs.current.delete(sendTime);
return;
}
measurementRefs.current.set(sendTime, element);
},
[],
);
const estimateMessageSize = React.useCallback(
(index: number) => { (index: number) => {
if (shouldShowConversationStart && index === 0) { const isConversationStart =
shouldShowConversationStart && index === messageChunks.length;
if (isConversationStart) {
return FALLBACK_MESSAGE_HEIGHT; return FALLBACK_MESSAGE_HEIGHT;
} }
const messageIndex = shouldShowConversationStart ? index - 1 : index; const chunk = messageChunks[index];
const sendTime = messages[messageIndex]?.send_time;
if (sendTime === undefined) { if (!chunk) {
return FALLBACK_MESSAGE_HEIGHT; 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 // eslint-disable-next-line react-hooks/incompatible-library
@ -149,9 +214,12 @@ export default function Screen() {
count: virtualRowCount, count: virtualRowCount,
getScrollElement: () => scrollRef.current, getScrollElement: () => scrollRef.current,
getItemKey, getItemKey,
estimateSize: estimateMessageSize, estimateSize,
overscan: isMobile ? 10 : 6, overscan: 2,
}); });
const totalSize = virtualizer.getTotalSize();
const contentHeight = Math.max(totalSize, viewportHeight);
const verticalOffset = Math.max(0, viewportHeight - totalSize);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const element = scrollRef.current; const element = scrollRef.current;
@ -159,14 +227,13 @@ export default function Screen() {
return; return;
} }
const updateDimensions = () => { const updateViewportHeight = () => {
setScrollWidth(element.clientWidth); setViewportHeight(element.clientHeight);
setScrollHeight(element.clientHeight);
}; };
updateDimensions(); updateViewportHeight();
const observer = new ResizeObserver(updateDimensions); const observer = new ResizeObserver(updateViewportHeight);
observer.observe(element); observer.observe(element);
return () => { return () => {
@ -175,277 +242,134 @@ export default function Screen() {
}, []); }, []);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const frame = requestAnimationFrame(() => { if (didInitialScrollRef.current || virtualRowCount === 0) {
virtualizer.measure();
});
return () => {
cancelAnimationFrame(frame);
};
}, [inputHeight, measuredHeights, scrollWidth, virtualizer]);
React.useLayoutEffect(() => {
if (typeof ResizeObserver === "undefined") {
return; return;
} }
const measureHeights = () => {
setMeasuredHeights((prev) => {
let changed = false;
const next: Record<number, number> = {};
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);
}
}
return () => {
observer.disconnect();
};
}, [messages, scrollWidth]);
React.useEffect(() => {
if (typeof window === "undefined") {
return;
}
const restoreBottomAfterResize = () => {
shouldRestoreBottomOnResizeRef.current =
isAtBottomRef.current || shouldRestoreBottomOnFocusResizeRef.current;
requestAnimationFrame(() => { requestAnimationFrame(() => {
virtualizer.measure(); if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!scrollRef.current || !shouldRestoreBottomOnResizeRef.current) { didInitialScrollRef.current = true;
return; setDidInitialScroll(true);
}
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
isAtBottomRef.current = true;
shouldRestoreBottomOnFocusResizeRef.current = false;
shouldRestoreBottomOnResizeRef.current = false;
}); });
}); });
}; }, [virtualizer, virtualRowCount]);
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]);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const count = liveMessagesSnapshot.length; const count = liveMessagesSnapshot.length;
if (!scrollRef.current) { if (count > lastLiveMessageCount && isAtBottomRef.current) {
return; requestAnimationFrame(() => {
} if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
if (count > lastLiveMessageCount) {
const shouldStickToBottom =
getDistanceFromBottom(scrollRef.current) <= stickyBottomThreshold;
if (shouldStickToBottom) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
isAtBottomRef.current = true;
} }
});
} }
setLastLiveMessageCount(count); setLastLiveMessageCount(count);
}, [ }, [
lastLiveMessageCount, lastLiveMessageCount,
liveMessagesSnapshot.length, liveMessagesSnapshot.length,
stickyBottomThreshold, virtualRowCount,
virtualizer,
]); ]);
React.useLayoutEffect(() => { React.useEffect(() => {
if ( if (
!scrollRef.current || viewportHeight === 0 ||
!prependAnchor || totalSize > viewportHeight ||
messagesQuery.isFetchingNextPage messagesQuery.isFetchingNextPage ||
!messagesQuery.hasNextPage
) { ) {
return; return;
} }
const delta = virtualizer.getTotalSize() - prependAnchor.totalSize; void messagesQuery.fetchNextPage();
scrollRef.current.scrollTop = prependAnchor.scrollTop + delta; }, [didInitialScroll, messagesQuery, totalSize, viewportHeight]);
setPrependAnchor(null);
}, [messagesQuery.isFetchingNextPage, prependAnchor, virtualizer]);
/** React.useLayoutEffect(() => {
* Triggers asynchronous scroll pagination without returning a promise to JSX. if (
* @returns Void. !didInitialScroll ||
*/ userScrolledUpRef.current ||
const handleContainerScroll = React.useCallback((): void => { virtualRowCount === 0
if (scrollRef.current) { ) {
isAtBottomRef.current = return;
getDistanceFromBottom(scrollRef.current) <= stickyBottomThreshold;
} }
void onScroll(); requestAnimationFrame(() => {
}, [onScroll, stickyBottomThreshold]); if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
});
}, [didInitialScroll, virtualRowCount]);
const [value, setValue] = React.useState(""); React.useEffect(() => {
const totalSize = virtualizer.getTotalSize(); const root = scrollRef.current;
const verticalOffset = Math.max(0, scrollHeight - totalSize); const sentinel = topSentinelRef.current;
const contentHeight = Math.max(totalSize, scrollHeight);
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) { if (!hasValidChatUser) {
return ( return (
<div className="w-full h-full flex items-center justify-center text-xl text-foreground/80"> <div className="w-full h-full flex items-center justify-center text-xl text-foreground/80">
@ -462,12 +386,13 @@ export default function Screen() {
className="min-h-0 flex-1 overflow-y-auto" className="min-h-0 flex-1 overflow-y-auto"
style={{ style={{
overflowAnchor: "none", overflowAnchor: "none",
paddingBottom: "22px", paddingTop: "22px",
transform: "scaleY(-1)",
}} }}
onScroll={handleContainerScroll} onScroll={handleContainerScroll}
> >
{messagesQuery.isPending && ( {messagesQuery.isPending && (
<div className="flex items-center justify-center pt-10"> <div className="flex items-center justify-center pt-10 scale-y-[-1]">
<p className="text-foreground/45 flex gap-1 items-center justify-center"> <p className="text-foreground/45 flex gap-1 items-center justify-center">
<Loader2 size={18} className="animate-spin" /> <Loader2 size={18} className="animate-spin" />
Loading... Loading...
@ -475,18 +400,23 @@ export default function Screen() {
</div> </div>
)} )}
{messagesQuery.isFetchingNextPage && ( {messagesQuery.isFetchingNextPage && (
<div className="flex items-center justify-center pt-3 pb-1"> <div className="flex items-center justify-center pt-3 pb-1 scale-y-[-1]">
<Loader2 size={16} className="animate-spin text-foreground/45" /> <Loader2 size={16} className="animate-spin text-foreground/45" />
</div> </div>
)} )}
<div <div
className="relative w-full" className="relative w-full"
style={{ style={{ height: `${contentHeight}px` }}
height: `${contentHeight}px`,
}}
> >
<div
ref={topSentinelRef}
className="absolute bottom-0 left-0 h-px w-full"
/>
{virtualizer.getVirtualItems().map((virtualRow) => { {virtualizer.getVirtualItems().map((virtualRow) => {
if (shouldShowConversationStart && virtualRow.index === 0) { if (
shouldShowConversationStart &&
virtualRow.index === messageChunks.length
) {
return ( return (
<div <div
key="conversation-start" key="conversation-start"
@ -497,10 +427,10 @@ export default function Screen() {
top: 0, top: 0,
left: 0, left: 0,
width: "100%", width: "100%",
transform: `translateY(${virtualRow.start + verticalOffset}px)`, transform: `translateY(${verticalOffset + virtualRow.start}px)`,
}} }}
> >
<div className="w-full flex justify-start"> <div className="w-full flex justify-start scale-y-[-1]">
<div className="text-sm text-foreground/55 px-2.5"> <div className="text-sm text-foreground/55 px-2.5">
Conversation start Conversation start
</div> </div>
@ -509,14 +439,29 @@ export default function Screen() {
); );
} }
const messageIndex = shouldShowConversationStart const chunkIndex = virtualRow.index;
? virtualRow.index - 1 const chunk = messageChunks[chunkIndex];
: virtualRow.index; if (!chunk) {
const message = messages[messageIndex];
if (!message) {
return null; return null;
} }
return (
<div
key={chunk.key}
data-index={virtualRow.index}
className="flex flex-col"
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${verticalOffset + virtualRow.start}px)`,
}}
>
<div className="flex flex-col scale-y-[-1]">
{chunk.messages.map((message, chunkMessageIndex) => {
const messageIndex = chunk.startIndex + chunkMessageIndex;
const lastMessage = messages[messageIndex - 1]; const lastMessage = messages[messageIndex - 1];
const isGrouped = const isGrouped =
lastMessage && lastMessage &&
@ -525,19 +470,19 @@ export default function Screen() {
Math.round(message.send_time / 10000); Math.round(message.send_time / 10000);
return ( return (
<div <Message
key={message.send_time} key={message.send_time}
data-index={virtualRow.index} grouped={isGrouped}
ref={virtualizer.measureElement} message={message}
style={{ user={
position: "absolute", message.sent_by_self
top: 0, ? (messageUsers.own ?? null)
left: 0, : (messageUsers.peer ?? null)
width: "100%", }
transform: `translateY(${virtualRow.start + verticalOffset}px)`, />
}} );
> })}
<Message grouped={isGrouped} message={message} /> </div>
</div> </div>
); );
})} })}
@ -546,31 +491,6 @@ export default function Screen() {
<div className="z-10 shrink-0"> <div className="z-10 shrink-0">
<InputComponent setValue={setValue} value={value} /> <InputComponent setValue={setValue} value={value} />
</div> </div>
<div
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 -z-10 overflow-hidden opacity-0"
style={{ width: scrollWidth > 0 ? `${scrollWidth}px` : "100%" }}
>
{messages.map((message, messageIndex) => {
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 (
<div
key={`measure-${message.send_time}`}
ref={(element) => {
setMeasurementRef(message.send_time, element);
}}
>
<Message grouped={isGrouped} message={message} />
</div>
);
})}
</div>
</div> </div>
); );
} }

View file

@ -10,4 +10,6 @@ export type LiveMessage = RawMessage & {
localId: string; 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;