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";
|
"use client";
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
createStyles,
|
createStyles,
|
||||||
Card,
|
Card,
|
||||||
Container,
|
Container,
|
||||||
Group,
|
|
||||||
Text,
|
Text,
|
||||||
Divider,
|
Divider,
|
||||||
Input,
|
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
TextInput,
|
TextInput,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Popover,
|
|
||||||
CopyButton,
|
|
||||||
Tooltip,
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import { IconArrowRight } from "@tabler/icons-react";
|
||||||
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 useSocketStore from "@/store/socket";
|
||||||
import { useEffect, useRef, useState, useLayoutEffect } from "react";
|
import { useEffect, useRef, useState, useLayoutEffect } from "react";
|
||||||
import useBasicStore from "@/store/basic";
|
import { MessageWithMe, SocketMessage } from "@/types/next";
|
||||||
import { SocketMessage } from "@/types/next";
|
import { toast } from "react-hot-toast";
|
||||||
|
import ChatroomTitle from "@/components/ChatRoomTitle";
|
||||||
|
|
||||||
const useStyles = createStyles((theme) => ({
|
const useStyles = createStyles((theme) => ({
|
||||||
rightMessageField: {
|
rightMessageField: {
|
||||||
|
@ -64,45 +50,42 @@ const useStyles = createStyles((theme) => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const storeName = useBasicStore((state) => state.name);
|
const { socket, connect, emit } = useSocketStore((state) => state); // deconstructing socket and its method from socket store
|
||||||
const { socket, emit, disconnect } = useSocketStore((state) => state);
|
|
||||||
|
|
||||||
const chatViewportRef = useRef<HTMLDivElement>(null);
|
const chatViewportRef = useRef<HTMLDivElement>(null); // binding chat viewport ref
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const messageInputRef = useRef<HTMLInputElement>(null); // binding message input ref
|
||||||
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>(""); // target socket id input value
|
||||||
const [targetSocketId, setTargetSocketId] = useState<string>("");
|
const [message, setMessage] = useState(""); // message input value
|
||||||
const [message, setMessage] = useState("");
|
const [messages, setMessages] = useState<MessageWithMe[]>([]); // show messages on ScrollArea
|
||||||
const [messages, setMessages] = useState<SocketMessage[]>([]);
|
|
||||||
|
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
|
||||||
const { classes } = useStyles();
|
const { classes } = useStyles();
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
console.log("scrollToBottom", chatViewportRef.current?.scrollHeight);
|
|
||||||
chatViewportRef?.current?.scrollTo({
|
chatViewportRef?.current?.scrollTo({
|
||||||
top: chatViewportRef.current.scrollHeight,
|
top: chatViewportRef.current.scrollHeight,
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const sendMessage = () => {
|
const sendMessage = () => {
|
||||||
if (!socketId || !message) return;
|
if (!message) return toast.error("Please enter a message");
|
||||||
emit("message", { from: socketId, to: targetSocketId, timestamp: Date.now(), 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("");
|
setMessage("");
|
||||||
inputRef.current?.focus();
|
messageInputRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setName(storeName);
|
connect();
|
||||||
if (!storeName) open();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [storeName]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSocketId(socket?.id);
|
console.log("socket", socket?.id);
|
||||||
socket?.on("message", (message: SocketMessage) => {
|
socket?.on("message", (message: SocketMessage) => {
|
||||||
console.log("message", message);
|
// console.log("message", message);
|
||||||
setMessages((state) => [...state, message]);
|
setMessages((state) => [...state, { ...message, me: message.from === socket?.id }]);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
socket?.off("message");
|
socket?.off("message");
|
||||||
|
@ -110,88 +93,24 @@ export default function Home() {
|
||||||
}, [socket]);
|
}, [socket]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
scrollToBottom(); // DOM更新完畢後,才執行scrollToBottom
|
scrollToBottom(); // Execute after DOM render
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Container pt="sm" size="md">
|
<Container pt="sm" size="md">
|
||||||
<Card shadow="sm" padding="sm" radius="md" withBorder>
|
<Card shadow="sm" padding="sm" radius="md" withBorder>
|
||||||
<Group position="apart" mt="xs" mb="xs" className="flex flex-row flex-nowrap">
|
<ChatroomTitle
|
||||||
<Group className="flex flex-row flex-nowrap">
|
targetSocketId={targetSocketId}
|
||||||
<Text size="xl" weight={500}>
|
handleTargetSocketIdChange={(e) => setTargetSocketId(e.target.value)}
|
||||||
{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>
|
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
<ScrollArea h={"80vh"} offsetScrollbars viewportRef={chatViewportRef}>
|
<ScrollArea h={"80vh"} offsetScrollbars viewportRef={chatViewportRef}>
|
||||||
{messages.map((message, index) => {
|
{messages.map((message, index) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
message.from === socketId
|
message.me
|
||||||
? classes.rightMessageField
|
? classes.rightMessageField
|
||||||
: classes.leftMessageField
|
: classes.leftMessageField
|
||||||
}
|
}
|
||||||
|
@ -199,9 +118,7 @@ export default function Home() {
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
className={
|
className={
|
||||||
message.from === socketId
|
message.me ? classes.rightMessage : classes.leftMessage
|
||||||
? classes.rightMessage
|
|
||||||
: classes.leftMessage
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{message.message}
|
{message.message}
|
||||||
|
@ -213,7 +130,7 @@ export default function Home() {
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={messageInputRef}
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
mt={10}
|
mt={10}
|
||||||
|
@ -232,8 +149,6 @@ export default function Home() {
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
<NameModal opened={opened} onClose={close} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
"use client";
|
"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 { Button } from "@mantine/core";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
@ -59,8 +59,23 @@ export default function Home() {
|
||||||
>
|
>
|
||||||
Tiny Socket.io demo
|
Tiny Socket.io demo
|
||||||
</Title>
|
</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
|
<Button
|
||||||
mt={30}
|
mt={30}
|
||||||
size="lg"
|
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;
|
message: string;
|
||||||
timestamp: number;
|
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