diff --git a/.env b/.env index cda84ee..04be248 100644 --- a/.env +++ b/.env @@ -8,4 +8,6 @@ DATABASE_URL="mongodb+srv://quyduc:Kk41ixPM7jnBJMRt@cluster0.9h7x1tw.mongodb.net NEXTAUTH_SECRET = "NEXTAUTH_SECRET" GOOGLE_CLIENT_ID=409276087119-9hmgc0ad6augp7gkeaqmq2r1ru92afo0.apps.googleusercontent.com -GOOGLE_CLIENT_SECRET=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 \ No newline at end of file +GOOGLE_CLIENT_SECRET=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 + +NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=dgp72bk0u \ No newline at end of file diff --git a/app/actions/getConversationById.ts b/app/actions/getConversationById.ts new file mode 100644 index 0000000..e9ca333 --- /dev/null +++ b/app/actions/getConversationById.ts @@ -0,0 +1,26 @@ +import prisma from '@/app/libs/prismadb' +import getCurrentUser from './getCurrentUser' + +const getConversationById = async (conversationId: string) => { + try { + const currentUser = await getCurrentUser() + + if (!currentUser?.email) { + return null + } + + const conversation = await prisma.conversation.findUnique({ + where: { + id: conversationId, + }, + include: { + users: true, + }, + }) + return conversation + } catch (error: any) { + return null + } +} + +export default getConversationById diff --git a/app/actions/getMessages.ts b/app/actions/getMessages.ts new file mode 100644 index 0000000..f9ba209 --- /dev/null +++ b/app/actions/getMessages.ts @@ -0,0 +1,23 @@ +import prisma from '@/app/libs/prismadb' + +const getMessages = async (conversationId: string) => { + try { + const messages = await prisma.message.findMany({ + where: { + conversationId: conversationId, + }, + include: { + sender: true, + seen: true, + }, + orderBy: { + createdAt: 'asc', + }, + }) + return messages + } catch (error: any) { + return [] + } +} + +export default getMessages diff --git a/app/api/conversations/[conversationId]/seen/route.ts b/app/api/conversations/[conversationId]/seen/route.ts new file mode 100644 index 0000000..4120b24 --- /dev/null +++ b/app/api/conversations/[conversationId]/seen/route.ts @@ -0,0 +1,61 @@ +import getCurrentUser from '@/app/actions/getCurrentUser' +import prisma from '@/app/libs/prismadb' +import { NextResponse } from 'next/server' + +interface IParams { + conversationId?: string +} + +export async function POST(request: Request, { params }: { params: IParams }) { + try { + const currentUser = await getCurrentUser() + const { conversationId } = params + + if (!currentUser?.id || !currentUser?.email) { + return new NextResponse('Unauthorized', { status: 401 }) + } + + // find the existing conversation + const conversation = await prisma.conversation.findUnique({ + where: { + id: conversationId, + }, + include: { + messages: { + include: { + seen: true, + }, + }, + users: true, + }, + }) + + if (!conversation) return new NextResponse('Invalid Id', { status: 400 }) + + // find last message + const lastMessage = conversation.messages[conversation.messages.length - 1] + if (!lastMessage) return NextResponse.json(conversation) + + // update seen of last message + const updatedMessage = await prisma.message.update({ + where: { + id: lastMessage.id, + }, + include: { + sender: true, + seen: true, + }, + data: { + seen: { + connect: { + id: currentUser.id, + }, + }, + }, + }) + return NextResponse.json(updatedMessage) + } catch (error: any) { + console.log(error, 'ERROR_MESSAGES_SEEN') + return new NextResponse('Internal Error', { status: 500 }) + } +} diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts new file mode 100644 index 0000000..74d632d --- /dev/null +++ b/app/api/messages/route.ts @@ -0,0 +1,68 @@ +import getCurrentUser from '@/app/actions/getCurrentUser' +import prisma from '@/app/libs/prismadb' +import { NextResponse } from 'next/server' + +export async function POST(request: Request) { + try { + const currentUser = await getCurrentUser() + const body = await request.json() + const { message, image, conversationId } = body + + if (!currentUser?.id || !currentUser?.email) { + return new NextResponse('Unauthorized', { status: 401 }) + } + + const newMessage = await prisma.message.create({ + data: { + body: message, + image: image, + conversation: { + connect: { + id: conversationId, + }, + }, + sender: { + connect: { + id: currentUser.id, + }, + }, + seen: { + connect: { + id: currentUser.id, + }, + }, + }, + include: { + seen: true, + sender: true, + }, + }) + + const updatedConversation = await prisma.conversation.update({ + where: { + id: conversationId, + }, + data: { + lastMessageAt: new Date(), + messages: { + connect: { + id: newMessage.id, + }, + }, + }, + include: { + users: true, + messages: { + include: { + seen: true, + }, + }, + }, + }) + + return NextResponse.json(newMessage) + } catch (error: any) { + console.log(error, 'ERROR_MESSAGES') + return new NextResponse('InternalError', { status: 500 }) + } +} diff --git a/app/conversations/[conversationId]/components/Body.tsx b/app/conversations/[conversationId]/components/Body.tsx new file mode 100644 index 0000000..0cf37a7 --- /dev/null +++ b/app/conversations/[conversationId]/components/Body.tsx @@ -0,0 +1,37 @@ +'use client' + +import useConversation from '@/app/hooks/useConversation' +import { FullMessageType } from '@/app/types' +import axios from 'axios' +import { useEffect, useRef, useState } from 'react' +import MessageBox from './MessageBox' + +interface BodyProps { + initialMessages: FullMessageType[] +} + +const Body: React.FC = ({ initialMessages }) => { + const [messages, setMessages] = useState(initialMessages) + const bottomRef = useRef(null) + + const { conversationId } = useConversation() + + useEffect(() => { + axios.post(`/api/conversations/${conversationId}/seen`) + }, [conversationId]) + + return ( +
+ {messages.map((message, i) => ( + + ))} +
+
+ ) +} + +export default Body diff --git a/app/conversations/[conversationId]/components/Form.tsx b/app/conversations/[conversationId]/components/Form.tsx new file mode 100644 index 0000000..5ea4618 --- /dev/null +++ b/app/conversations/[conversationId]/components/Form.tsx @@ -0,0 +1,74 @@ +'use client' + +import useConversation from '@/app/hooks/useConversation' +import axios from 'axios' +import { CldUploadButton } from 'next-cloudinary' +import { FieldValues, SubmitHandler, useForm } from 'react-hook-form' +import { HiPaperAirplane, HiPhoto } from 'react-icons/hi2' +import MessageInput from './MessageInput' + +const Form = () => { + const { conversationId } = useConversation() + + const { + register, + handleSubmit, + setValue, + formState: { errors }, + } = useForm({ + defaultValues: { + messages: '', + }, + }) + + const onSubmit: SubmitHandler = (data) => { + setValue('message', '', { shouldValidate: true }) + axios.post('/api/messages', { + ...data, + conversationId, + }) + } + + const handleUpload = (result: any) => { + axios.post('/api/messages', { + image: result?.info?.secure_url, + conversationId, + }) + } + + return ( +
+ + + + +
+ + + +
+ ) +} + +export default Form diff --git a/app/conversations/[conversationId]/components/Header.tsx b/app/conversations/[conversationId]/components/Header.tsx new file mode 100644 index 0000000..c862615 --- /dev/null +++ b/app/conversations/[conversationId]/components/Header.tsx @@ -0,0 +1,60 @@ +'use client' + +import Avatar from '@/app/components/Avatar' +import useOtherUser from '@/app/hooks/useOtherUser' +import { Conversation, User } from '@prisma/client' +import Link from 'next/link' +import { useMemo, useState } from 'react' +import { HiChevronLeft, HiEllipsisHorizontal } from 'react-icons/hi2' + +interface HeaderProps { + conversation: Conversation & { + user: User[] + } +} + +const Header: React.FC = ({ conversation }) => { + const otherUser = useOtherUser(conversation) + const [drawerOpen, setDrawerOpen] = useState(false) + + const statusText = useMemo(() => { + if (conversation.isGroup) { + return `${conversation.user.length} members` + } + + return 'Active' + }, [conversation]) + + return ( + <> +
+
+ + + + +
+
{conversation.name || otherUser.name}
+
+ {statusText} +
+
+
+ {}} + className="text-sky-500 cursor-pointer hover:text-sky-600 transition" + /> +
+ + ) +} + +export default Header diff --git a/app/conversations/[conversationId]/components/MessageBox.tsx b/app/conversations/[conversationId]/components/MessageBox.tsx new file mode 100644 index 0000000..0617387 --- /dev/null +++ b/app/conversations/[conversationId]/components/MessageBox.tsx @@ -0,0 +1,71 @@ +'use client' + +import Avatar from '@/app/components/Avatar' +import { FullMessageType } from '@/app/types' +import clsx from 'clsx' +import format from 'date-fns/format' +import { useSession } from 'next-auth/react' +import Image from 'next/image' + +interface MessageBoxProps { + data: FullMessageType + isLast?: boolean +} + +const MessageBox: React.FC = ({ data, isLast }) => { + const session = useSession() + + const isOwn = session?.data?.user?.email === data?.sender?.email + const seenList = (data.seen || []) + .filter((user) => user.email !== data?.sender?.email) + .map((user) => user.name) + .join(', ') + + const container = clsx('flex gap-3 p-4', isOwn && 'justify-end') + + const avatar = clsx(isOwn && 'order-2') + + const body = clsx('flex flex-col gap -2', isOwn && 'items-end') + + const message = clsx( + 'text-sm w-fit overflow-hidden', + isOwn ? 'bg-sky-500 text-white' : 'bg-gray-100', + data.image ? 'rounded-md p-0' : 'rounded-full py-2 px-3' + ) + + return ( +
+
+ +
+
+
+
{data.sender.name}
+
+ {format(new Date(data.createdAt), 'p')} +
+
+
+ {data.image ? ( + Image + ) : ( +
{data.body}
+ )} +
+ {isLast && isOwn && seenList.length > 0 && ( +
+ {`Seen by ${seenList}`} +
+ )} +
+
+ ) +} + +export default MessageBox diff --git a/app/conversations/[conversationId]/components/MessageInput.tsx b/app/conversations/[conversationId]/components/MessageInput.tsx new file mode 100644 index 0000000..8dedac8 --- /dev/null +++ b/app/conversations/[conversationId]/components/MessageInput.tsx @@ -0,0 +1,37 @@ +'use client' + +import { FieldErrors, FieldValues, UseFormRegister } from 'react-hook-form' + +interface MessageInputProps { + placeholder?: string + id: string + type?: string + required?: boolean + register: UseFormRegister + errors: FieldErrors +} + +const MessageInput: React.FC = ({ + placeholder, + id, + type, + required, + register, + errors, +}) => { + return ( +
+ +
+ ) +} + +export default MessageInput diff --git a/app/conversations/[conversationId]/page.tsx b/app/conversations/[conversationId]/page.tsx new file mode 100644 index 0000000..aba8b57 --- /dev/null +++ b/app/conversations/[conversationId]/page.tsx @@ -0,0 +1,37 @@ +import getConversationById from '@/app/actions/getConversationById' +import getMessages from '@/app/actions/getMessages' +import EmptyState from '@/app/components/EmptyState' +import Body from './components/Body' +import Form from './components/Form' +import Header from './components/Header' + +interface IParams { + conversationId: string +} + +const ConversationId = async ({ params }: { params: IParams }) => { + const conversation = await getConversationById(params.conversationId) + const messages = await getMessages(params.conversationId) + + if (!conversation) { + return ( +
+
+ +
+
+ ) + } + + return ( +
+
+
+ +
+
+
+ ) +} + +export default ConversationId diff --git a/app/hooks/useOtherUser.ts b/app/hooks/useOtherUser.ts index c3a2fce..a84c13a 100644 --- a/app/hooks/useOtherUser.ts +++ b/app/hooks/useOtherUser.ts @@ -4,11 +4,7 @@ import { useMemo } from 'react' import { FullConversationType } from '../types' const useOtherUser = ( - conversation: - | FullConversationType - | { - users: User[] - } + conversation: FullConversationType | { users: User[] } ) => { const session = useSession() const otherUser = useMemo(() => { diff --git a/middleware.ts b/middleware.ts index 33b7074..d9ec07b 100644 --- a/middleware.ts +++ b/middleware.ts @@ -7,5 +7,5 @@ export default withAuth({ }) export const config = { - matcher: ['/users/:path*'], + matcher: ['/users/:path*', '/conversations/:path*'], } diff --git a/package-lock.json b/package-lock.json index 3a50cc2..847cf67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "eslint-config-next": "13.4.1", "next": "13.4.1", "next-auth": "^4.22.1", + "next-cloudinary": "^4.15.0", "next-superjson-plugin": "^0.5.9", "postcss": "8.4.24", "react": "18.2.0", @@ -60,6 +61,41 @@ "node": ">=6.9.0" } }, + "node_modules/@cloudinary-util/url-loader": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/@cloudinary-util/url-loader/-/url-loader-3.9.1.tgz", + "integrity": "sha512-5j91AbiNIIyWz24hzrpKKEZut08j9px5xGTSwqFS1c2aadBOirOAOY8C23SzJSQNmuBDDZnHgH83EWfrZj3leA==", + "dependencies": { + "@cloudinary-util/util": "2.2.0", + "@cloudinary/url-gen": "^1.10.1" + } + }, + "node_modules/@cloudinary-util/url-loader/node_modules/@cloudinary-util/util": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cloudinary-util/util/-/util-2.2.0.tgz", + "integrity": "sha512-FCEwoswGO9z5BmSY0iJLos8U4bmNGIy7Ap8KoJRJ3Vn2STqx+ilDzYMPJTQ8FVcYD1iDajMs52xg00oWO99pNg==" + }, + "node_modules/@cloudinary-util/util": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@cloudinary-util/util/-/util-2.2.1.tgz", + "integrity": "sha512-MEIqn5WtPP3mxSMTNNfpqlGS1UquAXcjmVdY/t/edD/ZWVjI85viBiOMIuF0W6n6UF5gTstLymfQNYx0YO/GZg==" + }, + "node_modules/@cloudinary/transformation-builder-sdk": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@cloudinary/transformation-builder-sdk/-/transformation-builder-sdk-1.3.0.tgz", + "integrity": "sha512-/ax91epnmsr72JB7piI6tilld2POI1Xigf7uRZhaCG1QciQBtHCIfJc/IVLfwr9GYuB79oWYVF/SVsT7+7YA8Q==", + "dependencies": { + "@cloudinary/url-gen": "^1.7.0" + } + }, + "node_modules/@cloudinary/url-gen": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@cloudinary/url-gen/-/url-gen-1.10.2.tgz", + "integrity": "sha512-0iQcowRGxbRMuWijr3gkWajyrtocXyO5va6b2f5AIuA8K5Utd3IEq17DGR93kpOOLU439JBSjORuWifyphGduA==", + "dependencies": { + "@cloudinary/transformation-builder-sdk": "^1.2.7" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -3375,6 +3411,20 @@ } } }, + "node_modules/next-cloudinary": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/next-cloudinary/-/next-cloudinary-4.15.0.tgz", + "integrity": "sha512-pcpPR7PhtRskQT8oMet1X7qnwkc6wRaexPDkuSdHtZInx6yMZlxWj9sXQLtQMpyHy8NAWXLJ7aUsMpSg2VHjtA==", + "dependencies": { + "@cloudinary-util/url-loader": "^3.9.1", + "@cloudinary-util/util": "^2.2.1", + "@cloudinary/url-gen": "^1.10.1" + }, + "peerDependencies": { + "next": "^12 || ^13", + "react": "^17 || ^18" + } + }, "node_modules/next-superjson-plugin": { "version": "0.5.9", "resolved": "https://registry.npmjs.org/next-superjson-plugin/-/next-superjson-plugin-0.5.9.tgz", diff --git a/package.json b/package.json index beb3925..fe703ba 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "eslint-config-next": "13.4.1", "next": "13.4.1", "next-auth": "^4.22.1", + "next-cloudinary": "^4.15.0", "next-superjson-plugin": "^0.5.9", "postcss": "8.4.24", "react": "18.2.0",