feat: add participants view

This commit is contained in:
Alexander Daichendt 2025-02-13 13:46:35 +01:00
parent f27adc3360
commit 86c1409d3f
9 changed files with 133 additions and 22 deletions

View file

@ -0,0 +1,29 @@
import React from "react";
import { useChatState } from "../contexts/ChatContext";
function ChatMenu() {
const { actions, state } = useChatState();
return (
<div className="flex gap-4 w-full">
<button
onClick={() => actions.setMenu("chat")}
className={`w-1/2 py-4 ${
state.selectedMenu === "chat" ? "bg-white" : "bg-gray-100"
}`}
>
Chat
</button>
<button
onClick={() => actions.setMenu("participants")}
className={`w-1/2 py-4 ${
state.selectedMenu === "participants" ? "bg-white" : "bg-gray-100"
}`}
>
Participants
</button>
</div>
);
}
export default React.memo(ChatMenu);

View file

@ -1,6 +1,6 @@
import React, { useReducer } from "react"; import React, { useReducer } from "react";
import { User } from "../../../shared"; import { User } from "../../../shared";
import { ChatContext, Message } from "../contexts/ChatContext"; import { ChatContext, ChatState, Message } from "../contexts/ChatContext";
import { chatReducer } from "../reducers/chatReducers"; import { chatReducer } from "../reducers/chatReducers";
export function ChatProvider({ children }: { children: React.ReactNode }) { export function ChatProvider({ children }: { children: React.ReactNode }) {
@ -8,6 +8,8 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
messages: [], messages: [],
currentUser: null, currentUser: null,
isConnected: false, isConnected: false,
participants: [],
selectedMenu: "chat",
}); });
const value = { const value = {
@ -23,6 +25,10 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
setUser: (user: User) => dispatch({ type: "SET_USER", payload: user }), setUser: (user: User) => dispatch({ type: "SET_USER", payload: user }),
setConnected: (isConnected: boolean) => setConnected: (isConnected: boolean) =>
dispatch({ type: "SET_CONNECTED", payload: isConnected }), dispatch({ type: "SET_CONNECTED", payload: isConnected }),
setParticipants: (participants: User[]) =>
dispatch({ type: "SET_PARTICIPANTS", payload: participants }),
setMenu: (menu: ChatState["selectedMenu"]) =>
dispatch({ type: "SET_MENU", payload: menu }),
}, },
}; };

View file

