From 18dd8b83f0bbf0506e2deadeaf5dfecc7912577c Mon Sep 17 00:00:00 2001 From: Alexander Daichendt Date: Wed, 12 Feb 2025 11:21:57 +0100 Subject: [PATCH] feat: user registration and chat messaging (wip) --- client/src/App.tsx | 10 +++- client/src/components/ChatInput.tsx | 36 +++++++++++++ client/src/components/ChatMessages.tsx | 22 ++++++++ client/src/components/ChatProvider.tsx | 20 +++++++ .../{Connect.tsx => ConnectWithName.tsx} | 10 ++-- client/src/contexts/ChatContext.tsx | 17 ++++++ client/src/hooks/useWebSocket.ts | 47 ++++++++++++++-- client/src/index.css | 6 +++ client/src/pages/Home.tsx | 4 +- client/src/pages/Room.tsx | 17 +++++- server/src/handleClientMessage.ts | 54 ++++++++++++++++++- server/src/index.ts | 9 +++- server/src/types.ts | 8 +-- shared.ts | 16 +++--- 14 files changed, 243 insertions(+), 33 deletions(-) create mode 100644 client/src/components/ChatInput.tsx create mode 100644 client/src/components/ChatMessages.tsx create mode 100644 client/src/components/ChatProvider.tsx rename client/src/components/{Connect.tsx => ConnectWithName.tsx} (77%) create mode 100644 client/src/contexts/ChatContext.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 26ca021..ea05220 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,13 +1,21 @@ import { BrowserRouter, Routes, Route } from "react-router-dom"; import Home from "./pages/Home"; import Room from "./pages/Room"; +import { ChatProvider } from "./components/ChatProvider"; function App() { return ( } /> - } /> + + + + } + /> ); diff --git a/client/src/components/ChatInput.tsx b/client/src/components/ChatInput.tsx new file mode 100644 index 0000000..fbef502 --- /dev/null +++ b/client/src/components/ChatInput.tsx @@ -0,0 +1,36 @@ +import React, { useState } from "react"; + +interface ChatInputProps { + onSendMessage: (content: string) => void; +} + +function ChatInput({ onSendMessage }: ChatInputProps) { + const [message, setMessage] = useState(""); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!message.trim()) return; + + onSendMessage(message); + setMessage(""); + } + + return ( +
+
+ setMessage(e.target.value)} + placeholder="Type a message..." + className="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> + +
+
+ ); +} + +export default React.memo(ChatInput); diff --git a/client/src/components/ChatMessages.tsx b/client/src/components/ChatMessages.tsx new file mode 100644 index 0000000..76e4139 --- /dev/null +++ b/client/src/components/ChatMessages.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { useChatState } from "../contexts/ChatContext"; + +function ChatMessages() { + const { messages } = useChatState(); + + return ( + <> + {messages.map((message) => ( +
+
+ {message.author.name} + {new Date(message.timestamp).toLocaleTimeString()} +
+
{message.content}
+
+ ))} + + ); +} + +export default React.memo(ChatMessages); diff --git a/client/src/components/ChatProvider.tsx b/client/src/components/ChatProvider.tsx new file mode 100644 index 0000000..f2b2349 --- /dev/null +++ b/client/src/components/ChatProvider.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { ChatMessage, User } from "../../../shared"; +import { ChatContext } from "../contexts/ChatContext"; + +export function ChatProvider({ children }: { children: React.ReactNode }) { + const [messages, setMessages] = React.useState([]); + const [currentUser, setCurrentUser] = React.useState(null); + + const addMessage = (message: ChatMessage) => { + setMessages((prev) => [...prev, message]); + }; + + return ( + + {children} + + ); +} diff --git a/client/src/components/Connect.tsx b/client/src/components/ConnectWithName.tsx similarity index 77% rename from client/src/components/Connect.tsx rename to client/src/components/ConnectWithName.tsx index 32341d6..9f40135 100644 --- a/client/src/components/Connect.tsx +++ b/client/src/components/ConnectWithName.tsx @@ -1,10 +1,12 @@ import React, { useState } from "react"; +import { useChatState } from "../contexts/ChatContext"; interface ConnectProps { onConnect: (name: string) => void; } function Connect({ onConnect }: ConnectProps) { + const { setCurrentUser } = useChatState(); const [name, setName] = useState(""); function handleConnect() { @@ -12,6 +14,8 @@ function Connect({ onConnect }: ConnectProps) { return; } + // leave userId empty for now, it is server generated + setCurrentUser({ name, userId: "" }); onConnect(name); } @@ -31,11 +35,7 @@ function Connect({ onConnect }: ConnectProps) { onKeyDown={handleKeyPress} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> - diff --git a/client/src/contexts/ChatContext.tsx b/client/src/contexts/ChatContext.tsx new file mode 100644 index 0000000..b2a2b53 --- /dev/null +++ b/client/src/contexts/ChatContext.tsx @@ -0,0 +1,17 @@ +import { createContext, useContext } from "react"; +import { ChatMessage, User } from "../../../shared"; + +interface ChatContextType { + messages: ChatMessage[]; + currentUser: User | null; + addMessage: (message: ChatMessage) => void; + setCurrentUser: (user: User) => void; +} + +export const ChatContext = createContext(null); + +export const useChatState = () => { + const context = useContext(ChatContext); + if (!context) throw new Error("useChat must be used within ChatProvider"); + return context; +}; diff --git a/client/src/hooks/useWebSocket.ts b/client/src/hooks/useWebSocket.ts index 83f33fc..0500cc5 100644 --- a/client/src/hooks/useWebSocket.ts +++ b/client/src/hooks/useWebSocket.ts @@ -1,14 +1,46 @@ import { useCallback, useEffect, useState } from "react"; -import { ClientJoinMessage, ClientMessage } from "../../../shared"; +import { + ClientJoinMessage, + ClientMessage, + ServerMessage, +} from "../../../shared"; +import { useChatState } from "../contexts/ChatContext"; export function useWebSocket(roomId: string) { const [ws, setWs] = useState(null); const [isConnected, setIsConnected] = useState(false); + const { addMessage, currentUser, setCurrentUser } = useChatState(); - // Send a message to the server, useCallback to avoid unnecessary re-renders for components using this hook + // Handle incoming messages + const handleMessage = useCallback( + (event: MessageEvent) => { + const message = JSON.parse(event.data) as ServerMessage; + + switch (message.type) { + case "USER_JOINED": + console.log("User joined", message.payload); + if (message.payload.user.name === currentUser?.name) { + setCurrentUser(message.payload.user); + } + break; + case "CHAT_MESSAGE": + addMessage(message.payload); + break; + default: + console.error("Unknown message type", message); + } + }, + [currentUser, setCurrentUser, addMessage], + ); + + // Send a message to the server const sendMessage = useCallback( - (message: ClientMessage) => { + (content: string) => { if (ws?.readyState === WebSocket.OPEN) { + const message: ClientMessage = { + type: "CHAT_MESSAGE", + payload: { content }, + }; ws.send(JSON.stringify(message)); } else { console.error("WebSocket connection not established"); @@ -17,6 +49,7 @@ export function useWebSocket(roomId: string) { [ws], ); + // Connect to WebSocket server const connect = useCallback( (name: string) => { try { @@ -35,6 +68,8 @@ export function useWebSocket(roomId: string) { websocket.send(JSON.stringify(message)); }; + websocket.onmessage = handleMessage; + websocket.onclose = () => { console.log("WebSocket connection closed to room:", roomId); setIsConnected(false); @@ -52,16 +87,18 @@ export function useWebSocket(roomId: string) { setIsConnected(false); } }, - [roomId], + [roomId, handleMessage], ); + // Cleanup on unmount useEffect(() => { return () => { if (ws) { ws.close(); } + setIsConnected(false); }; }, [ws]); - return { ws, isConnected, connect, sendMessage }; + return { isConnected, connect, sendMessage }; } diff --git a/client/src/index.css b/client/src/index.css index f1d8c73..c8fe7fa 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1 +1,7 @@ @import "tailwindcss"; + +@layer components { + .btn { + @apply py-2.5 px-6 bg-gradient-to-r from-blue-500 to-indigo-600 text-white font-medium rounded-lg shadow-lg hover:from-blue-600 hover:to-indigo-700 hover:shadow-xl transform transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50; + } +} diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index fa19701..31518d3 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -28,7 +28,7 @@ function Home() { @@ -56,7 +56,7 @@ function Home() { type="button" onClick={onClickJoinRoom} disabled={!roomId} - className="w-full py-2 px-4 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed" + className="w-full btn bg-green-600 hover:bg-green-700" > Join diff --git a/client/src/pages/Room.tsx b/client/src/pages/Room.tsx index 2663b23..5036218 100644 --- a/client/src/pages/Room.tsx +++ b/client/src/pages/Room.tsx @@ -1,17 +1,24 @@ import { useParams } from "react-router-dom"; import Layout from "../components/Layout"; -import Connect from "../components/Connect"; +import Connect from "../components/ConnectWithName"; import { useWebSocket } from "../hooks/useWebSocket"; +import ChatMessages from "../components/ChatMessages"; +import ChatInput from "../components/ChatInput"; +import { useChatState } from "../contexts/ChatContext"; function Room() { const { roomId } = useParams(); - const { isConnected, connect } = useWebSocket(roomId ?? ""); + const { isConnected, connect, sendMessage } = useWebSocket(roomId ?? ""); + const { currentUser } = useChatState(); return (

Room ID: {roomId} + {currentUser && ( + User: {currentUser?.name} + )} {!isConnected && } + {isConnected && ( + <> + + + + )}

); diff --git a/server/src/handleClientMessage.ts b/server/src/handleClientMessage.ts index 1f37f29..b29395d 100644 --- a/server/src/handleClientMessage.ts +++ b/server/src/handleClientMessage.ts @@ -1,12 +1,62 @@ -import type { ServerWebSocket } from "bun"; -import type { ClientMessage } from "../../shared"; +import { randomUUIDv7, type ServerWebSocket } from "bun"; +import type { + ClientMessage, + ServerChatMessage, + ServerUserJoinedMessage, + User, +} from "../../shared"; import type { Room, WebSocketData } from "./types"; export default function handleClientMessage( client: ServerWebSocket, data: ClientMessage, room: Room, + sender: User | undefined, ) { // handle client messages console.log("Received new message ", data); + + switch (data.type) { + case "JOIN": + const userId = randomUUIDv7(); + const name = data.payload.username; + + room.userConnections.set(client, { + name, + userId, + }); + + const joinMessage: ServerUserJoinedMessage = { + type: "USER_JOINED", + payload: { + user: { + name, + userId, + }, + }, + }; + + for (const [socket] of room.userConnections) { + socket.send(JSON.stringify(joinMessage)); + } + + break; + + case "CHAT_MESSAGE": + const chatMessage: ServerChatMessage = { + type: "CHAT_MESSAGE", + payload: { + id: randomUUIDv7(), + author: sender!, + content: data.payload.content, + timestamp: Date.now(), + }, + }; + + // broadcast the message to all clients in the room + for (const [socket] of room.userConnections) { + socket.send(JSON.stringify(chatMessage)); + } + break; + } } diff --git a/server/src/index.ts b/server/src/index.ts index dc43441..18306be 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -78,7 +78,14 @@ const server = Bun.serve({ const data = JSON.parse(message.toString()) as ClientMessage; - handleClientMessage(ws, data, room); + const sender = room.userConnections.get(ws); + + if (!sender && data.type !== "JOIN") { + console.log("User not found in room", roomId); + return; + } + + handleClientMessage(ws, data, room, sender); }, }, }); diff --git a/server/src/types.ts b/server/src/types.ts index 7d6d285..ccb85e6 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -7,11 +7,5 @@ export interface WebSocketData { export interface Room { roomId: string; - userConnections: Map< - string, // userId - { - user: User; - socket: ServerWebSocket; - } - >; + userConnections: Map, User>; } diff --git a/shared.ts b/shared.ts index 237a0b6..d5547af 100644 --- a/shared.ts +++ b/shared.ts @@ -3,7 +3,7 @@ export interface User { name: string; - uid: string; + userId: string; } export interface ClientJoinMessage { @@ -51,40 +51,40 @@ export type ClientMessage = // Server messages -interface ChatMessage { +export interface ChatMessage { id: string; content: string; timestamp: number; author: User; } -interface ServerUserJoinedMessage { +export interface ServerUserJoinedMessage { type: "USER_JOINED"; payload: { user: User; }; } -interface ServerUserLeftMessage { +export interface ServerUserLeftMessage { type: "USER_LEFT"; payload: { user: User; }; } -interface ServerChatMessage { +export interface ServerChatMessage { type: "CHAT_MESSAGE"; payload: ChatMessage; } -interface ServerMessageDeletedMessage { +export interface ServerMessageDeletedMessage { type: "MESSAGE_DELETED"; payload: { messageId: string; }; } -interface ServerMessageEditedMessage { +export interface ServerMessageEditedMessage { type: "MESSAGE_EDITED"; payload: { messageId: string; @@ -92,7 +92,7 @@ interface ServerMessageEditedMessage { }; } -interface ServerUserListMessage { +export interface ServerUserListMessage { type: "USER_LIST"; payload: { users: User[];