diff --git a/components/chat/chat-item.tsx b/components/chat/chat-item.tsx new file mode 100644 index 0000000..272cfa8 --- /dev/null +++ b/components/chat/chat-item.tsx @@ -0,0 +1,242 @@ +"use client" + +import * as z from "zod"; +import axios from "axios"; +import qs from "query-string"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; + +import { Member, MemberRole, Profile } from "@prisma/client"; +import { UserAvatar } from "@/components/user-avatar"; +import { ActionTooltip } from "@/components/action-tooltip"; +import { Edit, FileIcon, ShieldAlert, ShieldCheck, Trash } from "lucide-react"; +import Image from "next/image"; +import { useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; + +import { + Form, + FormControl, + FormField, + FormItem, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useModal } from "@/hooks/use-modal-store"; + +interface ChatItemProps { + id: string; + content: string; + member: Member & { + profile: Profile; + }; + timestamp: string + fileUrl: string | null; + deleted: boolean; + currentMember: Member; + isUpdated: boolean; + socketUrl: string; + socketQuery: Record; +} + +const roleIconMap = { + "GUEST": null, + "MODERATOR": , + "ADMIN": , +} + +const formSchema = z.object({ + content: z.string().min(1), +}) + +export const ChatItem = ({ + id, + content, + member, + timestamp, + fileUrl, + deleted, + currentMember, + isUpdated, + socketUrl, + socketQuery, +}: ChatItemProps) => { + const [isEditing, setIsEditing] = useState(false); + const { onOpen } = useModal(); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" || event.keyCode === 27) { + setIsEditing(false); + } + }; + window.addEventListener("keydown", handleKeyDown); + + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + content: content + } + }); + + const isLoading = form.formState.isSubmitting; + + const onSubmit = async (values: z.infer) => { + try { + const url = qs.stringifyUrl({ + url: `${socketUrl}/${id}`, + query: socketQuery, + }); + + await axios.patch(url, values); + + form.reset(); + setIsEditing(false); + } catch (error) { + console.log(error); + } + } + + useEffect(() => { + form.reset({ + content: content, + }) + }, [content]); + + const filetype = fileUrl?.split(".").pop(); + + const isAdmin = currentMember.role === MemberRole.ADMIN; + const isModerator = currentMember.role === MemberRole.MODERATOR; + const isOwner = currentMember.id === member.id; + const canDeleteMessage = !deleted && (isAdmin || isModerator || isOwner); + const canEditMessage = !deleted && isOwner && !fileUrl; + const isPDF = filetype == "pdf" && fileUrl; + const isImage = !isPDF && fileUrl; + + + return ( +
+
+
+ +
+
+
+
+

+ {member.profile.name} +

+ + {roleIconMap[member.role]} + +
+ + {timestamp} + +
+ {isImage && ( + + {content} + + )} + {isPDF && ( + + )} + {!fileUrl && !isEditing && ( +

+ {content} + {isUpdated && ( + + (edited) + + )} +

+ )} + {!fileUrl && isEditing && ( +
+ + ( + + +
+ +
+
+ +
+ )} + /> + + + + Press escape to cancel, enter to save + + + + )} +
+
+ {canDeleteMessage && ( +
+ {canEditMessage && ( + + setIsEditing(true)} + className="cursor-pointer ml-auto w-4 h-4 text-zinc-500 hover:text-zinc-600 dark:hove:text-zinc-300 transition" + /> + + + )} + + onOpen("deleteMessage", { + apiUrl: `${socketUrl}/${id}`, + query: socketQuery, + })} + className="cursor-pointer ml-auto w-4 h-4 text-zinc-500 hover:text-zinc-600 dark:hove:text-zinc-300 transition" + /> + + +
+ )} +
+ ) +} \ No newline at end of file diff --git a/components/chat/chat-messages.tsx b/components/chat/chat-messages.tsx index af683f9..5087a7e 100644 --- a/components/chat/chat-messages.tsx +++ b/components/chat/chat-messages.tsx @@ -1,11 +1,15 @@ "use client"; import { Member, Message, Profile } from "@prisma/client"; +import { format } from "date-fns" import { ChatWelcome } from "./chat-welcome"; import { useChatQuery } from "@/hooks/use-chat-query"; import { Loader2, ServerCrash } from "lucide-react"; import { Fragment } from "react"; +import { ChatItem } from "./chat-item"; + +const DATE_FORMAT = "d MMM yyyy, HH:mm"; type MessageWithMemberWithProfile = Message & { member: Member & { @@ -84,9 +88,20 @@ export const ChatMessages = ({ {data?.pages.map((group, i) => ( {group.items.map((message: MessageWithMemberWithProfile) => ( -
- {message.content} -
+ ))}
))} diff --git a/components/modals/delete-message-modal.tsx b/components/modals/delete-message-modal.tsx new file mode 100644 index 0000000..cfd8faf --- /dev/null +++ b/components/modals/delete-message-modal.tsx @@ -0,0 +1,79 @@ +"use client"; + +import qs from "query-string"; +import axios from "axios"; +import { useState } from "react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useModal } from "@/hooks/use-modal-store"; +import { Button } from "@/components/ui/button"; + + +export const DeleteMessageModal = () => { + const { isOpen, onClose, type, data } = useModal(); + + const isModalOpen = isOpen && type === "deleteMessage"; + const { apiUrl, query } = data; + + const [isLoading, setIsLoading] = useState(false); + + const onClick = async () => + { + try + { + setIsLoading(true); + + const url = qs.stringifyUrl({ + url: apiUrl || "", + query, + }) + + await axios.delete(url); + + onClose(); + } + catch (error) + { + console.log(error); + } + finally + { + setIsLoading(false); + onClose(); + } + } + + return ( + + + + + Delete Message + + + Are you sure you want to do this?
+ The message will be permenantly deleted. +
+
+ +
+ + +
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/components/providers/modal-provider.tsx b/components/providers/modal-provider.tsx index 6adf1cc..21ad798 100644 --- a/components/providers/modal-provider.tsx +++ b/components/providers/modal-provider.tsx @@ -12,6 +12,7 @@ import { DeleteServerModal } from "@/components/modals/delete-server-modal"; import { DeleteChannelModal } from "@/components/modals/delete-channel-modal"; import { EditChannelModal } from "@/components/modals/edit-channel-modal"; import { MessageFileModal } from "@/components/modals/message-file-modal.tsx"; +import { DeleteMessageModal } from "@/components/modals/delete-message-modal"; export const ModalProvider = () => { const [isMounted, setIsMounted] = useState(false); @@ -36,6 +37,7 @@ export const ModalProvider = () => { + ) } \ No newline at end of file diff --git a/hooks/use-modal-store.ts b/hooks/use-modal-store.ts index 4f334b2..8dbfc04 100644 --- a/hooks/use-modal-store.ts +++ b/hooks/use-modal-store.ts @@ -2,7 +2,7 @@ import { Channel, ChannelType, Server } from "@prisma/client"; import { create } from "zustand"; -export type ModalType = "createServer" | "invite" | "editServer" | "members" | "createChannel" | "leaveServer" | "deleteServer" | "deleteChannel" | "editChannel" | "messageFile"; +export type ModalType = "createServer" | "invite" | "editServer" | "members" | "createChannel" | "leaveServer" | "deleteServer" | "deleteChannel" | "editChannel" | "messageFile" | "deleteMessage"; interface ModalData { server?: Server; diff --git a/pages/api/socket/messages/[messageId].ts b/pages/api/socket/messages/[messageId].ts new file mode 100644 index 0000000..0e0b4e7 --- /dev/null +++ b/pages/api/socket/messages/[messageId].ts @@ -0,0 +1,143 @@ +import { currentProfilePages } from "@/lib/current-profile-pages"; +import { db } from "@/lib/db"; +import { NextApiResponseServerIo } from "@/types"; +import { MemberRole } from "@prisma/client"; +import { NextApiRequest } from "next"; + +export default async function handler(req: NextApiRequest, res: NextApiResponseServerIo) { + if (req.method !== "DELETE" && req.method !== "PATCH") { + return res.status(405).json({ error: "Method not allowed" }); + } + + try { + const profile = await currentProfilePages(req); + const { serverId, channelId, messageId } = req.query; + const { content } = req.body; + + if (!profile) { + return res.status(401).json({ error: "Unauthorized" }); + } + + if (!serverId) { + return res.status(400).json({ error: "Server ID Missing" }); + } + + if (!channelId) { + return res.status(400).json({ error: "Channel ID Missing" }); + } + + const server= await db.server.findFirst({ + where: { + id: serverId as string, + members: { + some: { + profileId: profile.id + } + }, + }, + include: { + members: true + } + }); + + if (!server) { + return res.status(404).json({ error: "Server not found" }); + } + + const channel = await db.channel.findFirst({ + where: { + id: channelId as string, + serverId: server.id, + }, + }); + + if (!channel) { + return res.status(404).json({ error: "Channel not found" }); + } + + const member = server.members.find((member) => member.profileId === profile.id); + + if (!member) { + return res.status(404).json({ error: "Member not found" }); + } + + let message = await db.message.findFirst({ + where: { + id: messageId as string, + channelId: channel.id, + }, + include: { + member: { + include: { + profile: true, + } + } + } + }); + + if (!message || message.deleted) { + return res.status(404).json({ error: "Message not found" }); + } + + const isMessageOwner = message.memberId === member.id; + const isAdmin = member.role === MemberRole.ADMIN; + const isModerator = member.role === MemberRole.MODERATOR; + const canModify = isMessageOwner || isAdmin || isModerator; + + if (!canModify) { + return res.status(401).json({ error: "Unauthorized" }); + } + + if (req.method === "DELETE") { + message = await db.message.update({ + where: { + id: messageId as string, + }, + data: { + fileUrl: null, + content: "This message has been deleted.", + deleted: true, + }, + include: { + member: { + include: { + profile: true, + } + } + } + }); + } + + if (req.method === "PATCH") { + if (!isMessageOwner) { + return res.status(401).json({ error: "Unauthorized" }); + } + + message = await db.message.update({ + where: { + id: messageId as string, + }, + data: { + content, + }, + include: { + member: { + include: { + profile: true, + } + } + } + }); + } + + const updateKey = `chat:${channelId}:messages:update`; + + res?.socket?.server?.io?.to(updateKey).emit(updateKey, message); + + return res.status(200).json(message); + + } catch (error) { + console.error(error); + return res.status(500).json({ error: "Internal Error" }); + } +} \ No newline at end of file diff --git a/pages/api/socket/messages.ts b/pages/api/socket/messages/index.ts similarity index 100% rename from pages/api/socket/messages.ts rename to pages/api/socket/messages/index.ts