@ -17,6 +17,9 @@ function ChatMessages({ sendMessage }: MessageDisplayProps) {
const userId = state.currentUser?.userId; const userId = state.currentUser?.userId;
const messagesEndRef = useRef<HTMLDivElement>(null); // ref to an empty div at the end to scroll to const messagesEndRef = useRef<HTMLDivElement>(null); // ref to an empty div at the end to scroll to
// this counter is used to generate unique keys for join/leave messages. This is not ideal and with more time I'd introduce server-side message IDs for all messages, not just chat messages
let messageCounter = 0;
const scrollToBottom = () => { const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}; };
@ -65,7 +68,7 @@ function ChatMessages({ sendMessage }: MessageDisplayProps) {
return ( return (
<div <div
className="text-center text-gray-500 my-2" className="text-center text-gray-500 my-2"
key={`join-${message.payload.user.name}-${new Date()}`} key={`join-${message.payload.user.name}-${messageCounter++}`}
> >
{message.payload.user.name} joined! {message.payload.user.name} joined!
</div> </div>
@ -74,7 +77,7 @@ function ChatMessages({ sendMessage }: MessageDisplayProps) {
return ( return (
<div <div
className="text-center text-gray-500 my-2" className="text-center text-gray-500 my-2"
key={`leave-${message.payload.user.name}-${new Date()}`} key={`leave-${message.payload.user.name}-${messageCounter++}`}
> >
{message.payload.user.name} left! {message.payload.user.name} left!
</div> </div>

View file

@ -0,0 +1,18 @@
import { useChatState } from "../contexts/ChatContext";
function ParticipantList() {
const { state } = useChatState();
return (
<>
<h3 className="text-lg font-semibold">Participants</h3>
<ul>
{state.participants.map((participant) => (
<li key={participant.userId}>{participant.name}</li>
))}
</ul>
</>
);
}
export default ParticipantList;

View file

@ -0,0 +1,31 @@
import React from "react";
import { useChatState } from "../contexts/ChatContext";
interface RoomHeaderProps {
roomId: string;
isConnected: boolean;
}
function RoomHeader(props: RoomHeaderProps) {
const { state } = useChatState();
return (
<h2 className="text-xl bg-gray-100 p-4 flex justify-between items-center">
<span className="font-medium">Room ID: {props.roomId}</span>
{state.currentUser && (
<span className="font-medium">User: {state.currentUser?.name}</span>
)}
<span
className={`px-3 py-1 text-sm font-medium rounded-xl ${
props.isConnected
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{props.isConnected ? "Connected" : "Not Connected"}
</span>
</h2>
);
}
export default React.memo(RoomHeader);

View file

@ -15,6 +15,8 @@ export type ChatState = {
messages: Message[]; messages: Message[];
currentUser: User | null; currentUser: User | null;
isConnected: boolean; isConnected: boolean;
participants: User[];
selectedMenu: "chat" | "participants";
}; };
export type ChatAction = export type ChatAction =
@ -23,7 +25,9 @@ export type ChatAction =
| { type: "DELETE_MESSAGE"; payload: string } | { type: "DELETE_MESSAGE"; payload: string }
| { type: "SET_USER"; payload: User } | { type: "SET_USER"; payload: User }
| { type: "SET_CONNECTED"; payload: boolean } | { type: "SET_CONNECTED"; payload: boolean }
| { type: "CLEAR_MESSAGES" }; | { type: "CLEAR_MESSAGES" }
| { type: "SET_PARTICIPANTS"; payload: User[] }
| { type: "SET_MENU"; payload: ChatState["selectedMenu"] };
export type ChatActions = { export type ChatActions = {
editMessage: (messageId: string, newContent: string) => void; editMessage: (messageId: string, newContent: string) => void;
@ -31,6 +35,8 @@ export type ChatActions = {
deleteMessage: (messageId: string) => void; deleteMessage: (messageId: string) => void;
setUser: (user: User) => void; setUser: (user: User) => void;
setConnected: (isConnected: boolean) => void; setConnected: (isConnected: boolean) => void;
setParticipants: (participants: User[]) => void;
setMenu: (menu: ChatState["selectedMenu"]) => void;
}; };
interface ChatContextType { interface ChatContextType {

View file

@ -39,6 +39,10 @@ export function useWebSocket(roomId: string) {
); );
break; break;
case "USER_LIST":
actions.setParticipants(message.payload.users);
break;
default: default:
console.error("Unknown message type", message); console.error("Unknown message type", message);
} }

View file

@ -5,38 +5,41 @@ import { useWebSocket } from "../hooks/useWebSocket";
import ChatMessages from "../components/MessageDisplay"; import ChatMessages from "../components/MessageDisplay";
import ChatInput from "../components/ChatInput"; import ChatInput from "../components/ChatInput";
import { useChatState } from "../contexts/ChatContext"; import { useChatState } from "../contexts/ChatContext";
import ParticipantList from "../components/ParticipantsList";
import ChatMenu from "../components/ChatMenu";
import RoomHeader from "../components/RoomHeader";
function Room() { function Room() {
const { roomId } = useParams(); const { roomId } = useParams(); // can never be null as this Room component is only ever rendered if react-router matches the route WITH a roomId
const { isConnected, connect, sendMessage } = useWebSocket(roomId ?? ""); const { isConnected, connect, sendMessage } = useWebSocket(roomId ?? "");
const { state } = useChatState(); const { state } = useChatState();
if (!isConnected) {
return (
<Layout>
<section className="w-full">
<RoomHeader roomId={roomId!} isConnected={isConnected} />
<Connect onConnect={connect} />
</section>
</Layout>
);
}
// is connected
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"> <RoomHeader roomId={roomId!} isConnected={isConnected} />
<span className="font-medium">Room ID: {roomId}</span>
{state.currentUser && (
<span className="font-medium">User: {state.currentUser?.name}</span>
)}
<span
className={`px-3 py-1 rounded-full text-sm font-medium ${
isConnected
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{isConnected ? "Connected" : "Not Connected"}
</span>
</h2>
{!isConnected && <Connect onConnect={connect} />} <ChatMenu />
{isConnected && ( {state.selectedMenu === "chat" && (
<> <>
<ChatMessages sendMessage={sendMessage} /> <ChatMessages sendMessage={sendMessage} />
<ChatInput sendMessage={sendMessage} /> <ChatInput sendMessage={sendMessage} />
</> </>
)} )}
{state.selectedMenu === "participants" && <ParticipantList />}
</section> </section>
</Layout> </Layout>
); );

View file

@ -52,6 +52,17 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState {
...state, ...state,
messages: [], messages: [],
}; };
case "SET_PARTICIPANTS":
return {
...state,
participants: action.payload,
};
case "SET_MENU":
return {
...state,
selectedMenu: action.payload,
};
default: default:
return state; return state;
} }