feat: add participants view
This commit is contained in:
parent
f27adc3360
commit
86c1409d3f
9 changed files with 133 additions and 22 deletions
29
client/src/components/ChatMenu.tsx
Normal file
29
client/src/components/ChatMenu.tsx
Normal 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);
|
||||||
|
|
@ -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 }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
18
client/src/components/ParticipantsList.tsx
Normal file
18
client/src/components/ParticipantsList.tsx
Normal 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;
|
||||||
31
client/src/components/RoomHeader.tsx
Normal file
31
client/src/components/RoomHeader.tsx
Normal 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);
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue