(fix): message height calculation
This commit is contained in:
parent
de63b554c1
commit
f96d84f95e
3 changed files with 183 additions and 14 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
todo.md
3
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
|
- Handle live messages in notification context
|
||||||
- Add files to message height calculation
|
|
||||||
- Mobile message box broken
|
- Mobile message box broken
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue