From 206f895feb3cc1cc2c1507f709fcfcc46bd5587d Mon Sep 17 00:00:00 2001 From: Justin Xiao Date: Mon, 17 Jul 2023 03:37:40 +0800 Subject: [PATCH] feat: optimized UI --- frontend/src/app/home/page.tsx | 179 +++++++++++++++++++--- frontend/src/app/provide.tsx | 1 + frontend/src/app/test/page.tsx | 116 -------------- frontend/src/pages/api/socket/message.ts | 22 ++- frontend/src/pages/api/socket/socketio.ts | 8 +- frontend/src/store/socket.ts | 13 +- frontend/src/types/next.ts | 7 + server/index.js | 18 ++- 8 files changed, 210 insertions(+), 154 deletions(-) delete mode 100644 frontend/src/app/test/page.tsx diff --git a/frontend/src/app/home/page.tsx b/frontend/src/app/home/page.tsx index a034da0..9a7aa6e 100644 --- a/frontend/src/app/home/page.tsx +++ b/frontend/src/app/home/page.tsx @@ -1,6 +1,7 @@ "use client"; import { Button, + createStyles, Card, Container, Group, @@ -10,28 +11,83 @@ import { ScrollArea, TextInput, ActionIcon, + Popover, + CopyButton, + Tooltip, } from "@mantine/core"; -import { IconArrowRight } from "@tabler/icons-react"; +import { + IconArrowRight, + IconCheck, + IconCopy, + IconSettings, + IconEdit, + IconPlugOff, +} from "@tabler/icons-react"; import NameModal from "@/components/NameModal"; import { useDisclosure } from "@mantine/hooks"; import useSocketStore from "@/store/socket"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useLayoutEffect } from "react"; import useBasicStore from "@/store/basic"; +import { SocketMessage } from "@/types/next"; + +const useStyles = createStyles((theme) => ({ + rightMessageField: { + display: "flex", + flexDirection: "row-reverse", + flexWrap: "nowrap", + alignItems: "center", + width: "100%", + marginTop: theme.spacing.xs, + marginBottom: theme.spacing.xs, + }, + rightMessage: { + width: "fit-content", + padding: theme.spacing.xs, + borderRadius: theme.radius.md, + backgroundColor: theme.colors.green[2], + }, + leftMessageField: { + display: "flex", + flexDirection: "row", + flexWrap: "nowrap", + alignItems: "center", + width: "100%", + marginTop: theme.spacing.xs, + marginBottom: theme.spacing.xs, + }, + leftMessage: { + width: "fit-content", + padding: theme.spacing.xs, + borderRadius: theme.radius.md, + backgroundColor: theme.colors.gray[2], + }, +})); export default function Home() { const storeName = useBasicStore((state) => state.name); - const [socket, emit] = useSocketStore((state) => [state.socket, state.emit]); + const { socket, emit, disconnect } = useSocketStore((state) => state); + const chatViewportRef = useRef(null); const inputRef = useRef(null); const [name, setName] = useState(null); // avoiding Next.js hydration error + const [socketId, setSocketId] = useState(); // avoiding Next.js hydration error + const [targetSocketId, setTargetSocketId] = useState(""); const [message, setMessage] = useState(""); - const [messages, setMessages] = useState([]); + const [messages, setMessages] = useState([]); const [opened, { open, close }] = useDisclosure(false); + const { classes } = useStyles(); + const scrollToBottom = () => { + console.log("scrollToBottom", chatViewportRef.current?.scrollHeight); + chatViewportRef?.current?.scrollTo({ + top: chatViewportRef.current.scrollHeight, + behavior: "smooth", + }); + }; const sendMessage = () => { - emit("message", message); - setMessages([...messages, message]); + if (!socketId || !message) return; + emit("message", { from: socketId, to: targetSocketId, timestamp: Date.now(), message }); setMessage(""); inputRef.current?.focus(); }; @@ -43,35 +99,116 @@ export default function Home() { }, [storeName]); useEffect(() => { - socket?.on("message", (data) => { - console.log("message", data); + setSocketId(socket?.id); + socket?.on("message", (message: SocketMessage) => { + console.log("message", message); + setMessages((state) => [...state, message]); }); return () => { socket?.off("message"); }; }, [socket]); + useLayoutEffect(() => { + scrollToBottom(); // DOM更新完畢後,才執行scrollToBottom + }, [messages]); + return ( <> - - - + + + {name} - + + + + + + + + + SocketID + {socketId && ( + + {({ copied, copy }) => ( + + + {copied ? ( + + ) : ( + + )} + + + )} + + )} + + + Actions + + + + + + + + + + + + + + + + + + To: + setTargetSocketId(e.target.value)} + /> - - - {messages.map((message, index) => ( - {message} - ))} + + {messages.map((message, index) => { + return ( +
+ + {message.message} + +
+ ); + })}
@@ -90,9 +227,7 @@ export default function Home() { placeholder="Type something..." rightSectionWidth={42} onKeyDown={(e) => { - if (e.key === "Enter") { - sendMessage(); - } + if (e.key === "Enter") sendMessage(); }} />
diff --git a/frontend/src/app/provide.tsx b/frontend/src/app/provide.tsx index b75ac20..84d73a3 100644 --- a/frontend/src/app/provide.tsx +++ b/frontend/src/app/provide.tsx @@ -15,6 +15,7 @@ export function AppProvider({ children }: Prop) { console.log("disconnect"); disconnect(); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( diff --git a/frontend/src/app/test/page.tsx b/frontend/src/app/test/page.tsx deleted file mode 100644 index dcf5bbb..0000000 --- a/frontend/src/app/test/page.tsx +++ /dev/null @@ -1,116 +0,0 @@ -"use client"; -import { - createStyles, - Badge, - Group, - Title, - Text, - Card, - SimpleGrid, - Container, - rem, -} from "@mantine/core"; -import { IconGauge, IconUser, IconCookie } from "@tabler/icons-react"; - -const mockdata = [ - { - title: "Extreme performance", - description: - "This dust is actually a powerful poison that will even make a pro wrestler sick, Regice cloaks itself with frigid air of -328 degrees Fahrenheit", - icon: IconGauge, - }, - { - title: "Privacy focused", - description: - "People say it can run at the same speed as lightning striking, Its icy body is so cold, it will not melt even if it is immersed in magma", - icon: IconUser, - }, - { - title: "No third parties", - description: - "They’re popular, but they’re rare. Trainers who show them off recklessly may be targeted by thieves", - icon: IconCookie, - }, -]; - -const useStyles = createStyles((theme) => ({ - title: { - fontSize: rem(34), - fontWeight: 900, - - [theme.fn.smallerThan("sm")]: { - fontSize: rem(24), - }, - }, - - description: { - maxWidth: 600, - margin: "auto", - - "&::after": { - content: '""', - display: "block", - backgroundColor: theme.fn.primaryColor(), - width: rem(45), - height: rem(2), - marginTop: theme.spacing.sm, - marginLeft: "auto", - marginRight: "auto", - }, - }, - - card: { - border: `${rem(1)} solid ${ - theme.colorScheme === "dark" ? theme.colors.dark[5] : theme.colors.gray[1] - }`, - }, - - cardTitle: { - "&::after": { - content: '""', - display: "block", - backgroundColor: theme.fn.primaryColor(), - width: rem(45), - height: rem(2), - marginTop: theme.spacing.sm, - }, - }, -})); - -export default function FeaturesCards() { - const { classes, theme } = useStyles(); - const features = mockdata.map((feature) => ( - - - - {feature.title} - - - {feature.description} - - - )); - - return ( - - - - Best company ever - - - - - Integrate effortlessly with any technology stack - - - - Every once in a while, you’ll see a Golbat that’s missing some fangs. This happens - when hunger drives it to try biting a Steel-type Pokémon. - - - - {features} - - - ); -} diff --git a/frontend/src/pages/api/socket/message.ts b/frontend/src/pages/api/socket/message.ts index 771a331..06d2e14 100644 --- a/frontend/src/pages/api/socket/message.ts +++ b/frontend/src/pages/api/socket/message.ts @@ -1,13 +1,29 @@ -import { NextApiResponseServerIO } from "@/types/next"; +import { NextApiResponseServerIO, SocketMessage } from "@/types/next"; import { NextApiRequest } from "next"; const message = (req: NextApiRequest, res: NextApiResponseServerIO) => { if (req.method === "POST") { // get message - const message = req.body; + const { + from: sourceSocketId, + to: targetSocketId, + timestamp, + message, + } = req.body as SocketMessage; // dispatch to channel "message" - res?.socket?.server?.io?.emit("message", message); + res?.socket?.server?.io?.to(targetSocketId).emit("message", { + from: sourceSocketId, + to: targetSocketId, + message, + timestamp, + } as SocketMessage); + res?.socket?.server?.io?.to(sourceSocketId).emit("message", { + from: sourceSocketId, + to: targetSocketId, + message, + timestamp, + } as SocketMessage); // return message res.status(201).json(message); diff --git a/frontend/src/pages/api/socket/socketio.ts b/frontend/src/pages/api/socket/socketio.ts index 2cf4126..73e0575 100644 --- a/frontend/src/pages/api/socket/socketio.ts +++ b/frontend/src/pages/api/socket/socketio.ts @@ -18,9 +18,11 @@ const socketio = async (req: NextApiRequest, res: NextApiResponseServerIO) => { path: "/api/socket/socketio", addTrailingSlash: false, }); - io.on("connect", () => { - console.log("SOCKET CONNECTED!"); - }) + io.on("connect", (socket) => { + console.log("SOCKET CONNECTED!", socket.id); + }).on("disconnect", () => { + console.log("SOCKET DISCONNECTED!"); + }); // append SocketIO server to Next.js socket server response res.socket.server.io = io; } else { diff --git a/frontend/src/store/socket.ts b/frontend/src/store/socket.ts index 40af433..29a31f6 100644 --- a/frontend/src/store/socket.ts +++ b/frontend/src/store/socket.ts @@ -1,4 +1,5 @@ import { SOCKET_URL } from "@/config"; +import { SocketMessage } from "@/types/next"; import { toast } from "react-hot-toast"; import { io, Socket } from "socket.io-client"; import { create } from "zustand"; @@ -6,7 +7,7 @@ import { create } from "zustand"; type Store = { socketReady: boolean; socket: null | Socket; - emit: (event: string, data: any) => void; + emit: (event: string, data: SocketMessage) => void; disconnect: () => void; }; @@ -32,7 +33,8 @@ const useSocketStore = create((set, get) => { return { socketReady: false, socket: socket, - emit: async (event: string, data: any) => { + emit: async (event: string, data: SocketMessage) => { + console.log("emit", event, data); if (process.env.NODE_ENV === "development") { try { const response = await fetch("/api/socket/message", { @@ -42,14 +44,13 @@ const useSocketStore = create((set, get) => { }, body: JSON.stringify(data), }); - console.log("response", response); } catch (error) { if (error instanceof Error) toast.error(error?.message); } } else { // This response needs to define on server at first. - socket.emit(event, data, (response: any) => { - console.log(response.status); // ok + socket.emit(event, data, (response: { ok: boolean }) => { + if (!response.ok) toast.error("Something went wrong"); }); } }, @@ -58,6 +59,8 @@ const useSocketStore = create((set, get) => { if (socket) { socket.disconnect(); set({ socket: null }); + } else { + toast.error("Socket not connected"); } }, }; diff --git a/frontend/src/types/next.ts b/frontend/src/types/next.ts index bfa65cd..4284bec 100644 --- a/frontend/src/types/next.ts +++ b/frontend/src/types/next.ts @@ -9,3 +9,10 @@ export type NextApiResponseServerIO = NextApiResponse & { }; }; }; + +export type SocketMessage = { + from: string; + to: string; + message: string; + timestamp: number; +}; diff --git a/server/index.js b/server/index.js index 30227bb..ce3310a 100644 --- a/server/index.js +++ b/server/index.js @@ -15,12 +15,20 @@ const userSockets = new Map(); io.on("connection", (socket) => { console.log(socket.id); + socket.on("join", (userId) => { + userSockets.set(userId, socket.id); + console.log(userSockets); + }); + socket.on("message", (message, callback) => { - console.log(message); - io.emit("message", message); - callback({ - status: "ok", - }); + const { from: sourceSocketId, to: targetSocketId } = message; + io.to(targetSocketId).emit("message", message); + io.to(sourceSocketId).emit("message", message); + if (callback) { + callback({ + ok: true, + }); + } }); socket.on("disconnect", () => {