refactor: optimize and refactor codebase

- Extracte chatroom titile as a component
- Update introduction in entry page
- add me attribute avioding in SocketMessage
main
Justin Xiao 2023-07-17 22:20:49 +08:00
parent fc718a7fad
commit 01a69e2b6b
5 changed files with 218 additions and 118 deletions

View File

@ -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} />
</>
);
}

View File

@ -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"

View File

@ -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;

View File

@ -1 +1 @@
export const SOCKET_URL = process.env.NEXT_PUBLIC_SOCKET_URL || "";
export const SOCKET_URL = process.env.NEXT_PUBLIC_SOCKET_URL;

View File

@ -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;
}