feat: add broadcast emit and rename message event
parent
519aac58a2
commit
8f12e7d06b
|
@ -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,
|
||||
|
|
|
@ -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<HTMLDivElement>(null); // binding chat viewport ref to scroll to bottom
|
||||
const [targetSocketId, setTargetSocketId] = useState<string>(""); // target socket id input value
|
||||
const [messages, setMessages] = useState<MessageWithMe[]>([]); // 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<string, string>) => {
|
||||
setOnlineUsers(onlineUsers);
|
||||
});
|
||||
return () => {
|
||||
socket?.off("message");
|
||||
socket?.off("online_user");
|
||||
socket.off("private_message");
|
||||
socket.off("online_user");
|
||||
socket.off("broadcast");
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
|
|
|
@ -13,21 +13,43 @@ const useStyles = createStyles((theme) => ({
|
|||
},
|
||||
}));
|
||||
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 messageInputRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && e.altKey !== true && e.shiftKey !== true && e.ctrlKey !== true) {
|
||||
e.preventDefault();
|
||||
|
|
|
@ -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<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 [onlineUsers, setOnlineUsers] = useState<Record<string, string>>({}); // online users
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
socket?.on("online_user", (onlineUsers: Record<string, string>) => {
|
||||
if (!socket) return;
|
||||
socket.on("online_user", (onlineUsers: Record<string, string>) => {
|
||||
setOnlineUsers(onlineUsers);
|
||||
console.log("online_user", onlineUsers);
|
||||
});
|
||||
return () => {
|
||||
socket?.off("online_user");
|
||||
socket.off("online_user");
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
|
@ -118,25 +126,96 @@ const ChatroomTitle: FC<Props> = ({ targetSocketId, setTargetSocketId }) => {
|
|||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
<Group noWrap w={220}>
|
||||
<Text w={20}>To:</Text>
|
||||
<Input
|
||||
w={170}
|
||||
placeholder="Target Socket ID"
|
||||
<Group noWrap>
|
||||
<Popover
|
||||
trapFocus
|
||||
position="bottom"
|
||||
offset={{
|
||||
mainAxis: 5,
|
||||
crossAxis: 30,
|
||||
}}
|
||||
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);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Menu shadow="md" width={200}>
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<Menu shadow="md" width="fit-content">
|
||||
<Menu.Target>
|
||||
<ActionIcon>
|
||||
<IconUserCog size="1.125rem" />
|
||||
<ActionIcon variant="subtle">
|
||||
<IconUserCog size="1.25em" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>
|
||||
{environment === "development"
|
||||
? "Not available in development"
|
||||
? "Not available" // in development mode, hide online user list because it's a server side feature
|
||||
: "Online user"}
|
||||
</Menu.Label>
|
||||
{socket?.connected &&
|
||||
|
|
|
@ -23,7 +23,7 @@ const NameModal: FC<NameModalProps> = ({ opened, onClose }) => {
|
|||
|
||||
useEffect(() => {
|
||||
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
|
||||
}, [socket?.id, name]);
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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: <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;
|
||||
disconnect: () => void;
|
||||
};
|
||||
|
@ -13,18 +22,22 @@ type Store = {
|
|||
const useSocketStore = create<Store>((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: <T>(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",
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue