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

46
client/pnpm-lock.yaml generated
View file

@ -20,6 +20,12 @@ importers:
react-router-dom: react-router-dom:
specifier: ^7.1.5 specifier: ^7.1.5
version: 7.1.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 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: tailwindcss:
specifier: ^4.0.6 specifier: ^4.0.6
version: 4.0.6 version: 4.0.6
@ -39,6 +45,9 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: ^19.0.3 specifier: ^19.0.3
version: 19.0.3(@types/react@19.0.8) version: 19.0.3(@types/react@19.0.8)
'@types/react-window':
specifier: ^1.8.8
version: 1.8.8
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^4.3.4 specifier: ^4.3.4
version: 4.3.4(vite@6.1.0(jiti@2.4.2)(lightningcss@1.29.1)) version: 4.3.4(vite@6.1.0(jiti@2.4.2)(lightningcss@1.29.1))
@ -656,6 +665,9 @@ packages:
peerDependencies: peerDependencies:
'@types/react': ^19.0.0 '@types/react': ^19.0.0
'@types/react-window@1.8.8':
resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==}
'@types/react@19.0.8': '@types/react@19.0.8':
resolution: {integrity: sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==} resolution: {integrity: sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==}
@ -1256,6 +1268,9 @@ packages:
magic-string@0.30.17: magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} 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: merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -1389,6 +1404,19 @@ packages:
react-dom: react-dom:
optional: true 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: react@19.0.0:
resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2193,6 +2221,10 @@ snapshots:
dependencies: dependencies:
'@types/react': 19.0.8 '@types/react': 19.0.8
'@types/react-window@1.8.8':
dependencies:
'@types/react': 19.0.8
'@types/react@19.0.8': '@types/react@19.0.8':
dependencies: dependencies:
csstype: 3.1.3 csstype: 3.1.3
@ -2839,6 +2871,8 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/sourcemap-codec': 1.5.0
memoize-one@5.2.1: {}
merge2@1.4.1: {} merge2@1.4.1: {}
micromatch@4.0.8: micromatch@4.0.8:
@ -2956,6 +2990,18 @@ snapshots:
optionalDependencies: optionalDependencies:
react-dom: 19.0.0(react@19.0.0) 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: {} react@19.0.0: {}
redent@3.0.0: redent@3.0.0:

View file

@ -39,7 +39,7 @@ function ChatMessageDisplay({
return ( return (
<article <article
key={message.id} 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}`} aria-label={`Message from ${message.author.name} on ${timestamp}`}
> >
<MessageHeader <MessageHeader

View file

@ -1,9 +1,10 @@
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import { FixedSizeList as List } from "react-window";
import AutoSizer from "react-virtualized-auto-sizer";
import { import {
ClientDeleteMessage, ClientDeleteMessage,
ClientEditMessage, ClientEditMessage,
ClientMessage, ClientMessage,
ServerMessage,
} from "../../../shared"; } from "../../../shared";
import { useChatState } from "../contexts/ChatContext"; import { useChatState } from "../contexts/ChatContext";
import ChatMessageDisplay from "./ChatMessage/ChatMessageDisplay"; import ChatMessageDisplay from "./ChatMessage/ChatMessageDisplay";
@ -12,26 +13,16 @@ interface MessageDisplayProps {
sendMessage: (message: ClientMessage) => void; sendMessage: (message: ClientMessage) => void;
} }
function ChatMessages({ sendMessage }: MessageDisplayProps) { function MessageDisplay({ sendMessage }: MessageDisplayProps) {
const { state } = useChatState(); const { state } = useChatState();
const userId = state.currentUser?.userId; const userId = state.currentUser?.userId;
const messagesEndRef = useRef<HTMLDivElement>(null); // ref to an empty div at the end to scroll to const listRef = useRef<List>(null);
// 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 onDelete = (messageId: string) => { const onDelete = (messageId: string) => {
const deleteMessage: ClientDeleteMessage = { const deleteMessage: ClientDeleteMessage = {
type: "DELETE_MESSAGE", type: "DELETE_MESSAGE",
payload: { payload: { messageId },
messageId,
},
}; };
sendMessage(deleteMessage); sendMessage(deleteMessage);
}; };
@ -43,56 +34,71 @@ function ChatMessages({ sendMessage }: MessageDisplayProps) {
content: newContent, content: newContent,
}, },
}; };
sendMessage(editMessage); sendMessage(editMessage);
}; };
// scroll to bottom when messages change // Scroll to bottom when messages change
useEffect(() => { useEffect(() => {
scrollToBottom(); if (listRef.current) {
listRef.current.scrollToItem(state.messages.length - 1);
}
}, [state.messages]); }, [state.messages]);
function renderMessage(message: ServerMessage) { const Row = ({
index,
style,
}: {
index: number;
style: React.CSSProperties;
}) => {
const message = state.messages[index];
switch (message.type) { switch (message.type) {
case "CHAT_MESSAGE": case "CHAT_MESSAGE":
return ( return (
<ChatMessageDisplay <div style={style}>
message={message.payload} <ChatMessageDisplay
userId={userId} message={message.payload}
key={message.payload.id} userId={userId}
onDelete={onDelete} onDelete={onDelete}
onEdit={onEdit} onEdit={onEdit}
/> />
</div>
); );
case "USER_JOINED": case "USER_JOINED":
return ( return (
<div <div style={style} className="text-center text-gray-500 my-2">
className="text-center text-gray-500 my-2"
key={`join-${message.payload.user.name}-${messageCounter++}`}
>
{message.payload.user.name} joined! {message.payload.user.name} joined!
</div> </div>
); );
case "USER_LEFT": case "USER_LEFT":
return ( return (
<div <div style={style} className="text-center text-gray-500 my-2">
className="text-center text-gray-500 my-2"
key={`leave-${message.payload.user.name}-${messageCounter++}`}
>
{message.payload.user.name} left! {message.payload.user.name} left!
</div> </div>
); );
default: default:
return null; return null;
} }
} };
return ( return (
<div className="min-h-[calc(100vh-theme('spacing.48'))] overflow-y-auto p-4"> <div className="h-[calc(100vh-theme('spacing.48'))]">
{state.messages.map(renderMessage)} <AutoSizer>
<div ref={messagesEndRef} /> {({ 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> </div>
); );
} }
export default React.memo(ChatMessages); export default React.memo(MessageDisplay);