From 8f12e7d06b9ec1daa4157ba0a307a04407ecc6e7 Mon Sep 17 00:00:00 2001 From: Justin Xiao Date: Fri, 21 Jul 2023 02:52:58 +0800 Subject: [PATCH] feat: add broadcast emit and rename message event --- backend/src/server.ts | 18 ++- frontend/src/app/home/page.tsx | 15 ++- frontend/src/components/ChatroomInput.tsx | 32 ++++- frontend/src/components/ChatroomTitle.tsx | 111 +++++++++++++++--- frontend/src/components/NameModal.tsx | 2 +- frontend/src/pages/api/socket/broadcast.ts | 20 ++++ .../socket/{message.ts => private_message.ts} | 16 +-- frontend/src/store/socket.ts | 23 +++- frontend/src/types/next.ts | 17 ++- 9 files changed, 205 insertions(+), 49 deletions(-) create mode 100644 frontend/src/pages/api/socket/broadcast.ts rename frontend/src/pages/api/socket/{message.ts => private_message.ts} (54%) diff --git a/backend/src/server.ts b/backend/src/server.ts index c18eb41..3170199 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -9,7 +9,7 @@ import { Server } from 'socket.io'; const server = http.createServer(app); const io = new Server(server, { cors: { - origin: corsUrl + origin: corsUrl, }, }); @@ -35,11 +35,21 @@ io.on('connection', (socket) => { // ); }); - socket.on('message', (message, callback) => { + socket.on('broadcast', (broadcast, callback) => { + console.log('PRODUCTION SERVER: Broadcast ', broadcast); + io.emit('broadcast', broadcast); + if (callback) { + callback({ + ok: true, + }); + } + }); + + socket.on('private_message', (message, callback) => { console.log('PRODUCTION SERVER: ', message); const { from: sourceSocketId, to: targetSocketId } = message; - io.to(targetSocketId).emit('message', message); - io.to(sourceSocketId).emit('message', message); + io.to(targetSocketId).emit('private_message', message); + io.to(sourceSocketId).emit('private_message', message); if (callback) { callback({ ok: true, diff --git a/frontend/src/app/home/page.tsx b/frontend/src/app/home/page.tsx index 529b23e..0f329be 100644 --- a/frontend/src/app/home/page.tsx +++ b/frontend/src/app/home/page.tsx @@ -2,7 +2,7 @@ import { createStyles, Card, Container, Text, ScrollArea, Avatar } from "@mantine/core"; import useSocketStore from "@/store/socket"; import { useEffect, useRef, useState, useLayoutEffect } from "react"; -import { MessageWithMe, SocketMessage } from "@/types/next"; +import { MessageWithMe, SocketPrivateMessage, SocketBroadcastMessage } from "@/types/next"; import ChatroomTitle from "@/components/ChatroomTitle"; import ChatroomInput from "@/components/ChatroomInput"; @@ -63,7 +63,6 @@ const useStyles = createStyles((theme) => ({ flexWrap: "nowrap", fontSize: theme.fontSizes.xs, color: theme.colors.gray[5], - marginLeft: theme.spacing.xs, marginRight: theme.spacing.xs, alignItems: "center", }, @@ -82,7 +81,7 @@ const useStyles = createStyles((theme) => ({ export default function Home() { const { classes } = useStyles(); - const { socket, connect } = useSocketStore((state) => state); // deconstructing socket and its method from socket store + const { socket, connect } = useSocketStore(); // deconstructing socket and its method from socket store const chatViewportRef = useRef(null); // binding chat viewport ref to scroll to bottom const [targetSocketId, setTargetSocketId] = useState(""); // target socket id input value const [messages, setMessages] = useState([]); // show messages on ScrollArea @@ -102,15 +101,19 @@ export default function Home() { useEffect(() => { if (!socket) return; - socket.on("message", (message: SocketMessage) => { + socket.on("private_message", (message: SocketPrivateMessage) => { + setMessages((state) => [...state, { ...message, me: message.from === socket?.id }]); + }); + socket.on("broadcast", (message: SocketBroadcastMessage) => { setMessages((state) => [...state, { ...message, me: message.from === socket?.id }]); }); socket.on("online_user", (onlineUsers: Record) => { setOnlineUsers(onlineUsers); }); return () => { - socket?.off("message"); - socket?.off("online_user"); + socket.off("private_message"); + socket.off("online_user"); + socket.off("broadcast"); }; }, [socket]); diff --git a/frontend/src/components/ChatroomInput.tsx b/frontend/src/components/ChatroomInput.tsx index 4f95e4f..796d262 100644 --- a/frontend/src/components/ChatroomInput.tsx +++ b/frontend/src/components/ChatroomInput.tsx @@ -13,21 +13,43 @@ const useStyles = createStyles((theme) => ({ }, })); const ChatroomInput: FC = ({ targetSocketId }) => { - const { socket, emit } = useSocketStore((state) => state); // deconstructing socket and its method from socket store + const { socket, emitMode, emit } = useSocketStore(); // deconstructing socket and its method from socket store const [message, setMessage] = useState(""); // message input value const messageInputRef = useRef(null); // binding message input ref to focus const { classes } = useStyles(); const sendMessage = () => { - console.log("sendMessage", message === ""); if (!message) return toast.error("Please enter a message"); if (!socket?.connected) return toast.error("Please reconnect server first"); - if (!targetSocketId) return toast.error("Please enter a target socket id"); - emit("message", { from: socket?.id, to: targetSocketId, timestamp: Date.now(), message }); + if (!targetSocketId && emitMode === "private_message") + return toast.error("Please enter a target socket id"); + + switch (emitMode) { + case "private_message": { + const messageObj = { + from: socket?.id, + to: targetSocketId, + timestamp: Date.now(), + message, + }; + emit("private_message", messageObj); + break; + } + case "broadcast": { + const broadcastObj = { + from: socket?.id, + timestamp: Date.now(), + message, + }; + emit("broadcast", broadcastObj); + break; + } + default: + break; + } setMessage(""); messageInputRef.current?.focus(); }; - const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" && e.altKey !== true && e.shiftKey !== true && e.ctrlKey !== true) { e.preventDefault(); diff --git a/frontend/src/components/ChatroomTitle.tsx b/frontend/src/components/ChatroomTitle.tsx index b7a7439..d0486e9 100644 --- a/frontend/src/components/ChatroomTitle.tsx +++ b/frontend/src/components/ChatroomTitle.tsx @@ -2,7 +2,6 @@ import { Group, Text, - Input, ActionIcon, Popover, CopyButton, @@ -10,6 +9,10 @@ import { Avatar as MantineAvatar, Indicator, Menu, + SegmentedControl, + Center, + Box, + TextInput, } from "@mantine/core"; import { IconPlug, @@ -19,6 +22,9 @@ import { IconChevronDown, IconUserCog, IconUser, + IconBroadcast, + IconUserShare, + IconClipboard, } from "@tabler/icons-react"; import { SetStateAction, Dispatch, FC, useEffect, useState } from "react"; import useSocketStore from "@/store/socket"; @@ -31,18 +37,20 @@ type Props = { }; const ChatroomTitle: FC = ({ targetSocketId, setTargetSocketId }) => { - const { socket, connect, disconnect } = useSocketStore(); // deconstructing socket and its method from socket store + const { socket, emitMode, setEmitMode, connect, disconnect } = useSocketStore(); // deconstructing socket and its method from socket store const [popoverOpened, setPopoverOpened] = useState(false); // control popover open/close const [onlineUsers, setOnlineUsers] = useState>({}); // online users + const [opened, setOpened] = useState(false); useEffect(() => { - socket?.on("online_user", (onlineUsers: Record) => { + if (!socket) return; + socket.on("online_user", (onlineUsers: Record) => { setOnlineUsers(onlineUsers); console.log("online_user", onlineUsers); }); return () => { - socket?.off("online_user"); + socket.off("online_user"); }; }, [socket]); @@ -118,25 +126,96 @@ const ChatroomTitle: FC = ({ targetSocketId, setTargetSocketId }) => { - - To: - setTargetSocketId(e.currentTarget.value)} - /> - + + + + { + setEmitMode(value); + if (value === "broadcast") { + setTargetSocketId(""); + setOpened(false); + } else { + setOpened(true); + } + }} + data={[ + { + value: "broadcast", + label: ( +
+ + Broadcast +
+ ), + }, + { + value: "private_message", + label: ( +
+ setOpened(true)} + /> + To +
+ ), + }, + ]} + /> +
+ ({ + background: + theme.colorScheme === "dark" + ? theme.colors.dark[7] + : theme.white, + })} + > + setTargetSocketId(e.currentTarget.value)} + rightSection={ + + { + navigator.clipboard.readText().then((text) => { + setTargetSocketId(text); + }); + }} + /> + + } + /> + +
+ - - + + {environment === "development" - ? "Not available in development" + ? "Not available" // in development mode, hide online user list because it's a server side feature : "Online user"} {socket?.connected && diff --git a/frontend/src/components/NameModal.tsx b/frontend/src/components/NameModal.tsx index fdd1d94..1622874 100644 --- a/frontend/src/components/NameModal.tsx +++ b/frontend/src/components/NameModal.tsx @@ -23,7 +23,7 @@ const NameModal: FC = ({ opened, onClose }) => { useEffect(() => { if (!socket) return; - emit("join", { socketId: socket.id, name: name }); + emit("join", { socketId: socket.id, name: name }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [socket?.id, name]); diff --git a/frontend/src/pages/api/socket/broadcast.ts b/frontend/src/pages/api/socket/broadcast.ts new file mode 100644 index 0000000..64177b4 --- /dev/null +++ b/frontend/src/pages/api/socket/broadcast.ts @@ -0,0 +1,20 @@ +import { NextApiResponseServerIO, SocketBroadcastMessage } from "@/types/next"; +import { NextApiRequest } from "next"; + +const broadcast = (req: NextApiRequest, res: NextApiResponseServerIO) => { + if (req.method === "POST") { + // get message + const { from: sourceSocketId, timestamp, message } = req.body as SocketBroadcastMessage; + console.log("PRODUCTION SERVER: Broadcast ", message) + // dispatch to channel "message" + res?.socket?.server?.io?.emit("broadcast", { + from: sourceSocketId, + message, + timestamp, + } as SocketBroadcastMessage); + // return message + res.status(201).json(message); + } +}; + +export default broadcast; diff --git a/frontend/src/pages/api/socket/message.ts b/frontend/src/pages/api/socket/private_message.ts similarity index 54% rename from frontend/src/pages/api/socket/message.ts rename to frontend/src/pages/api/socket/private_message.ts index 06d2e14..2f8ebee 100644 --- a/frontend/src/pages/api/socket/message.ts +++ b/frontend/src/pages/api/socket/private_message.ts @@ -1,7 +1,7 @@ -import { NextApiResponseServerIO, SocketMessage } from "@/types/next"; +import { NextApiResponseServerIO, SocketPrivateMessage } from "@/types/next"; import { NextApiRequest } from "next"; -const message = (req: NextApiRequest, res: NextApiResponseServerIO) => { +const private_message = (req: NextApiRequest, res: NextApiResponseServerIO) => { if (req.method === "POST") { // get message const { @@ -9,25 +9,25 @@ const message = (req: NextApiRequest, res: NextApiResponseServerIO) => { to: targetSocketId, timestamp, message, - } = req.body as SocketMessage; + } = req.body as SocketPrivateMessage; // dispatch to channel "message" - res?.socket?.server?.io?.to(targetSocketId).emit("message", { + res?.socket?.server?.io?.to(targetSocketId).emit("private_message", { from: sourceSocketId, to: targetSocketId, message, timestamp, - } as SocketMessage); - res?.socket?.server?.io?.to(sourceSocketId).emit("message", { + } as SocketPrivateMessage); + res?.socket?.server?.io?.to(sourceSocketId).emit("private_message", { from: sourceSocketId, to: targetSocketId, message, timestamp, - } as SocketMessage); + } as SocketPrivateMessage); // return message res.status(201).json(message); } }; -export default message; +export default private_message; diff --git a/frontend/src/store/socket.ts b/frontend/src/store/socket.ts index 797e47e..41ce245 100644 --- a/frontend/src/store/socket.ts +++ b/frontend/src/store/socket.ts @@ -1,11 +1,20 @@ import { environment, SOCKET_URL } from "@/config"; +import { SocketBroadcastMessage, SocketOnlineUser, SocketPrivateMessage } from "@/types/next"; import { toast } from "react-hot-toast"; import { io, Socket } from "socket.io-client"; import { create } from "zustand"; +type EmitModeDataTypes = { + join: SocketOnlineUser; + broadcast: SocketBroadcastMessage; + private_message: SocketPrivateMessage; +}; + type Store = { socket: null | Socket; - emit: (event: string, data: T) => void; + emitMode: "broadcast" | "private_message"; + setEmitMode: (mode: "broadcast" | "private_message") => void; + emit: (event: T, data: EmitModeDataTypes[T]) => void; connect: () => void; disconnect: () => void; }; @@ -13,18 +22,22 @@ type Store = { const useSocketStore = create((set, get) => { return { socket: null, + emitMode: "broadcast", + setEmitMode: (mode) => { + set({ emitMode: mode }); + }, /** * Emits an event with data. * * @param event - The name of the event to emit. * @param data - The data to send along with the event. */ - emit: (event: string, data: T) => { - console.log("emit", event, data); + emit: (event, data) => { + // console.log("emit", event, data); // Check if environment is development if (environment === "development") { - // Send a POST request to the /api/socket/message endpoint with the data - fetch("/api/socket/message", { + // Send a POST request to the /api/socket/${event} endpoint with the data + fetch(`/api/socket/${event}`, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/frontend/src/types/next.ts b/frontend/src/types/next.ts index 6fdeaf2..ef7e1c1 100644 --- a/frontend/src/types/next.ts +++ b/frontend/src/types/next.ts @@ -10,24 +10,33 @@ export type NextApiResponseServerIO = NextApiResponse & { }; }; -export type SocketMessage = { +export type SocketPrivateMessage = { from: string; to: string; message: string; timestamp: number; }; +export type SocketBroadcastMessage = { + from: string; + message: string; + timestamp: number; +}; + export type SocketOnlineUser = { socketId: string; name: string | null; }; /** - * Originally, I used SocketMessage type, and only distinguish whether the message is from me or not by checking the socket id in "from" property. + * Originally, I used SocketPrivateMessage type, and only distinguish whether the message is from me or not by checking the socket id in "from" property. * Then I can put the message on the right side of the ScrollArea if it is from me, and on the left side if it is not. * But I found when socket reconnects, the message on the right side will be moved to the left side because the "from" property is different. * So I decided to add "me" property to distinguish whether the message is from me or not, and use it to put the message on the right side or left side. */ -export interface MessageWithMe extends SocketMessage { +export type MessageWithMe = { + from: string; me: boolean; -} + message: string; + timestamp: number; +};