feat: add online user popover
parent
bd7f4a907e
commit
e9aa844d29
|
@ -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
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 });
|
||||||
},
|
},
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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.
|
||||||
|
|
Loading…
Reference in New Issue