From 1285d58cc0f0fe08aa332951d015dfe4cb670dbc Mon Sep 17 00:00:00 2001 From: Diivvuu Date: Mon, 14 Oct 2024 21:27:37 +0530 Subject: [PATCH] image upload in convex diles db added, messages create and get endpoint (including populating messages) with hooks and integration --- convex/_generated/api.d.ts | 2 + convex/messages.ts | 103 +++++++++++++++++- convex/schema.ts | 31 +++++- convex/upload.ts | 5 + .../channel/[channelId]/chat-input.tsx | 49 +++++++-- src/components/Editor.tsx | 1 - src/components/emoji-popover.tsx | 2 - .../messages/api/use-create-messages.ts | 2 +- src/features/messages/api/use-get-messages.ts | 35 ++++++ .../upload/api/use-generate-upload-url.ts | 63 +++++++++++ 10 files changed, 273 insertions(+), 20 deletions(-) create mode 100644 convex/upload.ts create mode 100644 src/features/messages/api/use-get-messages.ts create mode 100644 src/features/upload/api/use-generate-upload-url.ts diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 082e2db..cdcb059 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -20,6 +20,7 @@ import type * as channels from "../channels.js"; import type * as http from "../http.js"; import type * as members from "../members.js"; import type * as messages from "../messages.js"; +import type * as upload from "../upload.js"; import type * as users from "../users.js"; import type * as workspaces from "../workspaces.js"; @@ -37,6 +38,7 @@ declare const fullApi: ApiFromModules<{ http: typeof http; members: typeof members; messages: typeof messages; + upload: typeof upload; users: typeof users; workspaces: typeof workspaces; }>; diff --git a/convex/messages.ts b/convex/messages.ts index 9c16ed3..880b198 100644 --- a/convex/messages.ts +++ b/convex/messages.ts @@ -1,7 +1,60 @@ import { v } from "convex/values"; -import { mutation, QueryCtx } from "./_generated/server"; +import { mutation, query, QueryCtx } from "./_generated/server"; import { getAuthUserId } from "@convex-dev/auth/server"; import { Id } from "./_generated/dataModel"; +import { timeStamp } from "console"; +import { paginationOptsValidator } from "convex/server"; + +const populateThread = async (ctx: QueryCtx, messageId: Id<"messages">) => { + const messages = await ctx.db + .query("messages") + .withIndex("by_parent_message_id", (q) => + q.eq("parentMessageId", messageId) + ) + .collect(); + + //if there is any message with parent message then it will come in replies only + if (messages.length === 0) { + return { + count: 0, + image: undefined, + timeStamp: 0, + }; + } + + const lastMessage = messages[messages.length - 1]; + const lastMessageMember = await populateMember(ctx, lastMessage.memberId); + + if (!lastMessageMember) { + return { + count: 0, + image: undefined, + timeStamp: 0, + }; + } + + const lastMessageUser = await populateUser(ctx, lastMessageMember.userId); + return { + count: messages.length, + image: lastMessageUser?.image, + timeStamp: lastMessage._creationTime, + }; +}; + +const populateReactions = (ctx: QueryCtx, messageId: Id<"messages">) => { + return ctx.db + .query("reactions") + .withIndex("by_message_id", (q) => q.eq("messageId", messageId)) + .collect(); +}; + +const populateUser = (ctx: QueryCtx, userId: Id<"users">) => { + return ctx.db.get(userId); +}; + +const populateMember = (ctx: QueryCtx, memberId: Id<"members">) => { + return ctx.db.get(memberId); +}; const getMember = async ( ctx: QueryCtx, @@ -16,6 +69,40 @@ const getMember = async ( .unique(); }; +export const get = query({ + args: { + channelId: v.optional(v.id("channels")), + conversationId: v.optional(v.id("conversations")), + parentMessageId: v.optional(v.id("messages")), + paginationOpts: paginationOptsValidator, + }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (!userId) throw new Error("Unauthorized"); + + let _conversationId = args.conversationId; + if (!args.conversationId && !args.channelId && args.parentMessageId) { + const parentMessage = await ctx.db.get(args.parentMessageId); + if (!parentMessage) throw new Error("Parent message not found"); + + _conversationId = parentMessage.conversationId; + } + + const results = await ctx.db + .query("messages") + .withIndex("by_channel_id_parent_message_id_conversation_id", (q) => + q + .eq("channelId", args.channelId) + .eq("parentMessageId", args.parentMessageId) + .eq("conversationId", _conversationId) + ) + .order("desc") + .paginate(args.paginationOpts); + + return results; + }, +}); + export const create = mutation({ args: { body: v.string(), @@ -23,7 +110,7 @@ export const create = mutation({ workspaceId: v.id("workspaces"), channelId: v.optional(v.id("channels")), parentMessageId: v.optional(v.id("messages")), - //todo : add convo id + conversationId: v.optional(v.id("conversations")), }, handler: async (ctx, args) => { const userId = await getAuthUserId(ctx); @@ -32,12 +119,20 @@ export const create = mutation({ const member = await getMember(ctx, args.workspaceId, userId); if (!member) throw new Error("Unauthorized"); - //todo : handle convo id + let _conversationId = args.conversationId; + + //this is only possible when in thread (i.e. conversation) + if (!args.conversationId && !args.channelId && args.parentMessageId) { + const parentMessage = await ctx.db.get(args.parentMessageId); + if (!parentMessage) throw new Error("Parent message not found"); + _conversationId = parentMessage.conversationId; + } + const messageId = await ctx.db.insert("messages", { memberId: member._id, body: args.body, image: args.image, - channeld: args.channelId, + channelId: args.channelId, workspaceId: args.workspaceId, parentMessageId: args.parentMessageId, updatedAt: Date.now(), diff --git a/convex/schema.ts b/convex/schema.ts index 39e2b6a..c2ba76a 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -26,16 +26,41 @@ const schema = defineSchema({ workspaceId: v.id("workspaces"), }).index("by_workspace_id", ["workspaceId"]), + conversations: defineTable({ + workspaceId: v.id("workspaces"), + memberOneId: v.id("members"), + memberTwoId: v.id("members"), + }).index("by_workspace_id", ["workspaceId"]), + messages: defineTable({ body: v.string(), image: v.optional(v.id("_storage")), memberId: v.id("members"), workspaceId: v.id("workspaces"), - channeld: v.optional(v.id("channels")), + channelId: v.optional(v.id("channels")), parentMessageId: v.optional(v.id("messages")), - //todo add convo id + conversationId: v.optional(v.id("conversations")), updatedAt: v.number(), - }), + }) + .index("by_workspace_id", ["workspaceId"]) + .index("by_member_id", ["memberId"]) + .index("by_channel_id", ["channelId"]) + .index("by_conversation_id", ["conversationId"]) + .index("by_parent_message_id", ["parentMessageId"]) + .index("by_channel_id_parent_message_id_conversation_id", [ + "channelId", + "parentMessageId", + "conversationId", + ]), + reactions: defineTable({ + workspaceId: v.id("workspaces"), + messageId: v.id("messages"), + memberId: v.id("messages"), + value: v.string(), + }) + .index("by_workspace_id", ["workspaceId"]) + .index("by_message_id", ["messageId"]) + .index("by_member_id", ["memberId"]), }); export default schema; diff --git a/convex/upload.ts b/convex/upload.ts new file mode 100644 index 0000000..019b294 --- /dev/null +++ b/convex/upload.ts @@ -0,0 +1,5 @@ +import { mutation } from "./_generated/server"; + +export const generateUploadURL = mutation(async (ctx) => { + return await ctx.storage.generateUploadUrl(); +}); diff --git a/src/app/workspace/[workspaceId]/channel/[channelId]/chat-input.tsx b/src/app/workspace/[workspaceId]/channel/[channelId]/chat-input.tsx index adbaef5..37d996e 100644 --- a/src/app/workspace/[workspaceId]/channel/[channelId]/chat-input.tsx +++ b/src/app/workspace/[workspaceId]/channel/[channelId]/chat-input.tsx @@ -1,10 +1,12 @@ import { useCreateMessage } from "@/features/messages/api/use-create-messages"; +import { useGenerateUploadUrl } from "@/features/upload/api/use-generate-upload-url"; import { useChannelId } from "@/hooks/use-channel-id"; import { useWorkspaceId } from "@/hooks/use-workspace-id"; import dynamic from "next/dynamic"; import Quill from "quill"; import { useRef, useState } from "react"; import { toast } from "sonner"; +import { Id } from "../../../../../../convex/_generated/dataModel"; const Editor = dynamic(() => import("@/components/editor"), { ssr: false }); @@ -12,6 +14,13 @@ interface ChatInputProps { placeholder: string; } +type CreateMessageValues = { + channelId: Id<"channels">; + workspaceId: Id<"workspaces">; + body: string; + image?: Id<"_storage"> | undefined; +}; + export const ChatInput = ({ placeholder }: ChatInputProps) => { const [editorKey, setEditorKey] = useState(0); const [pending, setPending] = useState(false); @@ -20,6 +29,8 @@ export const ChatInput = ({ placeholder }: ChatInputProps) => { const workspaceId = useWorkspaceId(); const channelId = useChannelId(); + + const { mutate: generateUploadUrl } = useGenerateUploadUrl(); const { mutate: createMessage } = useCreateMessage(); const handleSubmit = async ({ @@ -32,23 +43,43 @@ export const ChatInput = ({ placeholder }: ChatInputProps) => { console.log(body, image); try { setPending(true); - await createMessage( - { - workspaceId, - channelId, - body, - }, - { throwError: true } - ); + editorRef?.current?.enable(false); + + const values: CreateMessageValues = { + channelId, + workspaceId, + body, + image: undefined, + }; + + if (image) { + const url = await generateUploadUrl({}, { throwError: true }); + + if (!url) throw new Error("Url not found"); + + const result = await fetch(url, { + method: "POST", + headers: { "Content-Type": image.type }, + body: image, + }); + + if (!result.ok) throw new Error("Failed to upload image"); + + const { storageId } = await result.json(); + + values.image = storageId; + } + + await createMessage(values, { throwError: true }); setEditorKey((prevKey) => prevKey + 1); } catch (error) { toast.error("Failed to send message"); } finally { setPending(false); + editorRef?.current?.enable(true); } }; - return (
{ - console.log("selected", emoji); onEmojiSelect(emoji); - console.log("sent"); setPopoverOpen(false); setTimeout(() => { diff --git a/src/features/messages/api/use-create-messages.ts b/src/features/messages/api/use-create-messages.ts index d076b14..6d04177 100644 --- a/src/features/messages/api/use-create-messages.ts +++ b/src/features/messages/api/use-create-messages.ts @@ -9,7 +9,7 @@ type RequestType = { workspaceId: Id<"workspaces">; channelId?: Id<"channels">; parentMessageId?: Id<"messages">; - //todo : add convoId + conversationId?: Id<"conversations">; }; type ResponseType = Id<"messages"> | null; diff --git a/src/features/messages/api/use-get-messages.ts b/src/features/messages/api/use-get-messages.ts new file mode 100644 index 0000000..b1e9b56 --- /dev/null +++ b/src/features/messages/api/use-get-messages.ts @@ -0,0 +1,35 @@ +import { usePaginatedQuery } from "convex/react"; +import { api } from "../../../../convex/_generated/api"; +import { Id } from "../../../../convex/_generated/dataModel"; + +const BATCH_SIZE = 20; +interface UseGetMessageProps { + channelId?: Id<"channels">; + conversationId?: Id<"conversations">; + parentMessageId?: Id<"messages">; +} + +export type GetMessageReturnType = + (typeof api.messages.get._returnType)["page"]; + +export const useGetMessages = ({ + channelId, + conversationId, + parentMessageId, +}: UseGetMessageProps) => { + const { results, status, loadMore } = usePaginatedQuery( + api.messages.get, + { + channelId, + conversationId, + parentMessageId, + }, + { initialNumItems: BATCH_SIZE } + ); + + return { + results, + status, + loadMore: () => loadMore(BATCH_SIZE), + }; +}; diff --git a/src/features/upload/api/use-generate-upload-url.ts b/src/features/upload/api/use-generate-upload-url.ts new file mode 100644 index 0000000..18d565c --- /dev/null +++ b/src/features/upload/api/use-generate-upload-url.ts @@ -0,0 +1,63 @@ +import { useMutation } from "convex/react"; + +import { api } from "../../../../convex/_generated/api"; +import { useCallback, useMemo, useState } from "react"; +import { Id } from "../../../../convex/_generated/dataModel"; + +type ResponseType = string | null; + +type Options = { + onSuccess?: (data: ResponseType) => void; + onError?: (error: Error) => void; + onSeettled?: () => void; + throwError?: boolean; +}; + +export const useGenerateUploadUrl = () => { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [status, setStatus] = useState< + "success" | "error" | "settled" | "pending" | null + >(null); + + const isPending = useMemo(() => status === "pending", [status]); + const isSuccess = useMemo(() => status === "success", [status]); + const isError = useMemo(() => status === "error", [status]); + const isSettled = useMemo(() => status === "settled", [status]); + + const mutation = useMutation(api.upload.generateUploadURL); + + const mutate = useCallback( + async (_values: {}, options?: Options) => { + try { + setData(null); + setError(null); + setStatus("pending"); + + const response = await mutation(); + options?.onSuccess?.(response); + setStatus("success"); + return response; + } catch (error) { + options?.onError?.(error as Error); + setStatus("error"); + if (options?.throwError) { + throw error; + } + } finally { + setStatus("settled"); + options?.onSeettled?.(); + } + }, + [mutation] + ); + return { + mutate, + data, + error, + isPending, + isSuccess, + isError, + isSettled, + }; +};