(fix): message height calculation

This commit is contained in:
Alois 2026-05-01 12:35:00 +02:00
commit f96d84f95e
3 changed files with 183 additions and 14 deletions

View file

@ -21,7 +21,7 @@ export default function InputComponent({
const [invertEnterBehavior, setInvertEnterBehavior] = React.useState(false); const [invertEnterBehavior, setInvertEnterBehavior] = React.useState(false);
const { send } = useTTP(); const { send } = useTTP();
const { addLiveMessage, sharedSecret, userId } = useChat(); const { addLiveMessage, sharedSecret, userId, inputBoxRef } = useChat();
const { load } = useStorage(); const { load } = useStorage();
React.useEffect(() => { React.useEffect(() => {
@ -85,6 +85,7 @@ export default function InputComponent({
return ( return (
<Card <Card
ref={inputBoxRef}
className={cn( className={cn(
"rounded-none border-b-0 pt-0 pb-[env(safe-area-inset-bottom)]", "rounded-none border-b-0 pt-0 pb-[env(safe-area-inset-bottom)]",
isMobile isMobile

View file

@ -13,16 +13,21 @@ function getDistanceFromBottom(element: HTMLDivElement) {
return element.scrollHeight - (element.scrollTop + element.clientHeight); return element.scrollHeight - (element.scrollTop + element.clientHeight);
} }
const MESSAGE_ROW_HEIGHT = 48; const FALLBACK_MESSAGE_HEIGHT = 56;
const ROW_VERTICAL_PADDING = 8;
/** /**
* 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 { getMessages, liveMessages, clearLiveMessages, userId, sharedSecret } = const {
useChat(); getMessages,
liveMessages,
clearLiveMessages,
userId,
sharedSecret,
inputBoxRef,
} = useChat();
const scrollRef = React.useRef<HTMLDivElement | null>(null); const scrollRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -35,6 +40,12 @@ export default function Screen() {
totalSize: number; totalSize: number;
scrollTop: number; scrollTop: number;
} | null>(null); } | null>(null);
const [scrollWidth, setScrollWidth] = 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 hasValidChatUser = Number.isSafeInteger(userId) && userId > 0; const hasValidChatUser = Number.isSafeInteger(userId) && userId > 0;
const hasSharedSecret = sharedSecret.length > 0; const hasSharedSecret = sharedSecret.length > 0;
@ -92,15 +103,159 @@ export default function Screen() {
return messagesRef.current[index]?.send_time ?? index; 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 // eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: messages.length, count: messages.length,
getScrollElement: () => scrollRef.current, getScrollElement: () => scrollRef.current,
getItemKey, getItemKey,
estimateSize: () => MESSAGE_ROW_HEIGHT + ROW_VERTICAL_PADDING, estimateSize: estimateMessageSize,
overscan: isMobile ? 10 : 6, 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<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 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. * Loads the next page when the scroll container reaches the top.
* @returns Promise that resolves once pagination handling completes. * @returns Promise that resolves once pagination handling completes.
@ -196,13 +351,15 @@ export default function Screen() {
} }
return ( return (
<div className="w-full h-full flex flex-col px-2"> <div className="relative w-full h-full overflow-hidden px-2">
<div <div
ref={scrollRef} ref={scrollRef}
id="chat_container" id="chat_container"
className="flex-1 overflow-y-auto px-2.5 pb-32" className="absolute inset-x-0 top-0 overflow-y-auto px-2.5"
style={{ style={{
bottom: `${inputHeight}px`,
overflowAnchor: "none", overflowAnchor: "none",
paddingBottom: "8px",
}} }}
onScroll={handleContainerScroll} onScroll={handleContainerScroll}
> >
@ -238,12 +395,26 @@ export default function Screen() {
})} })}
</div> </div>
</div> </div>
<div <div className="absolute inset-x-0 bottom-0 z-10">
className="fixed -bottom-15 pb-15"
style={{ width: "calc(100vw - 277px)" }}
>
<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) => (
<div
key={`measure-${message.send_time}`}
ref={(element) => {
setMeasurementRef(message.send_time, element);
}}
style={{ padding: "4px 0" }}
>
<Message message={message} />
</div>
))}
</div>
</div> </div>
); );
} }

View file

@ -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 - Handle live messages in notification context
- Add files to message height calculation
- Mobile message box broken - Mobile message box broken