-
Notifications
You must be signed in to change notification settings - Fork 275
feat: implement chat using websocket #132
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
9a861ed
6861111
4b2ef46
791b55a
87f7fe2
fab06bb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| .messages { | ||
| display: flex; | ||
| flex-direction: column-reverse; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using |
||
| min-height: 200px; | ||
|
Comment on lines
+1
to
+4
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider making the message container a normal column flow and using |
||
| max-height: 200px; | ||
|
Comment on lines
+4
to
+5
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using both |
||
| overflow: scroll; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prefer |
||
| } | ||
|
Comment on lines
+1
to
+7
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are no styles for individual message fields like |
||
|
|
||
| .input { | ||
| display: flex; | ||
| gap: 24px; | ||
| } | ||
|
|
||
| .inputs { | ||
|
Comment on lines
+9
to
+14
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are two similar class names: |
||
| display: flex; | ||
| gap: 24px; | ||
| } | ||
|
|
||
| .room { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider adding styles for room items (e.g. |
||
| display: flex; | ||
| gap: 8px; | ||
| } | ||
|
|
||
| .roomsList { | ||
| display: flex; | ||
| flex-direction: column; | ||
|
|
||
| gap: 8px; | ||
|
Comment on lines
+26
to
+28
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Small readability note: |
||
| } | ||
|
|
||
| .roomName { | ||
| margin: 0; | ||
| margin-right: 16px; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,259 @@ | ||
| import React, { useEffect, useState } from 'react'; | ||
| import './App.css'; | ||
| import Username from './Username'; | ||
|
|
||
| type Message = { | ||
| id: number, | ||
| text: string, | ||
| author: string, | ||
| time: Date, | ||
|
Comment on lines
+5
to
+9
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Type mismatch: |
||
| roomId: number, | ||
| } | ||
|
Comment on lines
+5
to
+11
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Your |
||
|
|
||
| type Room = { | ||
| id: number, | ||
| name: string, | ||
| } | ||
|
|
||
| const App: React.FC = () => { | ||
| const [messages, setMessages] = useState<Message[]>([]); | ||
| const [rooms, setRooms] = useState<Room[]>([]); | ||
|
|
||
| const [currentRoom, setCurrentRoom] = useState<number | null>(null); | ||
|
|
||
| const [messageInputValue, setMessageInputValue] = useState<string>(''); | ||
| const [roomNameInputValue, setRoomNameInputValue] = useState<string>(''); | ||
|
|
||
| const [roomRenameInputValue, setRoomRenameInputValue] = useState<string>(''); | ||
|
|
||
| const [username, setUsername] = | ||
| useState(localStorage.getItem('username') || ''); | ||
|
Comment on lines
+29
to
+30
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You initialize username from localStorage here, but there is no code in this file that persists username back to localStorage when it changes or that explicitly notifies the server of the chosen username. The task requires that the client both persist the username in localStorage and send it to the server (Reqs 1.2, 1.3, 2.2, 3.1). If the |
||
|
|
||
| const [socket, setSocket] = useState<WebSocket | null>(null); | ||
|
|
||
| const handleMessageSend = () => { | ||
| if (!username || !currentRoom) return; | ||
| if (messageInputValue && socket) { | ||
| const message = { | ||
| text: messageInputValue, | ||
| time: new Date(), | ||
| author: username, | ||
| roomId: currentRoom, | ||
| }; | ||
| socket.send(JSON.stringify(message)); | ||
|
Comment on lines
+37
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When constructing the outgoing message you include
Comment on lines
+37
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Client sets and sends |
||
| setMessageInputValue(''); | ||
| } | ||
| }; | ||
|
|
||
| const handleCreateRoom = () => { | ||
| if (!roomNameInputValue) return; | ||
|
|
||
| fetch('http://localhost:3005/rooms', { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| body: JSON.stringify({ name: roomNameInputValue }), | ||
| }) | ||
| .then(response => response.json()) | ||
| .then(data => setRooms(prev => [data, ...prev])) | ||
| .then(() => setRoomNameInputValue('')) | ||
| .catch(error => console.log(error)); | ||
| } | ||
|
|
||
| const handleRenameRoom = () => { | ||
| if (!roomRenameInputValue) return; | ||
|
|
||
| fetch(`http://localhost:3005/rooms/${currentRoom}`, { | ||
| method: 'PATCH', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| body: JSON.stringify({ name: roomRenameInputValue }), | ||
| }) | ||
| .then(response => response.json()) | ||
| .then(data => { | ||
| setRooms((prev) => | ||
| prev.map((room) => | ||
| room.id === data.id ? { ...room, name: data.name } : room | ||
| ) | ||
| ); | ||
| }) | ||
| .then(() => { | ||
| setRoomRenameInputValue(''); | ||
| }) | ||
| .catch(error => console.log(error)); | ||
| } | ||
|
|
||
| const handleDeleteRoom = (id: number) => { | ||
| if (id === currentRoom) { | ||
| setCurrentRoom(null); | ||
| setMessages([]); | ||
| } | ||
|
|
||
| fetch(`http://localhost:3005/messages/${id}`, { | ||
| method: 'DELETE', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| } | ||
| }) | ||
| .then(() => { | ||
| setMessages(prev => prev.filter(message => message.roomId !== id)); | ||
| }) | ||
| .catch(error => console.log(error)); | ||
|
|
||
| fetch(`http://localhost:3005/rooms/${id}`, { | ||
| method: 'DELETE', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| } | ||
| }) | ||
| .then(() => { | ||
| setRooms(prev => prev.filter(room => room.id !== id)); | ||
| }) | ||
| .catch(error => console.log(error)); | ||
|
|
||
|
|
||
| } | ||
|
|
||
| const handleJoinRoom = (id: number) => { | ||
| fetch(`http://localhost:3005/rooms/${id}/join`, { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| } | ||
| }) | ||
| .then(response => { | ||
| if (response.ok) { | ||
| return response.json(); | ||
| } else { | ||
| throw new Error('Room not found or join failed'); | ||
| } | ||
| }) | ||
| .then(data => { | ||
| console.log('Room join confirmed:', data.message); | ||
| setCurrentRoom(id); | ||
|
|
||
| if (socket) { | ||
| socket.send(JSON.stringify({ | ||
| type: 'join', | ||
| roomId: id | ||
| })); | ||
| } | ||
|
|
||
| return fetch(`http://localhost:3005/messages/${id}`); | ||
| }) | ||
| .then(response => response.json()) | ||
| .then(data => setMessages(data.reverse())) | ||
|
Comment on lines
+119
to
+147
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. handleJoinRoom currently sends a WS |
||
| } | ||
|
|
||
| useEffect(() => { | ||
| const newSocket = new WebSocket('ws://localhost:3005'); | ||
|
|
||
| newSocket.onmessage = (event) => { | ||
| const data = JSON.parse(event.data); | ||
|
|
||
| if (data.type === 'history') { | ||
| setMessages(data.messages); | ||
| return; | ||
| } | ||
|
|
||
| if (data.type === 'join-success' || data.type === 'join-error') return; | ||
|
|
||
| setMessages(prev => [data, ...prev]); | ||
|
Comment on lines
+153
to
+163
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Incoming WS messages are appended into state unconditionally ( |
||
| }; | ||
|
|
||
| setSocket(newSocket); | ||
|
|
||
| if (currentRoom) { | ||
| newSocket.onopen = () => { | ||
| newSocket.send(JSON.stringify({ | ||
| type: 'join', | ||
| roomId: currentRoom | ||
| })); | ||
| }; | ||
|
Comment on lines
+166
to
+174
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You set |
||
| } | ||
|
|
||
| return () => newSocket.close(); | ||
| }, [currentRoom]); | ||
|
Comment on lines
+150
to
+178
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WebSocket is created inside a useEffect that depends on |
||
|
|
||
| useEffect(() => { | ||
| fetch('http://localhost:3005/rooms') | ||
| .then(response => response.json()) | ||
| .then(data => setRooms(data.reverse())) | ||
|
Comment on lines
+180
to
+183
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You call |
||
| .catch(err => console.log(err)) | ||
| }, []); | ||
|
|
||
| return ( | ||
| <div className="messager"> | ||
| <Username username={username} setUsername={setUsername} socket={socket} /> | ||
| <ul className="messages"> | ||
| {messages.map(message => ( | ||
| <li key={message.id}> | ||
| <span>{message.text}</span> | ||
| {` - FROM ${message.author} AT `} | ||
| {new Date(message.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| <div className="input"> | ||
| <input | ||
| placeholder='Message' | ||
| type="text" | ||
| value={messageInputValue} | ||
| onChange={(event) => setMessageInputValue(event.target.value)} | ||
| onKeyDown={(event) => { | ||
| if (event.key === 'Enter') { | ||
| handleMessageSend(); | ||
| } | ||
| }} | ||
| /> | ||
| <button type="submit" onClick={handleMessageSend}>Send</button> | ||
| </div> | ||
|
|
||
| <div className="rooms"> | ||
| <p>Current room: {rooms.find(room => room.id === currentRoom)?.name}</p> | ||
| <p>Rooms:</p> | ||
| <div className="inputs"> | ||
| <div> | ||
| <input | ||
| placeholder='Name' | ||
| type="text" | ||
| value={roomNameInputValue} | ||
| onChange={ | ||
| (event) => setRoomNameInputValue(event?.target.value) | ||
| } | ||
| /> | ||
| <button onClick={handleCreateRoom}>Create</button> | ||
| </div> | ||
|
|
||
| <div> | ||
| <input | ||
| placeholder='New name' | ||
| type="text" | ||
| value={roomRenameInputValue} | ||
| onChange={ | ||
| (event) => setRoomRenameInputValue(event?.target.value) | ||
| } | ||
| /> | ||
| <button | ||
| onClick={handleRenameRoom} | ||
| >Rename</button> | ||
| </div> | ||
| </div> | ||
|
|
||
| <ul className='roomsList'> | ||
| {rooms.map((room) => ( | ||
| <li key={room.id} className='room'> | ||
| <p className='roomName'>{room.name}</p> | ||
| <button onClick={() => handleJoinRoom(room.id)}>Join</button> | ||
| <button onClick={() => handleDeleteRoom(room.id)}>Delete</button> | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export default App; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import React, { useState } from 'react'; | ||
|
|
||
| type Props = { | ||
| username: string; | ||
| setUsername: React.Dispatch<React.SetStateAction<string>>; | ||
| socket: WebSocket | null; | ||
| } | ||
| const Username: React.FC<Props> = ({ username, setUsername, socket }) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ensure the server is informed of an already-saved username on reconnect/socket open. This component only sends on explicit save; when the app initializes with |
||
| const [inputValue, setInputValue] = useState<string>(''); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Currently
|
||
|
|
||
| function saveUsername() { | ||
| if (inputValue) { | ||
| setUsername(inputValue); | ||
| localStorage.setItem('username', inputValue); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good: storing the username into localStorage satisfies the requirement to persist the username across sessions (checklist items 1.3 and 3.1). Keep this behavior. You may want to ensure that you only store validated/non-empty usernames (trim whitespace) before persisting.
Comment on lines
+11
to
+14
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Trim and validate the input before saving. Right now you save whatever
|
||
|
|
||
|
|
||
| if (socket) { | ||
| socket.send(JSON.stringify({ | ||
| type: 'set-username', | ||
| username: inputValue | ||
| })); | ||
| } | ||
|
Comment on lines
+17
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid calling
|
||
|
|
||
| setInputValue(''); | ||
| } | ||
| } | ||
|
|
||
| return ( | ||
| <div> | ||
| <label htmlFor="username">Username: </label> | ||
| <input | ||
| id="username" | ||
| type="text" | ||
| value={inputValue} | ||
| onChange={e => setInputValue(e.target.value)} | ||
| placeholder="Enter your username" | ||
| /> | ||
| <button onClick={saveUsername}>Send</button> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Small UX suggestion (optional): provide feedback on save (e.g., disable the button while notifying the server, show confirmation or an error) and disable the save button when |
||
|
|
||
| {username.length !== 0 && ( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The condition |
||
| <div>Your username: {username}</div> | ||
| )} | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default Username; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| ul { | ||
| list-style: none; | ||
|
Comment on lines
+1
to
+2
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This file sets |
||
| } | ||
|
Comment on lines
+1
to
+3
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ensure this stylesheet is actually imported by the app. In the
Comment on lines
+1
to
+3
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid the global |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| <!doctype html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8" /> | ||
| <link rel="icon" type="image/svg+xml" href="/vite.svg" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| <title>Chat</title> | ||
| </head> | ||
| <body> | ||
| <div id="root"></div> | ||
| <script type="module" src="main.tsx"></script> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Module entry reference: confirm the script path will be resolved by your bundler. In Vite projects the common convention is to point to |
||
| </body> | ||
| </html> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import { StrictMode } from 'react' | ||
| import { createRoot } from 'react-dom/client' | ||
| import './index.css' | ||
| import App from './App.tsx' | ||
| import React from 'react'; | ||
|
Comment on lines
+4
to
+5
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional: |
||
|
|
||
| createRoot(document.getElementById('root')!).render( | ||
| <StrictMode> | ||
| <App /> | ||
| </StrictMode> | ||
| ) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The task requires the client to render message objects containing
author,time, andtext(checklist items 1.4 and 3.6). This stylesheet defines the container.messagesbut does not include any styles for individual message elements such as.message,.author,.time, or.text. Add these classes so the UI clearly presents the required fields and so tests / reviewers can verify the rendered structure.