540 lines
15 KiB
TypeScript
540 lines
15 KiB
TypeScript
import * as React from "react";
|
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
import { useChat } from "./context";
|
|
|
|
import InputComponent from "./components/input";
|
|
import Message from "./components/message";
|
|
|
|
import { PAGE_SIZE } from "./values";
|
|
import { useIsMobile } from "@tensamin/ui";
|
|
|
|
function getDistanceFromBottom(element: HTMLDivElement) {
|
|
return element.scrollHeight - (element.scrollTop + element.clientHeight);
|
|
}
|
|
|
|
const FALLBACK_MESSAGE_HEIGHT = 56;
|
|
|
|
/**
|
|
* 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 scrollRef = React.useRef<HTMLDivElement | null>(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<number, number>
|
|
>({});
|
|
const measurementRefs = React.useRef(new Map<number, HTMLDivElement>());
|
|
const shouldRestoreBottomOnResizeRef = React.useRef(false);
|
|
const isAtBottomRef = React.useRef(true);
|
|
const shouldRestoreBottomOnFocusResizeRef = React.useRef(false);
|
|
|
|
const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0;
|
|
const hasSharedSecret = sharedSecret.length > 0;
|
|
|
|
const messagesQuery = useInfiniteQuery({
|
|
queryKey: ["chat-messages", String(userId), hasSharedSecret],
|
|
initialPageParam: 0,
|
|
queryFn: ({ pageParam }) => getMessages(PAGE_SIZE, Number(pageParam)),
|
|
enabled: hasValidChatUser && hasSharedSecret,
|
|
getNextPageParam: (lastPage, allPages) => {
|
|
if (lastPage.length < PAGE_SIZE) {
|
|
return undefined;
|
|
}
|
|
|
|
return allPages.length * PAGE_SIZE;
|
|
},
|
|
});
|
|
|
|
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(() => {
|
|
if (historicalMessages.length === 0) {
|
|
return liveMessagesSnapshot;
|
|
}
|
|
|
|
const historicalSendTimes = new Set(
|
|
historicalMessages.map((message) => message.send_time),
|
|
);
|
|
const liveWithoutDuplicates = liveMessagesSnapshot.filter(
|
|
(message) => !historicalSendTimes.has(message.send_time),
|
|
);
|
|
|
|
return [...historicalMessages, ...liveWithoutDuplicates];
|
|
}, [historicalMessages, liveMessagesSnapshot]);
|
|
const shouldShowConversationStart =
|
|
!!messagesQuery.data && !messagesQuery.hasNextPage;
|
|
const virtualRowCount =
|
|
messages.length + (shouldShowConversationStart ? 1 : 0);
|
|
|
|
const messagesRef = React.useRef(messages);
|
|
|
|
React.useEffect(() => {
|
|
messagesRef.current = messages;
|
|
}, [messages]);
|
|
|
|
const getItemKey = React.useCallback(
|
|
(index: number) => {
|
|
if (shouldShowConversationStart && index === 0) {
|
|
return "conversation-start";
|
|
}
|
|
|
|
const messageIndex = shouldShowConversationStart ? index - 1 : index;
|
|
return messagesRef.current[messageIndex]?.send_time ?? index;
|
|
},
|
|
[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(
|
|
(index: number) => {
|
|
if (shouldShowConversationStart && index === 0) {
|
|
return FALLBACK_MESSAGE_HEIGHT;
|
|
}
|
|
|
|
const messageIndex = shouldShowConversationStart ? index - 1 : index;
|
|
const sendTime = messages[messageIndex]?.send_time;
|
|
|
|
if (sendTime === undefined) {
|
|
return FALLBACK_MESSAGE_HEIGHT;
|
|
}
|
|
|
|
return measuredHeights[sendTime] ?? FALLBACK_MESSAGE_HEIGHT;
|
|
},
|
|
[measuredHeights, messages, shouldShowConversationStart],
|
|
);
|
|
|
|
// eslint-disable-next-line react-hooks/incompatible-library
|
|
const virtualizer = useVirtualizer({
|
|
count: virtualRowCount,
|
|
getScrollElement: () => scrollRef.current,
|
|
getItemKey,
|
|
estimateSize: estimateMessageSize,
|
|
overscan: isMobile ? 10 : 6,
|
|
});
|
|
|
|
React.useLayoutEffect(() => {
|
|
const element = scrollRef.current;
|
|
if (!element || typeof ResizeObserver === "undefined") {
|
|
return;
|
|
}
|
|
|
|
const updateDimensions = () => {
|
|
setScrollWidth(element.clientWidth);
|
|
setScrollHeight(element.clientHeight);
|
|
};
|
|
|
|
updateDimensions();
|
|
|
|
const observer = new ResizeObserver(updateDimensions);
|
|
observer.observe(element);
|
|
|
|
return () => {
|
|
observer.disconnect();
|
|
};
|
|
}, []);
|
|
|
|
React.useLayoutEffect(() => {
|
|
const frame = requestAnimationFrame(() => {
|
|
virtualizer.measure();
|
|
});
|
|
|
|
return () => {
|
|
cancelAnimationFrame(frame);
|
|
};
|
|
}, [inputHeight, measuredHeights, scrollWidth, virtualizer]);
|
|
|
|
React.useLayoutEffect(() => {
|
|
if (typeof ResizeObserver === "undefined") {
|
|
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(() => {
|
|
virtualizer.measure();
|
|
|
|
requestAnimationFrame(() => {
|
|
if (!scrollRef.current || !shouldRestoreBottomOnResizeRef.current) {
|
|
return;
|
|
}
|
|
|
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
isAtBottomRef.current = true;
|
|
shouldRestoreBottomOnFocusResizeRef.current = false;
|
|
shouldRestoreBottomOnResizeRef.current = false;
|
|
});
|
|
});
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
isAtBottomRef.current = true;
|
|
setHasScrolledToBottomInitially(true);
|
|
}, [hasScrolledToBottomInitially, 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;
|
|
}
|
|
}
|
|
|
|
setLastLiveMessageCount(count);
|
|
}, [
|
|
lastLiveMessageCount,
|
|
liveMessagesSnapshot.length,
|
|
stickyBottomThreshold,
|
|
]);
|
|
|
|
React.useLayoutEffect(() => {
|
|
if (
|
|
!scrollRef.current ||
|
|
!prependAnchor ||
|
|
messagesQuery.isFetchingNextPage
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const delta = virtualizer.getTotalSize() - prependAnchor.totalSize;
|
|
scrollRef.current.scrollTop = prependAnchor.scrollTop + delta;
|
|
setPrependAnchor(null);
|
|
}, [messagesQuery.isFetchingNextPage, prependAnchor, virtualizer]);
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
void onScroll();
|
|
}, [onScroll, stickyBottomThreshold]);
|
|
|
|
const [value, setValue] = React.useState("");
|
|
const totalSize = virtualizer.getTotalSize();
|
|
const verticalOffset = Math.max(0, scrollHeight - totalSize);
|
|
const contentHeight = Math.max(totalSize, scrollHeight);
|
|
|
|
// Render
|
|
if (!hasValidChatUser) {
|
|
return (
|
|
<div className="w-full h-full flex items-center justify-center text-xl text-foreground/80">
|
|
Invalid user
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="relative flex h-full min-h-0 w-full flex-col overflow-hidden">
|
|
<div
|
|
ref={scrollRef}
|
|
id="chat_container"
|
|
className="min-h-0 flex-1 overflow-y-auto"
|
|
style={{
|
|
overflowAnchor: "none",
|
|
paddingBottom: "8px",
|
|
}}
|
|
onScroll={handleContainerScroll}
|
|
>
|
|
<div
|
|
className="relative w-full"
|
|
style={{
|
|
height: `${contentHeight}px`,
|
|
}}
|
|
>
|
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
if (shouldShowConversationStart && virtualRow.index === 0) {
|
|
return (
|
|
<div
|
|
key="conversation-start"
|
|
data-index={virtualRow.index}
|
|
ref={virtualizer.measureElement}
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
width: "100%",
|
|
transform: `translateY(${virtualRow.start + verticalOffset}px)`,
|
|
padding: "4px 0",
|
|
}}
|
|
>
|
|
<div className="w-full flex justify-start">
|
|
<div className="text-sm text-foreground/55 px-2.5">
|
|
Conversation start
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const messageIndex = shouldShowConversationStart
|
|
? virtualRow.index - 1
|
|
: virtualRow.index;
|
|
const message = messages[messageIndex];
|
|
if (!message) {
|
|
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 (
|
|
<div
|
|
key={message.send_time}
|
|
data-index={virtualRow.index}
|
|
ref={virtualizer.measureElement}
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
width: "100%",
|
|
transform: `translateY(${virtualRow.start + verticalOffset}px)`,
|
|
}}
|
|
>
|
|
<Message grouped={isGrouped} message={message} />
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div className="z-10 shrink-0">
|
|
<InputComponent setValue={setValue} value={value} />
|
|
</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>
|
|
);
|
|
}
|