diff --git a/client/package.json b/client/package.json index a3f7a0b..26ac2b1 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index d24d74b..13a6795 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -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: diff --git a/client/src/components/ChatMessage/ChatMessageDisplay.tsx b/client/src/components/ChatMessage/ChatMessageDisplay.tsx index dbea6bd..b2ff29e 100644 --- a/client/src/components/ChatMessage/ChatMessageDisplay.tsx +++ b/client/src/components/ChatMessage/ChatMessageDisplay.tsx @@ -39,7 +39,7 @@ function ChatMessageDisplay({ return (
void; } -function ChatMessages({ sendMessage }: MessageDisplayProps) { +function MessageDisplay({ sendMessage }: MessageDisplayProps) { const { state } = useChatState(); const userId = state.currentUser?.userId; - const messagesEndRef = useRef(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(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 ( - +
+ +
); case "USER_JOINED": return ( -
+
{message.payload.user.name} joined!
); case "USER_LEFT": return ( -
+
{message.payload.user.name} left!
); default: return null; } - } + }; return ( -
- {state.messages.map(renderMessage)} -
+
+ + {({ height, width }) => ( + + {Row} + + )} +
); } -export default React.memo(ChatMessages); +export default React.memo(MessageDisplay);