client/packages/chat/src/screen.tsx
Alois 7ba08090fe
Some checks failed
/ release (push) Has been cancelled
/ build-desktop (linux) (push) Has been cancelled
/ build-web (push) Has been cancelled
/ build-mobile (push) Has been cancelled
(wip): add reactions
(qol): update todo
2026-07-11 14:53:12 +02:00

506 lines
15 KiB
TypeScript

import * as React from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import InputComponent from "./components/input";
import Message from "./components/message";
import { useChat } from "./context";
import {
PAGE_SIZE,
FALLBACK_MESSAGE_HEIGHT,
MESSAGES_PER_VIRTUAL_ROW,
type LiveMessage,
type RawMessage,
} from "./values";
import Wrapper from "@tensamin/user/wrapper";
import { Skeleton } from "@tensamin/ui";
type MessageChunk = {
key: string;
messages: Array<RawMessage | LiveMessage>;
startIndex: number;
};
function shouldFetchPreviousPage({
entry,
hasNextPage,
isFetchingNextPage,
userScrolledUp,
}: {
entry: IntersectionObserverEntry | undefined;
hasNextPage: boolean;
isFetchingNextPage: boolean;
userScrolledUp: boolean;
}) {
return [
entry?.isIntersecting === true,
userScrolledUp,
hasNextPage,
!isFetchingNextPage,
].every(Boolean);
}
function getMessageRenderKey(message: RawMessage | LiveMessage) {
return "localId" in message ? message.localId : String(message.SendTime);
}
function buildMessageChunks(
messages: Array<RawMessage | LiveMessage>,
keyPrefix: string,
startOffset = 0,
) {
const chunks: MessageChunk[] = [];
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: `${keyPrefix}-${getMessageRenderKey(firstMessage)}`,
messages: chunkMessages,
startIndex: startOffset + start,
});
}
}
return chunks;
}
/**
* Renders the chat screen with virtualized history and live message updates.
* @returns Chat screen JSX.
*/
export default function Screen() {
const {
getMessages,
liveMessages,
clearLiveMessages,
userId,
chatSecret,
error,
errorDescription,
} = useChat();
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const topSentinelRef = React.useRef<HTMLDivElement | null>(null);
const didInitialScrollRef = React.useRef(false);
const userScrolledUpRef = React.useRef(false);
const isAtBottomRef = React.useRef(true);
const smoothScrollFrameRef = React.useRef<number | null>(null);
const smoothScrollTargetRef = React.useRef(0);
const hasNextPageRef = React.useRef(false);
const isFetchingNextPageRef = React.useRef(false);
const fetchNextPageRef = React.useRef<(() => void) | null>(null);
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 hasChatSecret = chatSecret !== null;
const messagesQuery = useInfiniteQuery({
queryKey: ["chat-messages", String(userId), hasChatSecret],
initialPageParam: 0,
queryFn: ({ pageParam }) => getMessages(PAGE_SIZE, Number(pageParam)),
enabled: hasValidChatUser && hasChatSecret,
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length < PAGE_SIZE) {
return undefined;
}
return allPages.length * PAGE_SIZE;
},
});
const {
fetchNextPage: fetchMessagesNextPage,
hasNextPage: hasMessagesNextPage,
isFetchingNextPage: isFetchingMessagesNextPage,
} = messagesQuery;
React.useEffect(() => {
hasNextPageRef.current = hasMessagesNextPage;
isFetchingNextPageRef.current = isFetchingMessagesNextPage;
fetchNextPageRef.current = () => {
void fetchMessagesNextPage();
};
}, [fetchMessagesNextPage, hasMessagesNextPage, isFetchingMessagesNextPage]);
React.useEffect(() => {
clearLiveMessages();
didInitialScrollRef.current = false;
userScrolledUpRef.current = false;
isAtBottomRef.current = true;
setDidInitialScroll(false);
setLastLiveMessageCount(0);
}, [clearLiveMessages, userId]);
const historicalMessages = React.useMemo(() => {
const pages = messagesQuery.data?.pages ?? [];
const seenSendTimes = new Set<number>();
const dedupedMessages: RawMessage[] = [];
for (const message of [...pages].reverse().flat()) {
if (seenSendTimes.has(message.SendTime)) {
continue;
}
seenSendTimes.add(message.SendTime);
dedupedMessages.push(message);
}
return dedupedMessages;
}, [messagesQuery.data]);
const liveMessagesSnapshot = liveMessages();
const liveWithoutDuplicates = React.useMemo(() => {
const historicalSendTimes = new Set(
historicalMessages.map((message) => message.SendTime),
);
return liveMessagesSnapshot.filter(
(message) => !historicalSendTimes.has(message.SendTime),
);
}, [historicalMessages, liveMessagesSnapshot]);
const messages = React.useMemo(() => {
return [...historicalMessages, ...liveWithoutDuplicates];
}, [historicalMessages, liveWithoutDuplicates]);
const historicalMessageChunks = React.useMemo(() => {
return buildMessageChunks(historicalMessages, "history");
}, [historicalMessages]);
const liveMessageChunks = React.useMemo(() => {
return buildMessageChunks(
liveWithoutDuplicates,
"live",
historicalMessages.length,
);
}, [historicalMessages.length, liveWithoutDuplicates]);
const messageChunks = React.useMemo(() => {
return [...liveMessageChunks, ...historicalMessageChunks];
}, [historicalMessageChunks, liveMessageChunks]);
const virtualRowCount = messageChunks.length;
const getItemKey = React.useCallback(
(index: number) => messageChunks[index]?.key ?? index,
[messageChunks],
);
const estimateSize = React.useCallback(() => FALLBACK_MESSAGE_HEIGHT, []);
// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
count: virtualRowCount,
getScrollElement: () => scrollRef.current,
getItemKey,
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;
if (!element || typeof ResizeObserver === "undefined") {
return;
}
const updateViewportHeight = () => {
setViewportHeight(element.clientHeight);
};
updateViewportHeight();
const observer = new ResizeObserver(updateViewportHeight);
observer.observe(element);
return () => {
observer.disconnect();
};
}, []);
React.useLayoutEffect(() => {
if (didInitialScrollRef.current || virtualRowCount === 0) {
return;
}
requestAnimationFrame(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
requestAnimationFrame(() => {
didInitialScrollRef.current = true;
setDidInitialScroll(true);
});
});
}, [virtualizer, virtualRowCount]);
React.useLayoutEffect(() => {
const count = liveMessagesSnapshot.length;
if (count > lastLiveMessageCount && isAtBottomRef.current) {
requestAnimationFrame(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
});
}
setLastLiveMessageCount(count);
}, [
lastLiveMessageCount,
liveMessagesSnapshot.length,
virtualRowCount,
virtualizer,
]);
React.useEffect(() => {
if (
viewportHeight === 0 ||
totalSize > viewportHeight ||
messagesQuery.isFetchingNextPage ||
!messagesQuery.hasNextPage
) {
return;
}
void messagesQuery.fetchNextPage();
}, [didInitialScroll, messagesQuery, totalSize, viewportHeight]);
React.useLayoutEffect(() => {
if (
!didInitialScroll ||
userScrolledUpRef.current ||
virtualRowCount === 0
) {
return;
}
requestAnimationFrame(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
});
}, [didInitialScroll, virtualRowCount]);
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;
}
smoothScrollTargetRef.current = element.scrollTop;
const animateScroll = () => {
const distance = smoothScrollTargetRef.current - element.scrollTop;
if (Math.abs(distance) < 0.5) {
element.scrollTop = smoothScrollTargetRef.current;
smoothScrollFrameRef.current = null;
handleContainerScroll();
return;
}
element.scrollTop += distance * 0.35;
handleContainerScroll();
smoothScrollFrameRef.current = requestAnimationFrame(animateScroll);
};
const fetchNextPageNearTop = (scrollTop: number, maxScrollTop: number) => {
if (didInitialScrollRef.current && scrollTop > 140) {
userScrolledUpRef.current = true;
}
if (
scrollTop >= maxScrollTop - 240 &&
userScrolledUpRef.current &&
hasNextPageRef.current &&
!isFetchingNextPageRef.current
) {
fetchNextPageRef.current?.();
}
};
const handleWheel = (event: WheelEvent) => {
event.preventDefault();
event.stopPropagation();
if (smoothScrollFrameRef.current === null) {
smoothScrollTargetRef.current = element.scrollTop;
}
const maxScrollTop = Math.max(
0,
element.scrollHeight - element.clientHeight,
);
smoothScrollTargetRef.current = Math.min(
Math.max(0, smoothScrollTargetRef.current - event.deltaY),
maxScrollTop,
);
fetchNextPageNearTop(smoothScrollTargetRef.current, maxScrollTop);
if (smoothScrollFrameRef.current === null) {
smoothScrollFrameRef.current = requestAnimationFrame(animateScroll);
}
};
element.addEventListener("wheel", handleWheel, { passive: false });
return () => {
element.removeEventListener("wheel", handleWheel);
if (smoothScrollFrameRef.current !== null) {
cancelAnimationFrame(smoothScrollFrameRef.current);
smoothScrollFrameRef.current = null;
}
};
}, [handleContainerScroll]);
if (!hasValidChatUser) {
return (
<div className="w-full h-full flex flex-col gap-2 items-center justify-center">
<p className="font-semibold text-xl">Invalid User</p>
</div>
);
}
return (
<div className="relative flex h-full min-h-0 w-full flex-col overflow-hidden">
{error !== "" && errorDescription !== "" ? (
<div className="w-full h-full flex flex-col gap-2 items-center justify-center">
<p className="font-semibold text-xl">{error}</p>
<p className="text-muted-foreground text-lg">{errorDescription}</p>
</div>
) : (
<>
<div
ref={scrollRef}
id="chat_container"
className="min-h-0 flex-1 overflow-y-auto"
style={{
overflowAnchor: "none",
paddingTop: "22px",
transform: "scaleY(-1)",
}}
onScroll={handleContainerScroll}
>
<div
className="relative w-full"
style={{ height: `${contentHeight}px` }}
>
<div
ref={topSentinelRef}
className="absolute bottom-0 left-0 h-px w-full"
/>
{virtualizer.getVirtualItems().map((virtualRow) => {
const chunkIndex = virtualRow.index;
const chunk = messageChunks[chunkIndex];
if (!chunk) {
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 isGrouped =
lastMessage &&
lastMessage.SenderId === message.SenderId &&
Math.round(lastMessage.SendTime / 10000) ===
Math.round(message.SendTime / 10000);
return (
<Wrapper
key={getMessageRenderKey(message)}
userId={message.SenderId}
loading={<Skeleton className="w-30 h-5" />}
component={(user) => (
<Message
grouped={isGrouped}
message={message}
user={user}
/>
)}
/>
);
})}
</div>
</div>
);
})}
</div>
</div>
<div className="z-10 shrink-0">
<InputComponent setValue={setValue} value={value} />
</div>
</>
)}
</div>
);
}