feat: user registration and chat messaging (wip)

This commit is contained in:
Alexander Daichendt 2025-02-12 11:21:57 +01:00
parent b0ce2fe0af
commit 18dd8b83f0
14 changed files with 243 additions and 33 deletions

View file

@ -1,13 +1,21 @@
import { BrowserRouter, Routes, Route } from "react-router-dom"; import { BrowserRouter, Routes, Route } from "react-router-dom";
import Home from "./pages/Home"; import Home from "./pages/Home";
import Room from "./pages/Room"; import Room from "./pages/Room";
import { ChatProvider } from "./components/ChatProvider";
function App() { function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/room/:roomId" element={<Room />} /> <Route
path="/room/:roomId"
element={
<ChatProvider>
<Room />
</ChatProvider>
}
/>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
); );

View file

@ -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 (
<form onSubmit={handleSubmit} className="mt-4">
<div className="flex gap-2">
<input
type="text"
value={message}
onChange={(e) => 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"
/>
<button type="submit" disabled={!message.trim()} className="btn">
Send
</button>
</div>
</form>
);
}
export default React.memo(ChatInput);

View file

@ -0,0 +1,22 @@
import React from "react";
import { useChatState } from "../contexts/ChatContext";
function ChatMessages() {
const { messages } = useChatState();
return (
<>
{messages.map((message) => (
<div key={message.id}>
<div>
<span>{message.author.name}</span>
<span>{new Date(message.timestamp).toLocaleTimeString()}</span>
</div>
<div>{message.content}</div>
</div>
))}
</>
);
}
export default React.memo(ChatMessages);

View file

@ -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<ChatMessage[]>([]);
const [currentUser, setCurrentUser] = React.useState<User | null>(null);
const addMessage = (message: ChatMessage) => {
setMessages((prev) => [...prev, message]);
};
return (
<ChatContext.Provider
value={{ messages, currentUser, addMessage, setCurrentUser }}
>
{children}
</ChatContext.Provider>
);
}

View file

@ -1,10 +1,12 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useChatState } from "../contexts/ChatContext";
interface ConnectProps { interface ConnectProps {
onConnect: (name: string) => void; onConnect: (name: string) => void;
} }
function Connect({ onConnect }: ConnectProps) { function Connect({ onConnect }: ConnectProps) {
const { setCurrentUser } = useChatState();
const [name, setName] = useState(""); const [name, setName] = useState("");
function handleConnect() { function handleConnect() {
@ -12,6 +14,8 @@ function Connect({ onConnect }: ConnectProps) {
return; return;
} }
// leave userId empty for now, it is server generated
setCurrentUser({ name, userId: "" });
onConnect(name); onConnect(name);
} }
@ -31,11 +35,7 @@ function Connect({ onConnect }: ConnectProps) {
onKeyDown={handleKeyPress} 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" 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"
/> />
<button <button onClick={handleConnect} disabled={!name} className="btn">
onClick={handleConnect}
disabled={!name}
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Connect Connect
</button> </button>
</div> </div>

View file

@ -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<ChatContextType | null>(null);
export const useChatState = () => {
const context = useContext(ChatContext);
if (!context) throw new Error("useChat must be used within ChatProvider");
return context;
};

View file

@ -1,14 +1,46 @@
import { useCallback, useEffect, useState } from "react"; 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) { export function useWebSocket(roomId: string) {
const [ws, setWs] = useState<WebSocket | null>(null); const [ws, setWs] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false); 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( const sendMessage = useCallback(
(message: ClientMessage) => { (content: string) => {
if (ws?.readyState === WebSocket.OPEN) { if (ws?.readyState === WebSocket.OPEN) {
const message: ClientMessage = {
type: "CHAT_MESSAGE",
payload: { content },
};
ws.send(JSON.stringify(message)); ws.send(JSON.stringify(message));
} else { } else {
console.error("WebSocket connection not established"); console.error("WebSocket connection not established");
@ -17,6 +49,7 @@ export function useWebSocket(roomId: string) {
[ws], [ws],
); );
// Connect to WebSocket server
const connect = useCallback( const connect = useCallback(
(name: string) => { (name: string) => {
try { try {
@ -35,6 +68,8 @@ export function useWebSocket(roomId: string) {
websocket.send(JSON.stringify(message)); websocket.send(JSON.stringify(message));
}; };
websocket.onmessage = handleMessage;
websocket.onclose = () => { websocket.onclose = () => {
console.log("WebSocket connection closed to room:", roomId); console.log("WebSocket connection closed to room:", roomId);
setIsConnected(false); setIsConnected(false);
@ -52,16 +87,18 @@ export function useWebSocket(roomId: string) {
setIsConnected(false); setIsConnected(false);
} }
}, },
[roomId], [roomId, handleMessage],
); );
// Cleanup on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
if (ws) { if (ws) {
ws.close(); ws.close();
} }
setIsConnected(false);
}; };
}, [ws]); }, [ws]);
return { ws, isConnected, connect, sendMessage }; return { isConnected, connect, sendMessage };
} }

View file

@ -1 +1,7 @@
@import "tailwindcss"; @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;
}
}

View file

