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-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
46
client/pnpm-lock.yaml
generated
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div style={style}>
|
||||
<ChatMessageDisplay
|
||||
message={message.payload}
|
||||
userId={userId}
|
||||
key={message.payload.id}
|
||||
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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue