feat: user registration and chat messaging (wip)
This commit is contained in:
parent
b0ce2fe0af
commit
18dd8b83f0
14 changed files with 243 additions and 33 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
36
client/src/components/ChatInput.tsx
Normal file
36
client/src/components/ChatInput.tsx
Normal 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);
|
||||
22
client/src/components/ChatMessages.tsx
Normal file
22
client/src/components/ChatMessages.tsx
Normal 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);
|
||||
20
client/src/components/ChatProvider.tsx
Normal file
20
client/src/components/ChatProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
17
client/src/contexts/ChatContext.tsx
Normal file
17
client/src/contexts/ChatContext.tsx
Normal 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;
|
||||
};
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<WebSocketData>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,14 @@ const server = Bun.serve<WebSocketData>({
|
|||
|
||||
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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,11 +7,5 @@ export interface WebSocketData {
|
|||
|
||||
export interface Room {
|
||||
roomId: string;
|
||||
userConnections: Map<
|
||||
string, // userId
|
||||
{
|
||||
user: User;
|
||||
socket: ServerWebSocket<WebSocketData>;
|
||||
}
|
||||
>;
|
||||
userConnections: Map<ServerWebSocket<WebSocketData>, User>;
|
||||
}
|
||||
|
|
|
|||
16
shared.ts
16
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[];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue