From b965b4b2b8c38ece23b9b9c5219ef1828b6f8d80 Mon Sep 17 00:00:00 2001 From: Shamal Chathuranga Wijesuriya Date: Sun, 19 May 2024 16:44:00 +0530 Subject: [PATCH 1/2] loading state added --- package-lock.json | 27 +++- package.json | 1 + src/app/(Main)/conversations/loading.tsx | 9 ++ .../conversations/[conversationId]/route.ts | 57 +++++++++ .../[conversationId]/seen/route.ts | 12 ++ src/app/api/conversations/route.ts | 7 ++ src/components/conversations/chat-body.tsx | 11 ++ .../conversations/conversation-box.tsx | 2 +- .../conversations/conversation-list.tsx | 61 ++++++++- .../conversations/profile-drawer.tsx | 117 +++++++++++------- src/components/modal.tsx | 57 +++++++++ .../conversation-delete-confirm-modal.tsx | 68 ++++++++++ src/components/modals/loading-modal.tsx | 35 ++++++ src/components/users/user-box.tsx | 7 +- 14 files changed, 420 insertions(+), 51 deletions(-) create mode 100644 src/app/(Main)/conversations/loading.tsx create mode 100644 src/app/api/conversations/[conversationId]/route.ts create mode 100644 src/components/modal.tsx create mode 100644 src/components/modals/conversation-delete-confirm-modal.tsx create mode 100644 src/components/modals/loading-modal.tsx diff --git a/package-lock.json b/package-lock.json index b224946..421ec8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "react": "^18", "react-dom": "^18", "react-hook-form": "^7.51.2", + "react-hot-toast": "^2.4.1", "react-spinners": "^0.13.8", "resend": "^3.2.0", "zod": "^3.22.4" @@ -4341,8 +4342,7 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -5769,6 +5769,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.14.tgz", + "integrity": "sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -7690,6 +7698,21 @@ "react": "^16.8.0 || ^17 || ^18" } }, + "node_modules/react-hot-toast": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz", + "integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==", + "dependencies": { + "goober": "^2.1.10" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index 8e29b66..951182d 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "react": "^18", "react-dom": "^18", "react-hook-form": "^7.51.2", + "react-hot-toast": "^2.4.1", "react-spinners": "^0.13.8", "resend": "^3.2.0", "zod": "^3.22.4" diff --git a/src/app/(Main)/conversations/loading.tsx b/src/app/(Main)/conversations/loading.tsx new file mode 100644 index 0000000..52e57c4 --- /dev/null +++ b/src/app/(Main)/conversations/loading.tsx @@ -0,0 +1,9 @@ +import LoadingModal from "@/components/modals/loading-modal" + +const Loading = () => { + return ( + + ) +} + +export default Loading \ No newline at end of file diff --git a/src/app/api/conversations/[conversationId]/route.ts b/src/app/api/conversations/[conversationId]/route.ts new file mode 100644 index 0000000..9273d32 --- /dev/null +++ b/src/app/api/conversations/[conversationId]/route.ts @@ -0,0 +1,57 @@ +import { currentUser } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { pusherServer } from "@/lib/pusher"; +import { NextResponse } from "next/server"; + +interface IParams { + conversationId?: string; +} + +export async function DELETE( + request: Request, + { params }: { params: IParams } +) { + const user = await currentUser(); + + if (!user) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + try { + const { conversationId } = params; + + const conversation = await db.conversation.findUnique({ + where: { + id: conversationId + }, + include: { + users: true + } + }); + + if(!conversation) { + return new NextResponse("Invalid ID", { status: 400 }); + } + + await db.conversation.deleteMany({ + where: { + id: conversationId, + userIds: { + hasSome: [user?.id!] + } + } + }); + + conversation.users.forEach((user) => { + if (user.email) { + pusherServer.trigger(user.email, 'conversation:remove', conversation) + } + }) + + return new NextResponse("Conversation deleted", { status: 200 }); + + } catch (error) { + console.log(error, "ERROR_CONVERSATION_DELETE"); + return new NextResponse("Internal server error", { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/conversations/[conversationId]/seen/route.ts b/src/app/api/conversations/[conversationId]/seen/route.ts index 80ee963..f9396ad 100644 --- a/src/app/api/conversations/[conversationId]/seen/route.ts +++ b/src/app/api/conversations/[conversationId]/seen/route.ts @@ -1,5 +1,6 @@ import { currentUser } from "@/lib/auth"; import { db } from "@/lib/db"; +import { pusherServer } from "@/lib/pusher"; import { NextResponse } from "next/server"; interface IParams { @@ -61,6 +62,17 @@ export async function POST( } }); + await pusherServer.trigger(user?.email as string, 'conversation:update' , { + id: conversationId, + message: [updatedMessage] + }) + + if(lastMessage.seenIds.indexOf(user?.id as string) !== -1) { + return NextResponse.json(conversation); + } + + await pusherServer.trigger(conversationId!, 'message:update', updatedMessage); + return NextResponse.json(updatedMessage); } catch (error) { diff --git a/src/app/api/conversations/route.ts b/src/app/api/conversations/route.ts index 388dc26..23c8f88 100644 --- a/src/app/api/conversations/route.ts +++ b/src/app/api/conversations/route.ts @@ -1,6 +1,7 @@ import { currentUser } from "@/lib/auth"; import { NextResponse } from "next/server"; import { db } from "@/lib/db"; +import { pusherServer } from "@/lib/pusher"; export async function POST( request: Request @@ -88,6 +89,12 @@ export async function POST( } }) + newConversation.users.map((user) => { + if(user.email) { + pusherServer.trigger(user.email, 'conversation:new', newConversation) + } + }) + return NextResponse.json(newConversation) } catch (error) { diff --git a/src/components/conversations/chat-body.tsx b/src/components/conversations/chat-body.tsx index afccf07..51f9c05 100644 --- a/src/components/conversations/chat-body.tsx +++ b/src/components/conversations/chat-body.tsx @@ -38,14 +38,25 @@ const ChatBody = ({ initialMessages }: ChatBodyProps) => { }) bottomRef?.current?.scrollIntoView(); + } + + const updateMessageHandler = (newMessage: FullMessageType) => { + setMessages((current) => current!.map((currentMessage) => { + if(currentMessage.id === newMessage.id) { + return newMessage; + } + return currentMessage; + })); } pusherClient.bind('messages:new', messageHandler); + pusherClient.bind('message:update', updateMessageHandler) return () => { pusherClient.unsubscribe(conversationId as string); pusherClient.unbind('messages:new', messageHandler); + pusherClient.unbind('message:update', updateMessageHandler) } }, [conversationId]); diff --git a/src/components/conversations/conversation-box.tsx b/src/components/conversations/conversation-box.tsx index dfde392..38526fa 100644 --- a/src/components/conversations/conversation-box.tsx +++ b/src/components/conversations/conversation-box.tsx @@ -66,7 +66,7 @@ const ConversationBox = ({
-
+

{data.name || otherUser.name}

{lastMessage?.createdAt && (

diff --git a/src/components/conversations/conversation-list.tsx b/src/components/conversations/conversation-list.tsx index 7515683..a842a96 100644 --- a/src/components/conversations/conversation-list.tsx +++ b/src/components/conversations/conversation-list.tsx @@ -5,8 +5,11 @@ import { cn } from "@/lib/utils"; import { FullConversationType } from "@/types"; import { Users } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import ConversationBox from "./conversation-box"; +import { useCurrentUser } from "@/hooks/use-current-user"; +import { pusherClient } from "@/lib/pusher"; +import { find } from "lodash"; interface ConversationListProps { initialItems: FullConversationType[] @@ -16,12 +19,66 @@ interface ConversationListProps { const ConversationList = ({ initialItems }: ConversationListProps) => { + const session = useCurrentUser(); const [items, setItems] = useState(initialItems) const router = useRouter(); const { conversationId, isOpen } = useConversation(); + const pusherKey = useMemo(() => { + return session?.email + }, [session?.email]) + + useEffect(() => { + if (!pusherKey) return; + + pusherClient.subscribe(pusherKey); + + const newHandler = (conversation: FullConversationType) => { + setItems((current) => { + if (find(current, { id: conversation.id })) return current; + + return [conversation, ...current]; + }) + }; + + const updateHandler = (conversation: FullConversationType) => { + setItems((current) => current.map((currentConversation) => { + if (currentConversation.id === conversation.id) { + return { + ...currentConversation, + message: conversation.messages + } + } + + return currentConversation; + })) + } + + const removeHandler = (conversation: FullConversationType) => { + setItems((current) => { + return [...current.filter((convo) => convo.id !== conversation.id)] + }) + + if (conversationId === conversation.id) { + router.push("/conversations"); + } + }; + + pusherClient.bind('conversation:new', newHandler); + pusherClient.bind('conversation:update', updateHandler); + pusherClient.bind('conversation:remove', removeHandler) + + return () => { + pusherClient.unsubscribe(pusherKey); + pusherClient.unbind('conversation:new', newHandler); + pusherClient.unbind('conversation:update', updateHandler); + pusherClient.unbind('conversation:remove', removeHandler); + } + + }, [pusherKey, conversationId, router]) + return (

{items.map((item) => ( - { const otherUser = useOtherUser(data); + const [confirmOpen, setConfirmOpen] = useState(false); const joinedDate = useMemo(() => { return format(new Date(otherUser.createdAt), 'PP') @@ -41,58 +44,82 @@ const ProfileDrawer = ({ }, [data]) return ( - - - -
- -
-
-
- - -
-
-
-
- -
-
-
-
-
-
-
- - + <> + setConfirmOpen(false)} + /> + + + +
+ +
+
+
+ + +
+
+
+
+
-
- {title} -
-
- {statusText} -
-
- +
+
+ {title} +
+
+ {statusText} +
+
+ +
+
+
+ {!data.isGroup && ( +
+
Email
+
{otherUser.email}
+
+ + )} + {!data.isGroup && ( +
+
Joined
+
+
+ )} +
+
-
- - + + +
-
-
-
+ + + ) } diff --git a/src/components/modal.tsx b/src/components/modal.tsx new file mode 100644 index 0000000..77ced14 --- /dev/null +++ b/src/components/modal.tsx @@ -0,0 +1,57 @@ +"use client" + +import { Dialog, DialogPanel, Transition, TransitionChild } from "@headlessui/react"; +import { X } from "lucide-react"; +import React, { Fragment } from "react"; + +interface ModalProps { + isOpen?: boolean; + onClose: () => void; + children: React.ReactNode; +} + +const Modal = ({ isOpen, onClose, children }: ModalProps) => { + return ( + + + +
+ + +
+
+ + +
+ +
+ {children} +
+
+
+
+
+
+ ) +} + +export default Modal \ No newline at end of file diff --git a/src/components/modals/conversation-delete-confirm-modal.tsx b/src/components/modals/conversation-delete-confirm-modal.tsx new file mode 100644 index 0000000..abbda0e --- /dev/null +++ b/src/components/modals/conversation-delete-confirm-modal.tsx @@ -0,0 +1,68 @@ +"use client" + +import useConversation from "@/hooks/use-conversation"; +import axios from "axios"; +import { useRouter } from "next/navigation"; +import { useCallback, useState } from "react"; +import Modal from "../modal"; +import { AlertTriangle, Loader2 } from "lucide-react"; +import { DialogTitle } from "@headlessui/react"; +import { Button } from "@nextui-org/react"; +import toast from "react-hot-toast"; + +interface DeleteConfirmModalProps { + isOpen?: boolean; + onClose: () => void; +} + +const DeleteConfirmModal = ({ onClose, isOpen }: DeleteConfirmModalProps) => { + const router = useRouter(); + const { conversationId } = useConversation(); + const [isLoading, setIsLoading] = useState(false); + + const onDelete = useCallback(() => { + setIsLoading(true); + + axios.delete(`/api/conversations/${conversationId}`) + .then(() => { + onClose(); + router.push('/conversations'); + router.refresh(); + }) + .catch(() => { + toast.error('Failed to delete conversation'); + }) + .finally(() => { + setIsLoading(false); + }) + }, [conversationId, onClose, router]) + + return ( + +
+
+ +
+
+ + Delete conversation + +
+

+ Are you sure you want to delete this conversation? This action cannot be undone. +

+
+
+
+
+ + +
+
+ ) +} + +export default DeleteConfirmModal \ No newline at end of file diff --git a/src/components/modals/loading-modal.tsx b/src/components/modals/loading-modal.tsx new file mode 100644 index 0000000..c40b52f --- /dev/null +++ b/src/components/modals/loading-modal.tsx @@ -0,0 +1,35 @@ +"use client" + +import React, { Fragment } from "react"; +import { Dialog, DialogPanel, Transition, TransitionChild } from "@headlessui/react"; +import { BeatLoader, MoonLoader } from "react-spinners" + +const LoadingModal = () => { + return ( + + { }}> + +
+ + +
+
+ + + +
+
+
+
+ ) +} + +export default LoadingModal \ No newline at end of file diff --git a/src/components/users/user-box.tsx b/src/components/users/user-box.tsx index ae9b03f..9ca8926 100644 --- a/src/components/users/user-box.tsx +++ b/src/components/users/user-box.tsx @@ -5,6 +5,7 @@ import axios from "axios" import { useRouter } from "next/navigation" import { useCallback, useState } from "react" import { Avatar } from "@nextui-org/avatar"; +import LoadingModal from "../modals/loading-modal" interface UserBoxProps { data: User @@ -28,6 +29,10 @@ const UserBox = ({ data }: UserBoxProps) => { }, [data, router]) return ( + <> + {isLoading && ( + + )}
- + ) } From a823032ae57997b77cbd70b70ef25ab167c97eb5 Mon Sep 17 00:00:00 2001 From: Shamal Chathuranga Wijesuriya Date: Sun, 19 May 2024 18:34:29 +0530 Subject: [PATCH 2/2] updated --- package-lock.json | 38 ++++++++++++++++++- package.json | 3 +- src/app/layout.tsx | 2 +- src/components/avatar.tsx | 9 ++++- src/components/conversations/chat-header.tsx | 7 +--- .../conversations/conversation-box.tsx | 6 +-- src/components/conversations/message-box.tsx | 6 +-- .../conversations/profile-drawer.tsx | 6 +-- 8 files changed, 55 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 421ec8b..f326c4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,8 @@ "react-hot-toast": "^2.4.1", "react-spinners": "^0.13.8", "resend": "^3.2.0", - "zod": "^3.22.4" + "zod": "^3.22.4", + "zustand": "^4.5.2" }, "devDependencies": { "@types/bcrypt": "^5.0.2", @@ -8908,6 +8909,14 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -9173,6 +9182,33 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zustand": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.2.tgz", + "integrity": "sha512-2cN1tPkDVkwCy5ickKrI7vijSjPksFRfqS6237NzT0vqSsztTNnQdHw9mmN7uBdk3gceVXU0a+21jFzFzAc9+g==", + "dependencies": { + "use-sync-external-store": "1.2.0" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 951182d..7c2d173 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "react-hot-toast": "^2.4.1", "react-spinners": "^0.13.8", "resend": "^3.2.0", - "zod": "^3.22.4" + "zod": "^3.22.4", + "zustand": "^4.5.2" }, "devDependencies": { "@types/bcrypt": "^5.0.2", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 44b78f1..bd4def4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -19,7 +19,7 @@ export default function RootLayout({ return ( - + {children} diff --git a/src/components/avatar.tsx b/src/components/avatar.tsx index d4dc455..5215d42 100644 --- a/src/components/avatar.tsx +++ b/src/components/avatar.tsx @@ -1,9 +1,14 @@ import { Avatar } from "@nextui-org/react" +import { User } from "@prisma/client" -export const UserAvatar = (image: string) => { +interface UserAvatarProps { + user?: User +} + +export const UserAvatar = ({ user }: UserAvatarProps) => { return (
- +
) diff --git a/src/components/conversations/chat-header.tsx b/src/components/conversations/chat-header.tsx index 34944ab..bb2770c 100644 --- a/src/components/conversations/chat-header.tsx +++ b/src/components/conversations/chat-header.tsx @@ -1,12 +1,12 @@ "use client" import useOtherUser from "@/hooks/use-other-user"; -import { Avatar } from "@nextui-org/react"; import { Conversation, User } from "@prisma/client"; import { ArrowLeft, Ellipsis } from "lucide-react"; import Link from "next/link"; import { useMemo, useState } from "react"; import ProfileDrawer from "./profile-drawer"; +import { UserAvatar } from "../avatar"; interface ChatHeaderProps { conversation: Conversation & { @@ -38,10 +38,7 @@ const ChatHeader = ({ conversation }: ChatHeaderProps) => { -
- - -
+
{conversation.name || otherUser.name} diff --git a/src/components/conversations/conversation-box.tsx b/src/components/conversations/conversation-box.tsx index 38526fa..a8f9395 100644 --- a/src/components/conversations/conversation-box.tsx +++ b/src/components/conversations/conversation-box.tsx @@ -9,6 +9,7 @@ import useOtherUser from "@/hooks/use-other-user" import { useCurrentUser } from "@/hooks/use-current-user" import { cn } from "@/lib/utils" import { Avatar } from "@nextui-org/react" +import { UserAvatar } from "../avatar" interface ConversationBoxProps { data: FullConversationType, @@ -60,10 +61,7 @@ const ConversationBox = ({ return (