feat: add broadcast emit and rename message event

main
Justin Xiao 2023-07-21 02:52:58 +08:00
parent 519aac58a2
commit 8f12e7d06b
9 changed files with 205 additions and 49 deletions

View File

@ -9,7 +9,7 @@ import { Server } from 'socket.io';
const server = http.createServer(app); const server = http.createServer(app);
const io = new Server(server, { const io = new Server(server, {
cors: { 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); console.log('PRODUCTION SERVER: ', message);
const { from: sourceSocketId, to: targetSocketId } = message; const { from: sourceSocketId, to: targetSocketId } = message;
io.to(targetSocketId).emit('message', message); io.to(targetSocketId).emit('private_message', message);
io.to(sourceSocketId).emit('message', message); io.to(sourceSocketId).emit('private_message', message);
if (callback) { if (callback) {
callback({ callback({
ok: true, ok: true,

View File

@ -2,7 +2,7 @@
import { createStyles, Card, Container, Text, ScrollArea, Avatar } from "@mantine/core"; import { createStyles, Card, Container, Text, ScrollArea, Avatar } from "@mantine/core";
import useSocketStore from "@/store/socket"; import useSocketStore from "@/store/socket";
import { useEffect, useRef, useState, useLayoutEffect } from "react"; 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 ChatroomTitle from "@/components/ChatroomTitle";
import ChatroomInput from "@/components/ChatroomInput"; import ChatroomInput from "@/components/ChatroomInput";
@ -63,7 +63,6 @@ const useStyles = createStyles((theme) => ({
flexWrap: "nowrap", flexWrap: "nowrap",
fontSize: theme.fontSizes.xs, fontSize: theme.fontSizes.xs,
color: theme.colors.gray[5], color: theme.colors.gray[5],
marginLeft: theme.spacing.xs,
marginRight: theme.spacing.xs, marginRight: theme.spacing.xs,
alignItems: "center", alignItems: "center",
}, },
@ -82,7 +81,7 @@ const useStyles = createStyles((theme) => ({
export default function Home() { export default function Home() {
const { classes } = useStyles(); 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<HTMLDivElement>(null); // binding chat viewport ref to scroll to bottom const chatViewportRef = useRef<HTMLDivElement>(null); // binding chat viewport ref to scroll to bottom
const [targetSocketId, setTargetSocketId] = useState<string>(""); // target socket id input value const [targetSocketId, setTargetSocketId] = useState<string>(""); // target socket id input value
const [messages, setMessages] = useState<MessageWithMe[]>([]); // show messages on ScrollArea const [messages, setMessages] = useState<MessageWithMe[]>([]); // show messages on ScrollArea
@ -102,15 +101,19 @@ export default function Home() {
useEffect(() => { useEffect(() => {
if (!socket) return; 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 }]); setMessages((state) => [...state, { ...message, me: message.from === socket?.id }]);
}); });
socket.on("online_user", (onlineUsers: Record<string, string>) => { socket.on("online_user", (onlineUsers: Record<string, string>) => {
setOnlineUsers(onlineUsers); setOnlineUsers(onlineUsers);
}); });
return () => { return () => {
socket?.off("message"); socket.off("private_message");
socket?.off("online_user"); socket.off("online_user");
socket.off("broadcast");
}; };
}, [socket]); }, [socket]);

View File

@ -13,21 +13,43 @@ const useStyles = createStyles((theme) => ({
}, },
})); }));
const ChatroomInput: FC<Props> = ({ targetSocketId }) => { const ChatroomInput: FC<Props> = ({ 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 [message, setMessage] = useState(""); // message input value
const messageInputRef = useRef<HTMLTextAreaElement>(null); // binding message input ref to focus const messageInputRef = useRef<HTMLTextAreaElement>(null); // binding message input ref to focus
const { classes } = useStyles(); const { classes } = useStyles();
const sendMessage = () => { const sendMessage = () => {
console.log("sendMessage", message === "");
if (!message) return toast.error("Please enter a message"); if (!message) return toast.error("Please enter a message");
if (!socket?.connected) return toast.error("Please reconnect server first"); if (!socket?.connected) return toast.error("Please reconnect server first");
if (!targetSocketId) return toast.error("Please enter a target socket id"); if (!targetSocketId && emitMode === "private_message")
emit("message", { from: socket?.id, to: targetSocketId, timestamp: Date.now(), 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(""); setMessage("");
messageInputRef.current?.focus(); messageInputRef.current?.focus();
}; };
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && e.altKey !== true && e.shiftKey !== true && e.ctrlKey !== true) { if (e.key === "Enter" && e.altKey !== true && e.shiftKey !== true && e.ctrlKey !== true) {
e.preventDefault(); e.preventDefault();

View File

@ -2,7 +2,6 @@
import { import {
Group, Group,
Text, Text,
Input,
ActionIcon, ActionIcon,
Popover, Popover,
CopyButton, CopyButton,
@ -10,6 +9,10 @@ import {
Avatar as MantineAvatar, Avatar as MantineAvatar,
Indicator, Indicator,
Menu, Menu,
SegmentedControl,
Center,
Box,
TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { import {
IconPlug, IconPlug,
@ -19,6 +22,9 @@ import {
IconChevronDown, IconChevronDown,
IconUserCog, IconUserCog,
IconUser, IconUser,
IconBroadcast,
IconUserShare,
IconClipboard,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { SetStateAction, Dispatch, FC, useEffect, useState } from "react"; import { SetStateAction, Dispatch, FC, useEffect, useState } from "react";
import useSocketStore from "@/store/socket"; import useSocketStore from "@/store/socket";
@ -31,18 +37,20 @@ type Props = {
}; };
const ChatroomTitle: FC<Props> = ({ targetSocketId, setTargetSocketId }) => { const ChatroomTitle: FC<Props> = ({ 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 [popoverOpened, setPopoverOpened] = useState(false); // control popover open/close
const [onlineUsers, setOnlineUsers] = useState<Record<string, string>>({}); // online users const [onlineUsers, setOnlineUsers] = useState<Record<string, string>>({}); // online users
const [opened, setOpened] = useState(false);
useEffect(() => { useEffect(() => {
socket?.on("online_user", (onlineUsers: Record<string, string>) => { if (!socket) return;
socket.on("online_user", (onlineUsers: Record<string, string>) => {
setOnlineUsers(onlineUsers); setOnlineUsers(onlineUsers);
console.log("online_user", onlineUsers); console.log("online_user", onlineUsers);
}); });
return () => { return () => {
socket?.off("online_user"); socket.off("online_user");
}; };
}, [socket]); }, [socket]);
@ -118,25 +126,96 @@ const ChatroomTitle: FC<Props> = ({ targetSocketId, setTargetSocketId }) => {
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
</Group> </Group>
<Group noWrap w={220}> <Group noWrap>
<Text w={20}>To:</Text> <Popover
<Input trapFocus
w={170} position="bottom"
placeholder="Target Socket ID" offset={{
value={targetSocketId} mainAxis: 5,
onChange={(e) => setTargetSocketId(e.currentTarget.value)} crossAxis: 30,
/> }}
<Menu shadow="md" width={200}> shadow="md"
opened={opened}
onChange={setOpened}
>
<Popover.Target>
<SegmentedControl
size="xs"
value={emitMode}
onChange={(value: "broadcast" | "private_message") => {
setEmitMode(value);
if (value === "broadcast") {
setTargetSocketId("");
setOpened(false);
} else {
setOpened(true);
}
}}
data={[
{
value: "broadcast",
label: (
<Center>
<IconBroadcast size="1rem" />
<Box ml={10}>Broadcast</Box>
</Center>
),
},
{
value: "private_message",
label: (
<Center>
<IconUserShare
size="1rem"
onClick={() => setOpened(true)}
/>
<Box ml={10}>To</Box>
</Center>
),
},
]}
/>
</Popover.Target>
<Popover.Dropdown
sx={(theme) => ({
background:
theme.colorScheme === "dark"
? theme.colors.dark[7]
: theme.white,
})}
>
<TextInput
label="Socket id"
placeholder="Target Socket id"
size="xs"
value={targetSocketId}
onChange={(e) => setTargetSocketId(e.currentTarget.value)}
rightSection={
<ActionIcon variant="subtle">
<IconClipboard
size="1rem"
onClick={() => {
navigator.clipboard.readText().then((text) => {
setTargetSocketId(text);
});
}}
/>
</ActionIcon>
}
/>
</Popover.Dropdown>
</Popover>
<Menu shadow="md" width="fit-content">
<Menu.Target> <Menu.Target>
<ActionIcon> <ActionIcon variant="subtle">
<IconUserCog size="1.125rem" /> <IconUserCog size="1.25em" />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Label> <Menu.Label>
{environment === "development" {environment === "development"
? "Not available in development" ? "Not available" // in development mode, hide online user list because it's a server side feature
: "Online user"} : "Online user"}
</Menu.Label> </Menu.Label>
{socket?.connected && {socket?.connected &&

View File

@ -23,7 +23,7 @@ const NameModal: FC<NameModalProps> = ({ opened, onClose }) => {
useEffect(() => { useEffect(() => {
if (!socket) return; if (!socket) return;
emit<SocketOnlineUser>("join", { socketId: socket.id, name: name }); emit("join", { socketId: socket.id, name: name });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [socket?.id, name]); }, [socket?.id, name]);

View File

@ -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;

View File

@ -1,7 +1,7 @@
import { NextApiResponseServerIO, SocketMessage } from "@/types/next"; import { NextApiResponseServerIO, SocketPrivateMessage } from "@/types/next";
import { NextApiRequest } from "next"; import { NextApiRequest } from "next";
const message = (req: NextApiRequest, res: NextApiResponseServerIO) => { const private_message = (req: NextApiRequest, res: NextApiResponseServerIO) => {
if (req.method === "POST") { if (req.method === "POST") {
// get message // get message
const { const {
@ -9,25 +9,25 @@ const message = (req: NextApiRequest, res: NextApiResponseServerIO) => {
to: targetSocketId, to: targetSocketId,
timestamp, timestamp,
message, message,
} = req.body as SocketMessage; } = req.body as SocketPrivateMessage;
// dispatch to channel "message" // dispatch to channel "message"
res?.socket?.server?.io?.to(targetSocketId).emit("message", { res?.socket?.server?.io?.to(targetSocketId).emit("private_message", {
from: sourceSocketId, from: sourceSocketId,
to: targetSocketId, to: targetSocketId,
message, message,
timestamp, timestamp,
} as SocketMessage); } as SocketPrivateMessage);
res?.socket?.server?.io?.to(sourceSocketId).emit("message", { res?.socket?.server?.io?.to(sourceSocketId).emit("private_message", {
from: sourceSocketId, from: sourceSocketId,
to: targetSocketId, to: targetSocketId,
message, message,
timestamp, timestamp,
} as SocketMessage); } as SocketPrivateMessage);
// return message // return message
res.status(201).json(message); res.status(201).json(message);
} }
}; };
export default message; export default private_message;

View File

@ -1,11 +1,20 @@
import { environment, SOCKET_URL } from "@/config"; import { environment, SOCKET_URL } from "@/config";
import { SocketBroadcastMessage, SocketOnlineUser, SocketPrivateMessage } from "@/types/next";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { io, Socket } from "socket.io-client"; import { io, Socket } from "socket.io-client";
import { create } from "zustand"; import { create } from "zustand";
type EmitModeDataTypes = {
join: SocketOnlineUser;
broadcast: SocketBroadcastMessage;
private_message: SocketPrivateMessage;
};
type Store = { type Store = {
socket: null | Socket; socket: null | Socket;
emit: <T>(event: string, data: T) => void; emitMode: "broadcast" | "private_message";
setEmitMode: (mode: "broadcast" | "private_message") => void;
emit: <T extends keyof EmitModeDataTypes>(event: T, data: EmitModeDataTypes[T]) => void;
connect: () => void; connect: () => void;
disconnect: () => void; disconnect: () => void;
}; };
@ -13,18 +22,22 @@ type Store = {
const useSocketStore = create<Store>((set, get) => { const useSocketStore = create<Store>((set, get) => {
return { return {
socket: null, socket: null,
emitMode: "broadcast",
setEmitMode: (mode) => {
set({ emitMode: mode });
},
/** /**
* Emits an event with data. * Emits an event with data.
* *
* @param event - The name of the event to emit. * @param event - The name of the event to emit.
* @param data - The data to send along with the event. * @param data - The data to send along with the event.
*/ */
emit: <T>(event: string, data: T) => { emit: (event, data) => {
console.log("emit", event, data); // console.log("emit", event, data);
// Check if environment is development // Check if environment is development
if (environment === "development") { if (environment === "development") {
// Send a POST request to the /api/socket/message endpoint with the data // Send a POST request to the /api/socket/${event} endpoint with the data
fetch("/api/socket/message", { fetch(`/api/socket/${event}`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

View File

@ -10,24 +10,33 @@ export type NextApiResponseServerIO = NextApiResponse & {
}; };
}; };
export type SocketMessage = { export type SocketPrivateMessage = {
from: string; from: string;
to: string; to: string;
message: string; message: string;
timestamp: number; timestamp: number;
}; };
export type SocketBroadcastMessage = {
from: string;
message: string;
timestamp: number;
};
export type SocketOnlineUser = { export type SocketOnlineUser = {
socketId: string; socketId: string;
name: string | null; 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. * 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. * 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. * 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; me: boolean;
} message: string;
timestamp: number;
};