refactor: optimize and refactor codebase
- Extracte chatroom titile as a component - Update introduction in entry page - add me attribute avioding in SocketMessagemain
parent
fc718a7fad
commit
01a69e2b6b
|
@ -1,34 +1,20 @@
|
|||
"use client";
|
||||
import {
|
||||
Button,
|
||||
createStyles,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Text,
|
||||
Divider,
|
||||
Input,
|
||||
ScrollArea,
|
||||
TextInput,
|
||||
ActionIcon,
|
||||
Popover,
|
||||
CopyButton,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconSettings,
|
||||
IconEdit,
|
||||
IconPlugOff,
|
||||
} from "@tabler/icons-react";
|
||||
import NameModal from "@/components/NameModal";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
import useSocketStore from "@/store/socket";
|
||||
import { useEffect, useRef, useState, useLayoutEffect } from "react";
|
||||
import useBasicStore from "@/store/basic";
|
||||
import { SocketMessage } from "@/types/next";
|
||||
import { MessageWithMe, SocketMessage } from "@/types/next";
|
||||
import { toast } from "react-hot-toast";
|
||||
import ChatroomTitle from "@/components/ChatRoomTitle";
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
rightMessageField: {
|
||||
|
@ -64,45 +50,42 @@ const useStyles = createStyles((theme) => ({
|
|||
}));
|
||||
|
||||
export default function Home() {
|
||||
const storeName = useBasicStore((state) => state.name);
|
||||
const { socket, emit, disconnect } = useSocketStore((state) => state);
|
||||
const { socket, connect, emit } = useSocketStore((state) => state); // deconstructing socket and its method from socket store
|
||||
|
||||
const chatViewportRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [name, setName] = useState<string | null>(null); // avoiding Next.js hydration error
|
||||
const [socketId, setSocketId] = useState<string | undefined>(); // avoiding Next.js hydration error
|
||||
const [targetSocketId, setTargetSocketId] = useState<string>("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [messages, setMessages] = useState<SocketMessage[]>([]);
|
||||
const chatViewportRef = useRef<HTMLDivElement>(null); // binding chat viewport ref
|
||||
const messageInputRef = useRef<HTMLInputElement>(null); // binding message input ref
|
||||
|
||||
const [targetSocketId, setTargetSocketId] = useState<string>(""); // target socket id input value
|
||||
const [message, setMessage] = useState(""); // message input value
|
||||
const [messages, setMessages] = useState<MessageWithMe[]>([]); // show messages on ScrollArea
|
||||
|
||||
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 = () => {
|
||||
if (!socketId || !message) return;
|
||||
emit("message", { from: socketId, to: targetSocketId, timestamp: Date.now(), 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 });
|
||||
setMessage("");
|
||||
inputRef.current?.focus();
|
||||
messageInputRef.current?.focus();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setName(storeName);
|
||||
if (!storeName) open();
|
||||
connect();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [storeName]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSocketId(socket?.id);
|
||||
console.log("socket", socket?.id);
|
||||
socket?.on("message", (message: SocketMessage) => {
|
||||
console.log("message", message);
|
||||
setMessages((state) => [...state, message]);
|
||||
// console.log("message", message);
|
||||
setMessages((state) => [...state, { ...message, me: message.from === socket?.id }]);
|
||||
});
|
||||
return () => {
|
||||
socket?.off("message");
|
||||
|
@ -110,88 +93,24 @@ export default function Home() {
|
|||
}, [socket]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
scrollToBottom(); // DOM更新完畢後,才執行scrollToBottom
|
||||
scrollToBottom(); // Execute after DOM render
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container pt="sm" size="md">
|
||||
<Card shadow="sm" padding="sm" radius="md" withBorder>
|
||||
<Group position="apart" mt="xs" mb="xs" className="flex flex-row flex-nowrap">
|
||||
<Group className="flex flex-row flex-nowrap">
|
||||
<Text size="xl" weight={500}>
|
||||
{name}
|
||||
</Text>
|
||||
<Popover width={170} position="bottom" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<ActionIcon variant="outline">
|
||||
<IconSettings size="1rem" />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Group className="justify-between">
|
||||
<Text size="sm">SocketID</Text>
|
||||
{socketId && (
|
||||
<CopyButton value={socketId} timeout={2000}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip
|
||||
label={
|
||||
copied ? `Copied: ${socketId}` : "Copy"
|
||||
}
|
||||
withArrow
|
||||
position="right"
|
||||
>
|
||||
<ActionIcon
|
||||
color={copied ? "teal" : "gray"}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? (
|
||||
<IconCheck size="1rem" />
|
||||
) : (
|
||||
<IconCopy size="1rem" />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
)}
|
||||
</Group>
|
||||
<Group className="justify-between">
|
||||
<Text size="sm">Actions</Text>
|
||||
<Group className="gap-1">
|
||||
<Tooltip label="Change name" withArrow position="right">
|
||||
<ActionIcon color="yellow" onClick={open}>
|
||||
<IconEdit size="1rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Disconnect" withArrow position="right">
|
||||
<ActionIcon color="red" onClick={disconnect}>
|
||||
<IconPlugOff size="1rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
<Group w={220} className="flex flex-row flex-nowrap">
|
||||
<Text w={20}>To:</Text>
|
||||
<Input
|
||||
w={170}
|
||||
placeholder="Your target Socket ID"
|
||||
value={targetSocketId}
|
||||
onChange={(e) => setTargetSocketId(e.target.value)}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<ChatroomTitle
|
||||
targetSocketId={targetSocketId}
|
||||
handleTargetSocketIdChange={(e) => setTargetSocketId(e.target.value)}
|
||||
/>
|
||||
<Divider />
|
||||
<ScrollArea h={"80vh"} offsetScrollbars viewportRef={chatViewportRef}>
|
||||
{messages.map((message, index) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
message.from === socketId
|
||||
message.me
|
||||
? classes.rightMessageField
|
||||
: classes.leftMessageField
|
||||
}
|
||||
|
@ -199,9 +118,7 @@ export default function Home() {
|
|||
>
|
||||
<Text
|
||||
className={
|
||||
message.from === socketId
|
||||
? classes.rightMessage
|
||||
: classes.leftMessage
|
||||
message.me ? classes.rightMessage : classes.leftMessage
|
||||
}
|
||||
>
|
||||
{message.message}
|
||||
|
@ -213,7 +130,7 @@ export default function Home() {
|
|||
<Divider />
|
||||
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
ref={messageInputRef}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
mt={10}
|
||||
|
@ -232,8 +149,6 @@ export default function Home() {
|
|||
/>
|
||||
</Card>
|
||||
</Container>
|
||||
|
||||
<NameModal opened={opened} onClose={close} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
"use client";
|
||||
import { Container, Title, createStyles, rem } from "@mantine/core";
|
||||
import { Container, Title, createStyles, rem, Text, Code, Mark } from "@mantine/core";
|
||||
import { Button } from "@mantine/core";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
|
@ -59,8 +59,23 @@ export default function Home() {
|
|||
>
|
||||
Tiny Socket.io demo
|
||||
</Title>
|
||||
<Title className={classes.subtitle}>Based on Next.js 13, Socket.io.</Title>
|
||||
|
||||
<Title className={classes.subtitle}>
|
||||
Based on <a href="https://nextjs.org/">Next.js13</a>,
|
||||
<a href="https://mantine.dev/">Mantine</a>,
|
||||
<a href="https://socket.io/">Socket.io</a>,
|
||||
<a href="https://zustand-demo.pmnd.rs/">Zustand</a>.
|
||||
</Title>
|
||||
<Text className={"opacity-75 max-w-full sm:max-w-[700px]"} mt={30}>
|
||||
This repo implements a simple chat app with Socket.io and Next.js 13.
|
||||
<br />
|
||||
You can use <Code>npm run dev</Code> to access
|
||||
<Mark> Next.js mock socket.io server </Mark>
|
||||
to test the app locally.
|
||||
<br />
|
||||
Or use <Code>npm run prod</Code> to access the
|
||||
<Mark> express server </Mark>
|
||||
in the backend folder to test like the production scenario.
|
||||
</Text>
|
||||
<Button
|
||||
mt={30}
|
||||
size="lg"
|
||||
|
|
|
@ -0,0 +1,160 @@
|
|||
"use client";
|
||||
import {
|
||||
Group,
|
||||
Text,
|
||||
Input,
|
||||
ActionIcon,
|
||||
Popover,
|
||||
CopyButton,
|
||||
Tooltip,
|
||||
Avatar,
|
||||
Indicator,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconPlug,
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconEdit,
|
||||
IconPlugOff,
|
||||
IconChevronDown,
|
||||
} from "@tabler/icons-react";
|
||||
import React, { ChangeEvent, FC, useEffect, useState } from "react";
|
||||
import NameModal from "./NameModal";
|
||||
import useBasicStore from "@/store/basic";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import useSocketStore from "@/store/socket";
|
||||
|
||||
type Props = {
|
||||
targetSocketId: string;
|
||||
handleTargetSocketIdChange: (e: ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
const ChatroomTitle: FC<Props> = ({ targetSocketId, handleTargetSocketIdChange }) => {
|
||||
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
|
||||
|
||||
useEffect(() => {
|
||||
setName(storeName);
|
||||
if (!storeName) modalOpen();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [storeName]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group position="apart" mt="xs" mb="xs" className="flex flex-row flex-nowrap">
|
||||
<Group className="flex flex-row flex-nowrap">
|
||||
<Indicator
|
||||
inline
|
||||
size={16}
|
||||
offset={5}
|
||||
position="bottom-end"
|
||||
color={socket?.connected ? "teal" : "red"}
|
||||
withBorder
|
||||
>
|
||||
<Avatar
|
||||
src={null}
|
||||
alt="User"
|
||||
color="blue"
|
||||
radius="xl"
|
||||
w="fit-content"
|
||||
>
|
||||
{name}
|
||||
</Avatar>
|
||||
</Indicator>
|
||||
|
||||
<Popover
|
||||
width="fit-content"
|
||||
position="bottom"
|
||||
withArrow
|
||||
shadow="md"
|
||||
opened={popoverOpened}
|
||||
onChange={setPopoverOpened}
|
||||
>
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={() => setPopoverOpened((open) => !open)}
|
||||
>
|
||||
<IconChevronDown size="1rem" />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Group position="apart">
|
||||
<Text size="sm">SocketID</Text>
|
||||
{socket?.id && (
|
||||
<CopyButton value={socket.id} timeout={2000}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip
|
||||
label={copied ? `Copied: ${socket.id}` : "Copy"}
|
||||
withArrow
|
||||
position="right"
|
||||
>
|
||||
<ActionIcon
|
||||
color={copied ? "teal" : "gray"}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? (
|
||||
<IconCheck size="1rem" />
|
||||
) : (
|
||||
<IconCopy size="1rem" />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
)}
|
||||
</Group>
|
||||
<Group position="apart">
|
||||
<Text size="sm">Actions</Text>
|
||||
<Group className="gap-1">
|
||||
<Tooltip label="Change name" withArrow position="right">
|
||||
<ActionIcon
|
||||
color="yellow"
|
||||
onClick={() => {
|
||||
modalOpen();
|
||||
setPopoverOpened(false);
|
||||
}}
|
||||
>
|
||||
<IconEdit size="1rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{
|
||||
// if socket is not connected, show connect button
|
||||
socket?.connected ? (
|
||||
<Tooltip label="Disconnect" withArrow position="right">
|
||||
<ActionIcon color="red" onClick={disconnect}>
|
||||
<IconPlugOff size="1rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label="Connect" withArrow position="right">
|
||||
<ActionIcon color="blue" onClick={connect}>
|
||||
<IconPlug size="1rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</Group>
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
<Group w={220} className="flex flex-row flex-nowrap">
|
||||
<Text w={20}>To:</Text>
|
||||
<Input
|
||||
w={170}
|
||||
placeholder="Your target Socket ID"
|
||||
value={targetSocketId}
|
||||
onChange={handleTargetSocketIdChange}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
<NameModal opened={modalOpened} onClose={modalClose} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatroomTitle;
|
|
@ -1 +1 @@
|
|||
export const SOCKET_URL = process.env.NEXT_PUBLIC_SOCKET_URL || "";
|
||||
export const SOCKET_URL = process.env.NEXT_PUBLIC_SOCKET_URL;
|
||||
|
|
|
@ -16,3 +16,13 @@ export type SocketMessage = {
|
|||
message: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 {
|
||||
me: boolean;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue