diff --git a/src/app/api/messages/chat/route.ts b/src/app/api/messages/chat/route.ts index 9a720015..751f92c3 100644 --- a/src/app/api/messages/chat/route.ts +++ b/src/app/api/messages/chat/route.ts @@ -6,6 +6,11 @@ import { prisma } from "@/lib/prisma"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const conversation_id = searchParams.get("conversation_id") as string; + const cursorQuery = searchParams.get("cursor") || undefined; + const take = Number(searchParams.get("limit")) || 20; + + const skip = cursorQuery ? 1 : 0; + const cursor = cursorQuery ? { id: cursorQuery } : undefined; const messageSchema = z.string(); const zod = messageSchema.safeParse(conversation_id); @@ -16,12 +21,24 @@ export async function GET(request: Request) { try { const chat = await prisma.message.findMany({ + skip, + take, + cursor, where: { conversation_id: conversation_id, }, + + orderBy: { + created_at: "desc", + }, }); - return NextResponse.json(chat, { status: 200 }); + const nextId = chat.length < take ? undefined : chat[chat.length - 1].id; + + return NextResponse.json({ + chat, + nextId, + }); } catch (error: any) { return NextResponse.json({ error: error.message }, { status: 500 }); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a2a6d020..27e78233 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -9,8 +9,7 @@ import { AuthFlow } from "@/features/auth"; import { MobileTweetButton } from "@/features/create-tweet"; import { MobileNavbar } from "@/features/navbar"; import { Sidebar } from "@/features/sidebar"; -import { NextAuthProvider } from "@/utils/next-auth-provider"; -import { ReactQueryProvider } from "@/utils/react-query-provider"; +import { AppProviders } from "@/providers"; import { Hamburger } from "./hamburger"; import { JoinTwitter } from "./join-twitter"; @@ -35,40 +34,38 @@ export default async function RootLayout({ lang="en" > - - -
- -
- -
- - + +
+ +
+ +
-
- {children} -
+ -
- - + + + + + +
+ ); diff --git a/src/features/messages/api/get-chat.ts b/src/features/messages/api/get-chat.ts index 15ead9fd..9916d25a 100644 --- a/src/features/messages/api/get-chat.ts +++ b/src/features/messages/api/get-chat.ts @@ -1,9 +1,17 @@ import axios from "axios"; -export const getChat = async (conversation_id: string | undefined) => { +export const getChat = async ({ + conversation_id, + pageParam, + limit, +}: { + conversation_id: string | undefined; + pageParam: string | unknown; + limit: number; +}) => { try { const { data } = await axios.get( - `/api/messages/chat?conversation_id=${conversation_id}`, + `/api/messages/chat?conversation_id=${conversation_id}&cursor=${pageParam}&limit=${limit}`, ); return data; } catch (error: any) { diff --git a/src/features/messages/components/chat.tsx b/src/features/messages/components/chat.tsx index 6e3a59ce..e3054d6c 100644 --- a/src/features/messages/components/chat.tsx +++ b/src/features/messages/components/chat.tsx @@ -1,5 +1,6 @@ +import { usePathname } from "next/navigation"; import { useSession } from "next-auth/react"; -import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useInView } from "react-intersection-observer"; import { Button } from "@/components/elements/button"; @@ -15,47 +16,69 @@ import { Message } from "./message"; export type status = "sending" | "sent" | "seen" | "failed"; -export const Chat = ({ - conversation_id, -}: { - conversation_id: string | undefined; -}) => { - const { ref, inView } = useInView({ - threshold: 0, - }); +export const Chat = memo(() => { + const pathname = usePathname(); + const conversation_id = pathname?.split("/")[2]; + + const { data: session } = useSession(); + const { + data, + isLoading, + isError, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useChat(conversation_id); + + const isLastMessageSender = useMemo( + () => data?.pages.at(-1)?.chat?.at(-1)?.sender_id === session?.user?.id, + [data, session], + ); + const anchorRef = useRef(null); + const [toast, setToast] = useState< + "new message" | "scroll to bottom" | "none" + >("none"); - const [scrolledToBottom, setScrolledToBottom] = useState(false); - const [displayNewMessageToast, setDisplayNewMessageToast] = useState(false); + const { ref: firstMessageRef } = useInView({ + initialInView: false, + onChange(inView) { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + setToast("scroll to bottom"); + } + }, + }); - const { data: session } = useSession(); - const { data: chat, isLoading, isError } = useChat(conversation_id); + const { ref: lastMessageRef, inView: lastMessageInView } = useInView({ + initialInView: true, + }); + + const handleToastClick = useCallback(() => { + scrollIntoView({ + element: anchorRef.current, + }); + setToast("none"); + }, []); useSocketEvents(conversation_id); useEffect(() => { - if (inView) { - setScrolledToBottom(true); - } else { - setScrolledToBottom(false); - } - }, [inView]); + if (lastMessageInView) { + setToast("none"); + } else setToast("scroll to bottom"); + }, [lastMessageInView]); - useLayoutEffect(() => { - if (!scrolledToBottom) { + useEffect(() => { + if (lastMessageInView) { scrollIntoView({ element: anchorRef.current, - behavior: "instant", }); - } else { - if ( - chat && - chat?.length > 0 && - chat[chat.length - 1]?.sender_id !== session?.user?.id - ) - setDisplayNewMessageToast(true); + } else if (!isLastMessageSender) { + setToast("new message"); } - }, [chat]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); if (isLoading) return ; @@ -63,47 +86,60 @@ export const Chat = ({ return (
- {chat?.map((message, index) => { - return ( -
- } + + {data?.pages?.map((page, pageIndex) => { + return page?.chat?.map((message, messageIndex) => { + return ( +
-
- ); + key={message.id} + > + +
+ ); + }); })}
- {!scrolledToBottom && !displayNewMessageToast && ( + + {toast === "new message" && ( )} - {displayNewMessageToast && ( + {toast === "scroll to bottom" && ( )}
); -}; +}); + +Chat.displayName = "Chat"; diff --git a/src/features/messages/components/conversation.tsx b/src/features/messages/components/conversation.tsx index 85357c61..f3443472 100644 --- a/src/features/messages/components/conversation.tsx +++ b/src/features/messages/components/conversation.tsx @@ -29,10 +29,10 @@ export const Conversation = () => { useEffect(() => { socket.auth = { conversation_id: id }; - socket.connect(); + socket?.connect(); return () => { - socket.disconnect(); + socket?.disconnect(); }; }, [id]); @@ -65,7 +65,7 @@ export const Conversation = () => {
- +
{ - console.log(); setChosenImage({ url: reader.result, id: Math.random(), @@ -59,13 +58,12 @@ export const MessageInput = ({ }; const onFocus = () => { - const chat = queryClient.getQueryData([ + const chat = queryClient.getQueryData>([ "chat", conversation_id, ]); - if (!chat) return; - const lastMessage = chat.at(-1); + const lastMessage = chat?.pages?.at(0)?.chat?.at(0); if (lastMessage?.sender_id === sender_id) return; socket.emit("status", { status: "seen", message_id: lastMessage?.id }); @@ -125,9 +123,35 @@ export const MessageInput = ({ status: "sending", }); - queryClient.setQueryData(["chat", conversation_id], (oldData: any) => { - return [...oldData, message]; - }); + queryClient.setQueryData( + ["chat", conversation_id], + (oldData: InfiniteData) => { + const lastPage = oldData.pages?.at(0); + if (lastPage?.chat && lastPage.chat.length >= 20) { + return { + ...oldData, + pages: [ + { + chat: [message], + nextId: message.id, + }, + ...oldData.pages, + ], + }; + } else { + return { + ...oldData, + pages: [ + { + chat: [message, ...(lastPage?.chat ?? [])], + nextId: lastPage?.nextId, + }, + ...oldData.pages.slice(1), + ], + }; + } + }, + ); socket.emit("message", { id, diff --git a/src/features/messages/components/message.tsx b/src/features/messages/components/message.tsx index e62153db..2765e18f 100644 --- a/src/features/messages/components/message.tsx +++ b/src/features/messages/components/message.tsx @@ -11,10 +11,10 @@ import styles from "./styles/message.module.scss"; export const Message = ({ message, - show_status, + show_status = false, }: { message: IMessage; - show_status: boolean; + show_status?: boolean; }) => { const { data: session } = useSession(); const isSender = session?.user?.id === message.sender_id; diff --git a/src/features/messages/hooks/use-get-chat.ts b/src/features/messages/hooks/use-get-chat.ts index d8d1261c..0c9f2155 100644 --- a/src/features/messages/hooks/use-get-chat.ts +++ b/src/features/messages/hooks/use-get-chat.ts @@ -1,13 +1,49 @@ -import { useQuery } from "@tanstack/react-query"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { getChat } from "../api/get-chat"; import { IMessage } from "../types"; +export interface IInfiniteChat { + nextId: string; + chat: IMessage[]; +} + export const useChat = (conversation_id: string | undefined) => { - return useQuery({ + const response = useInfiniteQuery({ queryKey: ["chat", conversation_id], - queryFn: async () => { - return getChat(conversation_id); + queryFn: ({ pageParam }) => { + return getChat({ conversation_id, pageParam, limit: 20 }); + }, + initialPageParam: "", + getNextPageParam: (lastPage) => { + return lastPage?.nextId; + }, + + select: (data) => { + const pages = data?.pages?.map((page) => { + if (page && page.chat) { + return { + ...page, + chat: page.chat.concat().reverse(), + }; + } + + return page; + }); + + return { + pages: pages.concat().reverse(), + pageParams: data.pageParams.concat().reverse(), + }; }, }); + + return { + data: response.data, + isLoading: response.isLoading, + isError: response.isError, + isFetchingNextPage: response.isFetchingNextPage, + fetchNextPage: response.fetchNextPage, + hasNextPage: response.hasNextPage, + }; }; diff --git a/src/features/messages/hooks/use-socket-events.ts b/src/features/messages/hooks/use-socket-events.ts index 486f164b..5af440a7 100644 --- a/src/features/messages/hooks/use-socket-events.ts +++ b/src/features/messages/hooks/use-socket-events.ts @@ -1,13 +1,11 @@ -import { useQueryClient } from "@tanstack/react-query"; +import { InfiniteData, useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; import { socket } from "@/lib/socket-io"; -import { - IMessage, - SocketEmitMessagePayload, - SocketEmitStatusPayload, -} from "../types"; +import { SocketEmitMessagePayload, SocketEmitStatusPayload } from "../types"; + +import { IInfiniteChat } from "./use-get-chat"; export const useSocketEvents = (conversation_id: string | undefined) => { const queryClient = useQueryClient(); @@ -17,17 +15,22 @@ export const useSocketEvents = (conversation_id: string | undefined) => { queryClient.setQueryData( ["chat", conversation_id], - (oldData: IMessage[]) => { - const newData = oldData.map((message: IMessage) => { - if (message.id === data.message_id) { - return { - ...message, - status: data.status, - }; + (oldData: InfiniteData) => { + const newData = oldData?.pages?.map((page) => { + if (page) { + const newChat = page.chat.map((message) => { + if (message.id === data.message_id) { + return { ...message, status: data.status }; + } + + return message; + }); + + return { ...page, chat: newChat }; } - return message; }); - return newData; + + return { ...oldData, pages: newData }; }, ); }); @@ -35,9 +38,32 @@ export const useSocketEvents = (conversation_id: string | undefined) => { socket.on("message", (data: SocketEmitMessagePayload) => { queryClient.setQueryData( ["chat", conversation_id], - (oldData: IMessage[]) => { - const newData = [...oldData, data.message]; - return newData; + (oldData: InfiniteData) => { + const lastPage = oldData.pages?.at(0); + + if (lastPage?.chat && lastPage.chat.length >= 20) { + return { + ...oldData, + pages: [ + { + chat: [data.message], + nextId: data.message.id, + }, + ...oldData.pages, + ], + }; + } else { + return { + ...oldData, + pages: [ + { + chat: [data.message, ...(lastPage?.chat ?? [])], + nextId: lastPage?.nextId, + }, + ...oldData.pages.slice(1), + ], + }; + } }, ); }); diff --git a/src/lib/socket-io.ts b/src/lib/socket-io.ts index 50321a96..b1c90972 100644 --- a/src/lib/socket-io.ts +++ b/src/lib/socket-io.ts @@ -6,8 +6,10 @@ const URL = SOCKET_URL; export const socket = io(URL, { autoConnect: false, - reconnectionAttempts: 5, - reconnectionDelay: 5000, + transports: ["websocket"], + reconnection: true, + reconnectionAttempts: 10, + reconnectionDelay: 1000, }); socket.on("connect_error", (error) => { diff --git a/src/providers/index.tsx b/src/providers/index.tsx new file mode 100644 index 00000000..dd2271c5 --- /dev/null +++ b/src/providers/index.tsx @@ -0,0 +1,16 @@ +import { FC } from "react"; + +import { NextAuthProvider } from "./next-auth-provider"; +import { ReactQueryProvider } from "./react-query-provider"; + +type AppProvidersProps = { + children: React.ReactNode; +}; + +export const AppProviders: FC = ({ children }) => { + return ( + + {children} + + ); +}; diff --git a/src/utils/next-auth-provider.tsx b/src/providers/next-auth-provider.tsx similarity index 100% rename from src/utils/next-auth-provider.tsx rename to src/providers/next-auth-provider.tsx diff --git a/src/utils/react-query-provider.tsx b/src/providers/react-query-provider.tsx similarity index 100% rename from src/utils/react-query-provider.tsx rename to src/providers/react-query-provider.tsx