diff --git a/client/src/components/ChatMenu.tsx b/client/src/components/ChatMenu.tsx new file mode 100644 index 0000000..a971b01 --- /dev/null +++ b/client/src/components/ChatMenu.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { useChatState } from "../contexts/ChatContext"; + +function ChatMenu() { + const { actions, state } = useChatState(); + + return ( +
+ + +
+ ); +} + +export default React.memo(ChatMenu); diff --git a/client/src/components/ChatProvider.tsx b/client/src/components/ChatProvider.tsx index 4f70a99..18bd036 100644 --- a/client/src/components/ChatProvider.tsx +++ b/client/src/components/ChatProvider.tsx @@ -1,6 +1,6 @@ import React, { useReducer } from "react"; import { User } from "../../../shared"; -import { ChatContext, Message } from "../contexts/ChatContext"; +import { ChatContext, ChatState, Message } from "../contexts/ChatContext"; import { chatReducer } from "../reducers/chatReducers"; export function ChatProvider({ children }: { children: React.ReactNode }) { @@ -8,6 +8,8 @@ export function ChatProvider({ children }: { children: React.ReactNode }) { messages: [], currentUser: null, isConnected: false, + participants: [], + selectedMenu: "chat", }); const value = { @@ -23,6 +25,10 @@ export function ChatProvider({ children }: { children: React.ReactNode }) { setUser: (user: User) => dispatch({ type: "SET_USER", payload: user }), setConnected: (isConnected: boolean) => 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 }), }, }; diff --git a/client/src/components/MessageDisplay.tsx b/client/src/components/MessageDisplay.tsx index e40b397..4748fb0 100644 --- a/client/src/components/MessageDisplay.tsx +++ b/client/src/components/MessageDisplay.tsx @@ -17,6 +17,9 @@ function ChatMessages({ sendMessage }: MessageDisplayProps) { const userId = state.currentUser?.userId; const messagesEndRef = useRef(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 = () => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }; @@ -65,7 +68,7 @@ function ChatMessages({ sendMessage }: MessageDisplayProps) { return (
{message.payload.user.name} joined!
@@ -74,7 +77,7 @@ function ChatMessages({ sendMessage }: MessageDisplayProps) { return (
{message.payload.user.name} left!
diff --git a/client/src/components/ParticipantsList.tsx b/client/src/components/ParticipantsList.tsx new file mode 100644 index 0000000..a221cb6 --- /dev/null +++ b/client/src/components/ParticipantsList.tsx @@ -0,0 +1,18 @@ +import { useChatState } from "../contexts/ChatContext"; + +function ParticipantList() { + const { state } = useChatState(); + + return ( + <> +

Participants

+ + + ); +} + +export default ParticipantList; diff --git a/client/src/components/RoomHeader.tsx b/client/src/components/RoomHeader.tsx new file mode 100644 index 0000000..6c0495f --- /dev/null +++ b/client/src/components/RoomHeader.tsx @@ -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 ( +

+ Room ID: {props.roomId} + {state.currentUser && ( + User: {state.currentUser?.name} + )} + + {props.isConnected ? "Connected" : "Not Connected"} + +

+ ); +} + +export default React.memo(RoomHeader); diff --git a/client/src/contexts/ChatContext.tsx b/client/src/contexts/ChatContext.tsx index 0be8839..651b97c 100644 --- a/client/src/contexts/ChatContext.tsx +++ b/client/src/contexts/ChatContext.tsx @@ -15,6 +15,8 @@ export type ChatState = { messages: Message[]; currentUser: User | null; isConnected: boolean; + participants: User[]; + selectedMenu: "chat" | "participants"; }; export type ChatAction = @@ -23,7 +25,9 @@ export type ChatAction = | { type: "DELETE_MESSAGE"; payload: string } | { type: "SET_USER"; payload: User } | { 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 = { editMessage: (messageId: string, newContent: string) => void; @@ -31,6 +35,8 @@ export type ChatActions = { deleteMessage: (messageId: string) => void; setUser: (user: User) => void; setConnected: (isConnected: boolean) => void; + setParticipants: (participants: User[]) => void; + setMenu: (menu: ChatState["selectedMenu"]) => void; }; interface ChatContextType { diff --git a/client/src/hooks/useWebSocket.ts b/client/src/hooks/useWebSocket.ts index edd1f64..edaade9 100644 --- a/client/src/hooks/useWebSocket.ts +++ b/client/src/hooks/useWebSocket.ts @@ -39,6 +39,10 @@ export function useWebSocket(roomId: string) { ); break; + case "USER_LIST": + actions.setParticipants(message.payload.users); + break; + default: console.error("Unknown message type", message); } diff --git a/client/src/pages/Room.tsx b/client/src/pages/Room.tsx index 162ca40..1219425 100644 --- a/client/src/pages/Room.tsx +++ b/client/src/pages/Room.tsx @@ -5,38 +5,41 @@ import { useWebSocket } from "../hooks/useWebSocket"; import ChatMessages from "../components/MessageDisplay"; import ChatInput from "../components/ChatInput"; import { useChatState } from "../contexts/ChatContext"; +import ParticipantList from "../components/ParticipantsList"; +import ChatMenu from "../components/ChatMenu"; +import RoomHeader from "../components/RoomHeader"; 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 { state } = useChatState(); + if (!isConnected) { + return ( + +
+ + +
+
+ ); + } + + // is connected return (
-

- Room ID: {roomId} - {state.currentUser && ( - User: {state.currentUser?.name} - )} - - {isConnected ? "Connected" : "Not Connected"} - -

+ - {!isConnected && } - {isConnected && ( + + {state.selectedMenu === "chat" && ( <> )} + + {state.selectedMenu === "participants" && }
); diff --git a/client/src/reducers/chatReducers.ts b/client/src/reducers/chatReducers.ts index ad81f20..aa04d6b 100644 --- a/client/src/reducers/chatReducers.ts +++ b/client/src/reducers/chatReducers.ts @@ -52,6 +52,17 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState { ...state, messages: [], }; + case "SET_PARTICIPANTS": + return { + ...state, + participants: action.payload, + }; + + case "SET_MENU": + return { + ...state, + selectedMenu: action.payload, + }; default: return state; }