feat: add online user popover

main
Justin Xiao 2023-07-18 16:32:24 +08:00
parent bd7f4a907e
commit e9aa844d29
8 changed files with 2099 additions and 50 deletions

View File

@ -1 +1,2 @@
NEXT_PUBLIC_SOCKET_URL=ws://localhost:3001 # if you wanna deploy to Vercel, you need to change this to "/" # if you wanna deploy to Vercel, you need to host bankend and replace the url or create a new .env.production.local file
NEXT_PUBLIC_SOCKET_URL=ws://localhost:3001

File diff suppressed because it is too large Load Diff

View File

@ -32,6 +32,10 @@
"socket.io-client": "^4.7.1", "socket.io-client": "^4.7.1",
"tailwindcss": "3.3.2", "tailwindcss": "3.3.2",
"typescript": "5.1.6", "typescript": "5.1.6",
"uuid": "^9.0.0",
"zustand": "^4.3.8" "zustand": "^4.3.8"
},
"devDependencies": {
"@types/uuid": "^9.0.2"
} }
} }

View File

@ -9,6 +9,7 @@ import {
Tooltip, Tooltip,
Avatar, Avatar,
Indicator, Indicator,
Menu,
} from "@mantine/core"; } from "@mantine/core";
import { import {
IconPlug, IconPlug,
@ -17,24 +18,28 @@ import {
IconEdit, IconEdit,
IconPlugOff, IconPlugOff,
IconChevronDown, IconChevronDown,
IconUserCog,
IconUser,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import React, { ChangeEvent, FC, useEffect, useState } from "react"; import { SetStateAction, Dispatch, FC, useEffect, useState } from "react";
import NameModal from "./NameModal"; import NameModal from "./NameModal";
import useBasicStore from "@/store/basic"; import useBasicStore from "@/store/basic";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import useSocketStore from "@/store/socket"; import useSocketStore from "@/store/socket";
import { environment } from "@/config";
type Props = { type Props = {
targetSocketId: string; targetSocketId: string;
handleTargetSocketIdChange: (e: ChangeEvent<HTMLInputElement>) => void; setTargetSocketId: Dispatch<SetStateAction<string>>;
}; };
const ChatroomTitle: FC<Props> = ({ targetSocketId, handleTargetSocketIdChange }) => { const ChatroomTitle: FC<Props> = ({ targetSocketId, setTargetSocketId }) => {
const { socket, connect, disconnect } = useSocketStore(); // deconstructing socket and its method from socket store const { socket, connect, disconnect } = useSocketStore(); // deconstructing socket and its method from socket store
const storeName = useBasicStore((state) => state.name); // get name from basic store const storeName = useBasicStore((state) => state.name); // get name from basic store
const [name, setName] = useState<string | null>(null); // avoiding Next.js hydration error const [name, setName] = useState<string | null>(null); // avoiding Next.js hydration error
const [modalOpened, { open: modalOpen, close: modalClose }] = useDisclosure(false); // control change name modal open/close const [modalOpened, { open: modalOpen, close: modalClose }] = useDisclosure(false); // control change name modal open/close
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
useEffect(() => { useEffect(() => {
setName(storeName); setName(storeName);
@ -42,6 +47,16 @@ const ChatroomTitle: FC<Props> = ({ targetSocketId, handleTargetSocketIdChange }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [storeName]); }, [storeName]);
useEffect(() => {
socket?.on("online_user", (onlineUsers: Record<string, string>) => {
setOnlineUsers(onlineUsers);
console.log("online_user", onlineUsers);
});
return () => {
socket?.off("online_user");
};
}, [socket]);
return ( return (
<> <>
<Group position="apart" mt="xs" mb="xs" className="flex flex-row flex-nowrap"> <Group position="apart" mt="xs" mb="xs" className="flex flex-row flex-nowrap">
@ -54,13 +69,7 @@ const ChatroomTitle: FC<Props> = ({ targetSocketId, handleTargetSocketIdChange }
color={socket?.connected ? "teal" : "red"} color={socket?.connected ? "teal" : "red"}
withBorder withBorder
> >
<Avatar <Avatar src={null} alt="User" color="blue" radius="xl" w="fit-content">
src={null}
alt="User"
color="blue"
radius="xl"
w="fit-content"
>
{name} {name}
</Avatar> </Avatar>
</Indicator> </Indicator>
@ -148,8 +157,55 @@ const ChatroomTitle: FC<Props> = ({ targetSocketId, handleTargetSocketIdChange }
w={170} w={170}
placeholder="Your target Socket ID" placeholder="Your target Socket ID"
value={targetSocketId} value={targetSocketId}
onChange={handleTargetSocketIdChange} onChange={(e) => setTargetSocketId(e.currentTarget.value)}
/> />
<Menu shadow="md" width={200}>
<Menu.Target>
<ActionIcon>
<IconUserCog size="1.125rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>
{environment === "development" ? "Not available in development" : "Online user"}
</Menu.Label>
{socket?.connected &&
Object.keys(onlineUsers)
.filter((socketId) => socketId !== socket?.id)
.map((socketId) => (
<Menu.Item
key={socketId}
onClick={() => setTargetSocketId(socketId)}
>
<Group
position="apart"
className="flex flex-row flex-nowrap"
>
<Indicator
inline
size={16}
offset={5}
position="bottom-end"
color="teal"
withBorder
>
<Avatar
src={null}
alt="User"
color="blue"
radius="xl"
w="fit-content"
>
<IconUser size="1.5rem" />
</Avatar>
</Indicator>
<Text>{onlineUsers[socketId]}</Text>
</Group>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
</Group> </Group>
</Group> </Group>
<NameModal opened={modalOpened} onClose={modalClose} /> <NameModal opened={modalOpened} onClose={modalClose} />

View File

@ -2,27 +2,37 @@ import { TextInput, Button, Modal, Text } from "@mantine/core";
import { FC } from "react"; import { FC } from "react";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import useBasicStore from "@/store/basic"; import useBasicStore from "@/store/basic";
import useSocketStore from "@/store/socket";
type Props = { import { useEffect } from "react";
import { SocketOnlineUser } from "@/types/next";
type NameModalProps = {
opened: boolean; opened: boolean;
onClose: () => void; onClose: () => void;
}; };
const NameModal: FC<Props> = ({ opened, onClose }) => {
const NameModal: FC<NameModalProps> = ({ opened, onClose }) => {
const [name, setName] = useBasicStore((state) => [state.name, state.setName]); const [name, setName] = useBasicStore((state) => [state.name, state.setName]);
const { socket, emit } = useSocketStore((state) => state);
const form = useForm({ const form = useForm({
initialValues: { initialValues: { name },
name: "",
},
validate: { validate: {
name: (value) => (value.trim().length < 1 ? "Name is required" : null), name: (value) => (value.trim().length < 1 ? "Name is required" : null),
}, },
}); });
useEffect(() => {
if (!socket) return;
emit<SocketOnlineUser>("join", { socketId: socket.id, name: name });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [socket?.id, name]);
// Function to handle form submission
const handleSubmit = ({ name }: { name: string }) => { const handleSubmit = ({ name }: { name: string }) => {
console.log(name);
setName(name); setName(name);
onClose(); onClose();
}; };
return ( return (
<Modal opened={opened} onClose={onClose} title="New Name"> <Modal opened={opened} onClose={onClose} title="New Name">
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
@ -36,8 +46,7 @@ const NameModal: FC<Props> = ({ opened, onClose }) => {
Submit Submit
</Button> </Button>
<Text size="xs" mt={5}> <Text size="xs" mt={5}>
Your current name is{" "} Your current name is <span className="current-name">{name}</span>
<span style={{ fontWeight: "bold", backgroundColor: "#fcc419" }}>{name}</span>
</Text> </Text>
</form> </form>
</Modal> </Modal>

View File

@ -1,15 +1,16 @@
import { create } from "zustand"; import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware"; import { persist, createJSONStorage } from "zustand/middleware";
import { v4 as uuidv4 } from "uuid";
type Store = { type Store = {
name: null | string; name: string;
setName: (name: string) => void; setName: (name: string) => void;
}; };
const useBasicStore = create<Store>()( const useBasicStore = create<Store>()(
persist( persist(
(set) => ({ (set) => ({
name: null, name: uuidv4(),
setName: (name) => { setName: (name) => {
set({ name }); set({ name });
}, },

View File

@ -1,12 +1,11 @@
import { SOCKET_URL } from "@/config"; import { environment, SOCKET_URL } from "@/config";
import { SocketMessage } 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 Store = { type Store = {
socket: null | Socket; socket: null | Socket;
emit: (event: string, data: SocketMessage) => void; emit: <T>(event: string, data: T) => void;
connect: () => void; connect: () => void;
disconnect: () => void; disconnect: () => void;
}; };
@ -14,58 +13,76 @@ type Store = {
const useSocketStore = create<Store>((set, get) => { const useSocketStore = create<Store>((set, get) => {
return { return {
socket: null, socket: null,
emit: async (event: string, data: SocketMessage) => { /**
* 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); console.log("emit", event, data);
if (process.env.NODE_ENV === "development") { // Check if environment is development
try { if (environment === "development") {
const response = await fetch("/api/socket/message", { // Send a POST request to the /api/socket/message endpoint with the data
fetch("/api/socket/message", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
}); }).catch((error) => {
} catch (error) { // Display an error message if there was an error sending the request
if (error instanceof Error) toast.error(error?.message); if (error instanceof Error) toast.error(error?.message);
} });
} else { } else {
const { socket } = get(); const { socket } = get();
if (!socket) return toast.error("Socket not connected"); if (!socket) return toast.error("Socket not connected");
// This callback response needs to define on server at first. // This callback response needs to define on server at first.
// Emit the event with the data and handle the response
socket.emit(event, data, (response: { ok: boolean }) => { socket.emit(event, data, (response: { ok: boolean }) => {
// Display an error message if response.ok is false
if (!response.ok) toast.error("Something went wrong"); if (!response.ok) toast.error("Something went wrong");
}); });
} }
}, },
/**
* Connects to the socket server.
*/
connect: () => { connect: () => {
const { socket } = get(); const { socket } = get();
if (SOCKET_URL === undefined) return toast.error("Socket URL is undefined"); if (SOCKET_URL === undefined) {
// Display error message if socket URL is undefined
return toast.error("Socket URL is undefined");
}
if (socket) { if (socket) {
console.log("Socket already connected", socket); console.log("Socket already connected", socket);
// Display error message if socket is already connected
toast.error("Socket already connected"); toast.error("Socket already connected");
} else { } else {
console.log("Connecting to socket", SOCKET_URL); console.log("Connecting to socket", SOCKET_URL);
const socket = io( const options =
SOCKET_URL, environment === "development"
process.env.NODE_ENV === "development" ? { path: "/api/socket/socketio", addTrailingSlash: false }
? { : {};
path: "/api/socket/socketio", // Connect to the socket server
addTrailingSlash: false, const socket = io(SOCKET_URL, options);
}
: {}
);
socket socket
.on("connect", () => { .on("connect", () => {
console.log("SOCKET CONNECTED!", socket.id); console.log("SOCKET CONNECTED!", socket.id);
// Update the socket in the global state when connected
set({ socket }); set({ socket });
}) })
.on("disconnect", () => { .on("disconnect", () => {
console.log("SOCKET DISCONNECTED!"); console.log("SOCKET DISCONNECTED!");
// Set socket to null in the global state when disconnected
set({ socket: null }); set({ socket: null });
}); });
} }
}, },
/**
* Disconnects the socket if it is connected.
* If the socket is not connected, displays an error message.
*/
disconnect: () => { disconnect: () => {
const { socket } = get(); const { socket } = get();
if (socket) { if (socket) {

View File

@ -17,6 +17,11 @@ export type SocketMessage = {
timestamp: number; 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 SocketMessage 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.