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 Home from "./pages/Home";
import Room from "./pages/Room";
import { ChatProvider } from "./components/ChatProvider";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/room/:roomId" element={<Room />} />
<Route
path="/room/:roomId"
element={
<ChatProvider>
<Room />
</ChatProvider>
}
/>
</Routes>
</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 { 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"
/>
<button
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"
>
<button onClick={handleConnect} disabled={!name} className="btn">
Connect
</button>
</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 { ClientJoinMessage, ClientMessage } from "../../../shared";
import {
ClientJoinMessage,
ClientMessage,
ServerMessage,
} from "../../../shared";
import { useChatState } from "../contexts/ChatContext";
export function useWebSocket(roomId: string) {
const [ws, setWs] = useState<WebSocket | null>(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 };
}

View file

@ -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;
}
}

View file

@ -28,7 +28,7 @@ function Home() {
<button
type="button"
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
</button>
@ -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
</button>

View file

@ -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 (
<Layout>
<section className="w-full">
<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>
{currentUser && (
<span className="font-medium">User: {currentUser?.name}</span>
)}
<span
className={`px-3 py-1 rounded-full text-sm font-medium ${
isConnected
@ -24,6 +31,12 @@ function Room() {
</h2>
{!isConnected && <Connect onConnect={connect} />}
{isConnected && (
<>
<ChatMessages />
<ChatInput onSendMessage={sendMessage} />
</>
)}
</section>
</Layout>
);