@ -28,7 +28,7 @@ function Home() {
<button <button
type="button" type="button"
onClick={onClickCreateRoom} onClick={onClickCreateRoom}
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" className="btn w-full"
> >
Create Create
</button> </button>
@ -56,7 +56,7 @@ function Home() {
type="button" type="button"
onClick={onClickJoinRoom} onClick={onClickJoinRoom}
disabled={!roomId} 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 Join
</button> </button>

View file

@ -1,17 +1,24 @@
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import Layout from "../components/Layout"; import Layout from "../components/Layout";
import Connect from "../components/Connect"; import Connect from "../components/ConnectWithName";
import { useWebSocket } from "../hooks/useWebSocket"; import { useWebSocket } from "../hooks/useWebSocket";
import ChatMessages from "../components/ChatMessages";
import ChatInput from "../components/ChatInput";
import { useChatState } from "../contexts/ChatContext";
function Room() { function Room() {
const { roomId } = useParams(); const { roomId } = useParams();
const { isConnected, connect } = useWebSocket(roomId ?? ""); const { isConnected, connect, sendMessage } = useWebSocket(roomId ?? "");
const { currentUser } = useChatState();
return ( return (
<Layout> <Layout>
<section className="w-full"> <section className="w-full">
<h2 className="text-xl bg-gray-100 p-4 flex justify-between items-center rounded-md shadow-sm"> <h2 className="text-xl bg-gray-100 p-4 flex justify-between items-center rounded-md shadow-sm">
<span className="font-medium">Room ID: {roomId}</span> <span className="font-medium">Room ID: {roomId}</span>
{currentUser && (
<span className="font-medium">User: {currentUser?.name}</span>
)}
<span <span
className={`px-3 py-1 rounded-full text-sm font-medium ${ className={`px-3 py-1 rounded-full text-sm font-medium ${
isConnected isConnected
@ -24,6 +31,12 @@ function Room() {
</h2> </h2>
{!isConnected && <Connect onConnect={connect} />} {!isConnected && <Connect onConnect={connect} />}
{isConnected && (
<>
<ChatMessages />
<ChatInput onSendMessage={sendMessage} />
</>
)}
</section> </section>
</Layout> </Layout>
); );

View file

@ -1,12 +1,62 @@
import type { ServerWebSocket } from "bun"; import { randomUUIDv7, type ServerWebSocket } from "bun";
import type { ClientMessage } from "../../shared"; import type {
ClientMessage,
ServerChatMessage,
ServerUserJoinedMessage,
User,
} from "../../shared";
import type { Room, WebSocketData } from "./types"; import type { Room, WebSocketData } from "./types";
export default function handleClientMessage( export default function handleClientMessage(
client: ServerWebSocket<WebSocketData>, client: ServerWebSocket<WebSocketData>,
data: ClientMessage, data: ClientMessage,
room: Room, room: Room,
sender: User | undefined,
) { ) {
// handle client messages // handle client messages
console.log("Received new message ", data); 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;
}
} }

View file

@ -78,7 +78,14 @@ const server = Bun.serve<WebSocketData>({
const data = JSON.parse(message.toString()) as ClientMessage; 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);
}, },
}, },
}); });

View file

@ -7,11 +7,5 @@ export interface WebSocketData {
export interface Room { export interface Room {
roomId: string; roomId: string;
userConnections: Map< userConnections: Map<ServerWebSocket<WebSocketData>, User>;
string, // userId
{
user: User;
socket: ServerWebSocket<WebSocketData>;
}
>;
} }

View file

@ -3,7 +3,7 @@
export interface User { export interface User {
name: string; name: string;
uid: string; userId: string;
} }
export interface ClientJoinMessage { export interface ClientJoinMessage {
@ -51,40 +51,40 @@ export type ClientMessage =
// Server messages // Server messages
interface ChatMessage { export interface ChatMessage {
id: string; id: string;
content: string; content: string;
timestamp: number; timestamp: number;
author: User; author: User;
} }
interface ServerUserJoinedMessage { export interface ServerUserJoinedMessage {
type: "USER_JOINED"; type: "USER_JOINED";
payload: { payload: {
user: User; user: User;
}; };
} }
interface ServerUserLeftMessage { export interface ServerUserLeftMessage {
type: "USER_LEFT"; type: "USER_LEFT";
payload: { payload: {
user: User; user: User;
}; };
} }
interface ServerChatMessage { export interface ServerChatMessage {
type: "CHAT_MESSAGE"; type: "CHAT_MESSAGE";
payload: ChatMessage; payload: ChatMessage;
} }
interface ServerMessageDeletedMessage { export interface ServerMessageDeletedMessage {
type: "MESSAGE_DELETED"; type: "MESSAGE_DELETED";
payload: { payload: {
messageId: string; messageId: string;
}; };
} }
interface ServerMessageEditedMessage { export interface ServerMessageEditedMessage {
type: "MESSAGE_EDITED"; type: "MESSAGE_EDITED";
payload: { payload: {
messageId: string; messageId: string;
@ -92,7 +92,7 @@ interface ServerMessageEditedMessage {
}; };
} }
interface ServerUserListMessage { export interface ServerUserListMessage {
type: "USER_LIST"; type: "USER_LIST";
payload: { payload: {
users: User[]; users: User[];