diff --git a/packages/chat/src/components/input.tsx b/packages/chat/src/components/input.tsx index c42cf59..52397ad 100644 --- a/packages/chat/src/components/input.tsx +++ b/packages/chat/src/components/input.tsx @@ -21,7 +21,7 @@ export default function InputComponent({ const [invertEnterBehavior, setInvertEnterBehavior] = React.useState(false); const { send } = useTTP(); - const { addLiveMessage, sharedSecret, userId } = useChat(); + const { addLiveMessage, sharedSecret, userId, inputBoxRef } = useChat(); const { load } = useStorage(); React.useEffect(() => { @@ -85,6 +85,7 @@ export default function InputComponent({ return ( (null); const isMobile = useIsMobile(); @@ -35,6 +40,12 @@ export default function Screen() { totalSize: number; scrollTop: number; } | null>(null); + const [scrollWidth, setScrollWidth] = React.useState(0); + const [inputHeight, setInputHeight] = React.useState(0); + const [measuredHeights, setMeasuredHeights] = React.useState< + Record + >({}); + const measurementRefs = React.useRef(new Map()); const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0; const hasSharedSecret = sharedSecret.length > 0; @@ -92,15 +103,159 @@ export default function Screen() { return messagesRef.current[index]?.send_time ?? index; }, []); + 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) => { + const sendTime = messages[index]?.send_time; + + if (sendTime === undefined) { + return FALLBACK_MESSAGE_HEIGHT; + } + + return measuredHeights[sendTime] ?? FALLBACK_MESSAGE_HEIGHT; + }, + [measuredHeights, messages], + ); + // eslint-disable-next-line react-hooks/incompatible-library const virtualizer = useVirtualizer({ count: messages.length, getScrollElement: () => scrollRef.current, getItemKey, - estimateSize: () => MESSAGE_ROW_HEIGHT + ROW_VERTICAL_PADDING, + estimateSize: estimateMessageSize, overscan: isMobile ? 10 : 6, }); + React.useLayoutEffect(() => { + const element = scrollRef.current; + if (!element || typeof ResizeObserver === "undefined") { + return; + } + + const updateWidth = () => { + setScrollWidth(element.clientWidth); + }; + + updateWidth(); + + const observer = new ResizeObserver(updateWidth); + 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 = {}; + + 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 handleResize = () => { + requestAnimationFrame(() => { + virtualizer.measure(); + }); + }; + + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, [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]); + /** * Loads the next page when the scroll container reaches the top. * @returns Promise that resolves once pagination handling completes. @@ -196,13 +351,15 @@ export default function Screen() { } return ( -
+
@@ -238,12 +395,26 @@ export default function Screen() { })}
-
+
+
); } diff --git a/todo.md b/todo.md index e214fb1..58f4d9e 100644 --- a/todo.md +++ b/todo.md @@ -1,5 +1,2 @@ -- Calculate message height and pass to virtualizer (-> packages/chat/src/components/input.tsx) -- Add "Rename Conversations to Friends" option in settings - Handle live messages in notification context -- Add files to message height calculation - Mobile message box broken