feat: add virtual list

This commit is contained in:
Alexander Daichendt 2025-02-13 15:12:59 +01:00
parent b4d693c3a8
commit 64d360a9c5
4 changed files with 94 additions and 39 deletions

View file

@ -22,6 +22,8 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.5",
"react-virtualized-auto-sizer": "^1.0.25",
"react-window": "^1.8.11",
"tailwindcss": "^4.0.6"
},
"devDependencies": {
@ -30,6 +32,7 @@
"@testing-library/react": "^16.2.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.19.0",
"eslint-plugin-react-hooks": "^5.0.0",

46
client/pnpm-lock.yaml generated
View file

@ -20,6 +20,12 @@ importers:
react-router-dom:
specifier: ^7.1.5
version: 7.1.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react-virtualized-auto-sizer:
specifier: ^1.0.25
version: 1.0.25(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react-window:
specifier: ^1.8.11
version: 1.8.11(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
tailwindcss:
specifier: ^4.0.6
version: 4.0.6
@ -39,6 +45,9 @@ importers:
'@types/react-dom':
specifier: ^19.0.3
version: 19.0.3(@types/react@19.0.8)
'@types/react-window':
specifier: ^1.8.8
version: 1.8.8
'@vitejs/plugin-react':
specifier: ^4.3.4
version: 4.3.4(vite@6.1.0(jiti@2.4.2)(lightningcss@1.29.1))
@ -656,6 +665,9 @@ packages:
peerDependencies:
'@types/react': ^19.0.0
'@types/react-window@1.8.8':
resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==}
'@types/react@19.0.8':
resolution: {integrity: sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==}
@ -1256,6 +1268,9 @@ packages:
magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
memoize-one@5.2.1:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@ -1389,6 +1404,19 @@ packages:
react-dom:
optional: true
react-virtualized-auto-sizer@1.0.25:
resolution: {integrity: sha512-YHsksEGDfsHbHuaBVDYwJmcktblcHGafz4ZVuYPQYuSHMUGjpwmUCrAOcvMSGMwwk1eFWj1M/1GwYpNPuyhaBg==}
peerDependencies:
react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0
react-window@1.8.11:
resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==}
engines: {node: '>8.0.0'}
peerDependencies:
react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react@19.0.0:
resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==}
engines: {node: '>=0.10.0'}
@ -2193,6 +2221,10 @@ snapshots:
dependencies:
'@types/react': 19.0.8
'@types/react-window@1.8.8':
dependencies:
'@types/react': 19.0.8
'@types/react@19.0.8':
dependencies:
csstype: 3.1.3
@ -2839,6 +2871,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
memoize-one@5.2.1: {}
merge2@1.4.1: {}
micromatch@4.0.8:
@ -2956,6 +2990,18 @@ snapshots:
optionalDependencies:
react-dom: 19.0.0(react@19.0.0)
react-virtualized-auto-sizer@1.0.25(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react-window@1.8.11(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@babel/runtime': 7.26.7
memoize-one: 5.2.1
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react@19.0.0: {}
redent@3.0.0:

View file

@ -39,7 +39,7 @@ function ChatMessageDisplay({
return (
<article
key={message.id}
className={`mb-4 ${isOwn ? "ml-auto" : "mr-auto"}`}
className={`${isOwn ? "ml-auto" : "mr-auto"}`}
aria-label={`Message from ${message.author.name} on ${timestamp}`}
>
<MessageHeader

View file

@ -1,9 +1,10 @@
import React, { useEffect, useRef } from "react";
import { FixedSizeList as List } from "react-window";
import AutoSizer from "react-virtualized-auto-sizer";
import {
ClientDeleteMessage,
ClientEditMessage,
ClientMessage,
ServerMessage,
} from "../../../shared";
import { useChatState } from "../contexts/ChatContext";
import ChatMessageDisplay from "./ChatMessage/ChatMessageDisplay";
@ -12,26 +13,16 @@ interface MessageDisplayProps {
sendMessage: (message: ClientMessage) => void;
}
function ChatMessages({ sendMessage }: MessageDisplayProps) {
function MessageDisplay({ sendMessage }: MessageDisplayProps) {
const { state } = useChatState();
const userId = state.currentUser?.userId;
const messagesEndRef = useRef<HTMLDivElement>(null); // ref to an empty div at the end to scroll to
// this counter is used to generate unique keys for join/leave messages. This is not ideal and with more time I'd introduce server-side message IDs for all messages, not just chat messages
let messageCounter = 0;
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
const listRef = useRef<List>(null);
const onDelete = (messageId: string) => {
const deleteMessage: ClientDeleteMessage = {
type: "DELETE_MESSAGE",
payload: {
messageId,
},
payload: { messageId },
};
sendMessage(deleteMessage);
};
@ -43,56 +34,71 @@ function ChatMessages({ sendMessage }: MessageDisplayProps) {
content: newContent,
},
};
sendMessage(editMessage);
};
// scroll to bottom when messages change
// Scroll to bottom when messages change
useEffect(() => {
scrollToBottom();
if (listRef.current) {
listRef.current.scrollToItem(state.messages.length - 1);
}
}, [state.messages]);
function renderMessage(message: ServerMessage) {
const Row = ({
index,
style,
}: {
index: number;
style: React.CSSProperties;
}) => {
const message = state.messages[index];
switch (message.type) {
case "CHAT_MESSAGE":
return (
<ChatMessageDisplay
message={message.payload}
userId={userId}
key={message.payload.id}
onDelete={onDelete}
onEdit={onEdit}
/>
<div style={style}>
<ChatMessageDisplay
message={message.payload}
userId={userId}
onDelete={onDelete}
onEdit={onEdit}
/>
</div>
);
case "USER_JOINED":
return (
<div
className="text-center text-gray-500 my-2"
key={`join-${message.payload.user.name}-${messageCounter++}`}
>
<div style={style} className="text-center text-gray-500 my-2">
{message.payload.user.name} joined!
</div>
);
case "USER_LEFT":
return (
<div
className="text-center text-gray-500 my-2"
key={`leave-${message.payload.user.name}-${messageCounter++}`}
>
<div style={style} className="text-center text-gray-500 my-2">
{message.payload.user.name} left!
</div>
);
default:
return null;
}
}
};
return (
<div className="min-h-[calc(100vh-theme('spacing.48'))] overflow-y-auto p-4">
{state.messages.map(renderMessage)}
<div ref={messagesEndRef} />
<div className="h-[calc(100vh-theme('spacing.48'))]">
<AutoSizer>
{({ height, width }) => (
<List
ref={listRef}
height={height}
width={width}
itemCount={state.messages.length}
itemSize={90} // sadly, this will make the system messages really tall. apparently its non trivial to fix that
>
{Row}
</List>
)}
</AutoSizer>
</div>
);
}
export default React.memo(ChatMessages);
export default React.memo(MessageDisplay);