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",
"tailwindcss": "3.3.2",
"typescript": "5.1.6",
"uuid": "^9.0.0",
"zustand": "^4.3.8"
},
"devDependencies": {
"@types/uuid": "^9.0.2"
}
}

View File

@ -9,6 +9,7 @@ import {
Tooltip,
Avatar,
Indicator,
Menu,
} from "@mantine/core";
import {
IconPlug,
@ -17,24 +18,28 @@ import {
IconEdit,
IconPlugOff,
IconChevronDown,
IconUserCog,
IconUser,
} 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 useBasicStore from "@/store/basic";
import { useDisclosure } from "@mantine/hooks";
import useSocketStore from "@/store/socket";
import { environment } from "@/config";
type Props = {
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 storeName = useBasicStore((state) => state.name); // get name from basic store
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 [popoverOpened, setPopoverOpened] = useState(false); // control popover open/close
const [onlineUsers, setOnlineUsers] = useState<Record<string, string>>({}); // online users
useEffect(() => {
setName(storeName);
@ -42,6 +47,16 @@ const ChatroomTitle: FC<Props> = ({ targetSocketId, handleTargetSocketIdChange }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storeName]);
useEffect(() => {
socket?.on("online_user", (onlineUsers: Record<string, string>) => {
setOnlineUsers(onlineUsers);
console.log("online_user", onlineUsers);
});
return () => {
socket?.off("online_user");
};
}, [socket]);
return (
<>
<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"}
withBorder
>
<Avatar
src={null}
alt="User"
color="blue"
radius="xl"
w="fit-content"
>
<Avatar src={null} alt="User" color="blue" radius="xl" w="fit-content">
{name}
</Avatar>
</Indicator>
@ -148,8 +157,55 @@ const ChatroomTitle: FC<Props> = ({ targetSocketId, handleTargetSocketIdChange }
w={170}
placeholder="Your target Socket ID"
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>
<NameModal opened={modalOpened} onClose={modalClose} />

View File

@ -2,27 +2,37 @@ import { TextInput, Button, Modal, Text } from "@mantine/core";
import { FC } from "react";
import { useForm } from "@mantine/form";
import useBasicStore from "@/store/basic";
type Props = {
import useSocketStore from "@/store/socket";
import { useEffect } from "react";
import { SocketOnlineUser } from "@/types/next";
type NameModalProps = {
opened: boolean;
onClose: () => void;
};
const NameModal: FC<Props> = ({ opened, onClose }) => {
const NameModal: FC<NameModalProps> = ({ opened, onClose }) => {
const [name, setName] = useBasicStore((state) => [state.name, state.setName]);
const { socket, emit } = useSocketStore((state) => state);
const form = useForm({
initialValues: {
name: "",
},
initialValues: { name },
validate: {
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 }) => {
console.log(name);
setName(name);
onClose();
};
return (
<Modal opened={opened} onClose={onClose} title="New Name">
<form onSubmit={form.onSubmit(handleSubmit)}>
@ -36,8 +46,7 @@ const NameModal: FC<Props> = ({ opened, onClose }) => {
Submit
</Button>
<Text size="xs" mt={5}>
Your current name is{" "}
<span style={{ fontWeight: "bold", backgroundColor: "#fcc419" }}>{name}</span>
Your current name is <span className="current-name">{name}</span>
</Text>
</form>
</Modal>

View File

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

View File

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

View File

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