Skip to content

Commit

Permalink
real time update setup
Browse files Browse the repository at this point in the history
  • Loading branch information
cieko committed Mar 11, 2024
1 parent fd1004d commit 80864d4
Show file tree
Hide file tree
Showing 10 changed files with 654 additions and 84 deletions.
83 changes: 83 additions & 0 deletions app/api/messages/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { NextResponse } from "next/server";
import { Message } from "@prisma/client";

import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";

const MESSAGES_BATCH = 10;

export async function GET(
req: Request
) {
try {
const profile = await currentProfile();
const { searchParams } = new URL(req.url);

const cursor = searchParams.get("cursor");
const channelId = searchParams.get("channelId");

if (!profile) {
return new NextResponse("Unauthorized", { status: 401 });
}

if (!channelId) {
return new NextResponse("Channel ID missing", { status: 400 });
}

let messages: Message[] = [];

if (cursor) {
messages = await db.message.findMany({
take: MESSAGES_BATCH,
skip: 1,
cursor: {
id: cursor,
},
where: {
channelId,
},
include: {
member: {
include: {
profile: true,
}
}
},
orderBy: {
createdAt: "desc",
}
})
} else {
messages = await db.message.findMany({
take: MESSAGES_BATCH,
where: {
channelId,
},
include: {
member: {
include: {
profile: true,
}
}
},
orderBy: {
createdAt: "desc",
}
});
}

let nextCursor = null;

if (messages.length === MESSAGES_BATCH) {
nextCursor = messages[MESSAGES_BATCH - 1].id;
}

return NextResponse.json({
items: messages,
nextCursor
});
} catch (error) {
console.log("[MESSAGES_GET]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}
248 changes: 248 additions & 0 deletions components/chat/chat-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
"use client";

import * as z from "zod";
import axios from "axios";
import qs from "query-string";
import { Member, Profile } from "@prisma/client";
import { UserAvatar } from "@/components/user-avatar";
import { ActionTooltip } from "@/components/action-tooltip";
import { Edit, FileIcon, ShieldCheck, Trash } from "lucide-react";
import Image from "next/image";
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 { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useModal } from "@/hooks/use-modal-store";
import { useParams, useRouter } from "next/navigation";

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<string, string>;
}

const roleIconMap = {
GUEST: null,
MODERATOR: <ShieldCheck className="h-4 w-4 ml-2 text-amber-500" />,
ADMIN: <ShieldCheck className="h-4 w-4 ml-2 text-rose-500" />,
};

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();
const params = useParams();
const router = useRouter();

const onMemberClick = () => {
if (member.id === currentMember.id) {
return;
}

router.push(`/servers/${params?.serverId}/conversations/${member.id}`);
};

useEffect(() => {
const handleKeyDown = (event: any) => {
if (event.key === "Escape" || event.keyCode === 27) {
setIsEditing(false);
}
};

window.addEventListener("keydown", handleKeyDown);

return () => window.removeEventListener("keyDown", handleKeyDown);
}, []);

const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
content: content,
},
});

const isLoading = form.formState.isSubmitting;

const onSubmit = async (values: z.infer<typeof formSchema>) => {
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, form]);

const fileType = fileUrl?.split(".").pop();

const isAdmin = currentMember.role === "ADMIN";
const isModerator = currentMember.role === "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 (
<div className="relative group flex items-center hover:bg-black/5 p-4 transition w-full">
<div className="group flex gap-x-2 items-start w-full">
<div onClick={onMemberClick} className="cursor-pointer hover:drop-shadow-md transition">
<UserAvatar src={member.profile.imageUrl} />
</div>

<div className="flex flex-col w-full">
<div className="flex items-center gap-x-2">
<div className="flex items-center">
<p onClick={onMemberClick} className="font-semibold text-amber-600 text-sm hover:underline cursor-pointer">
{member.profile.name}
</p>
<ActionTooltip label={member.role}>
{roleIconMap[member.role]}
</ActionTooltip>
</div>
<span className="text-xs text-zinc-400 ">{timestamp}</span>
</div>
{isImage && (
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="relative aspect-square rounded-md mt-2 overflow-hidden border flex items-center bg-secondary h-48 w-48"
>
<Image
src={fileUrl}
alt={content}
fill
className="object-cover"
sizes="100vw"
priority={true}
loading="eager"
/>
</a>
)}
{isPDF && (
<div className="relative flex items-center p-2 mt-2 rounded-md bg-background/10">
<FileIcon className="h-10 w-10 fill-amber-500 stroke-amber-800" />
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-sm text-amber-500 hover:underline"
>
PDF File
</a>
</div>
)}
{!fileUrl && !isEditing && (
<p
className={cn(
"text-xl text-[#ffbd80]",
deleted &&
"italic text-amber-400 text-md mt-1"
)}
>
{content}
{isUpdated && !deleted && (
<span className="text-[10px] mx-2 text-amber-400">
(edited)
</span>
)}
</p>
)}
{!fileUrl && isEditing && (
<Form {...form}>
<form
className="flex items-center w-full gap-x-2 pt-2"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<div className="relative w-full">
<Input
disabled={isLoading}
className="p-2 bg-zinc-700/75 border-none border-0 focus-visible:ring-0 focus-visible:ring-offset-0 text-zinc-200"
placeholder="Edited message"
{...field}
/>
</div>
</FormControl>
</FormItem>
)}
/>
<Button disabled={isLoading} size="sm" variant="theme">
Save
</Button>
</form>
<span className="text-[10px] mt-1 text-zinc-400">
Press escape to cancel, enter to save
</span>
</Form>
)}
</div>
</div>
{canDeleteMessage && (
<div className="hidden group-hover:flex items-center gap-x-2 absolute p-1 -top-2 right-5 bg-white dark:bg-zinc-800 border rounded-sm">
{canEditMessage && (
<ActionTooltip label="Edit">
<Edit
onClick={() => setIsEditing(true)}
className="cursor-pointer ml-auto w-4 h-4 text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 transition"
/>
</ActionTooltip>
)}
<ActionTooltip label="Delete">
<Trash
onClick={() => onOpen("deleteMessage", {
apiUrl: `${socketUrl}/${id}`,
query: socketQuery,
})}
className="cursor-pointer ml-auto w-4 h-4 text-zinc-500 hover:text-zinc-300 transition"
/>
</ActionTooltip>
</div>
)}
</div>
);
};
Loading

0 comments on commit 80864d4

Please sign in to comment.