feat: add virtual list
This commit is contained in:
parent
b4d693c3a8
commit
64d360a9c5
4 changed files with 94 additions and 39 deletions
|
|
@ -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
46
client/pnpm-lock.yaml
generated
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue