├── README.md
├── .eslintrc.json
├── public
├── gpt.png
├── bg-dark.png
├── bg-light.png
├── dall-e.png
├── poopenai.png
├── whatsapp.png
├── desktop-hero.png
├── placeholder.png
├── vercel.svg
└── next.svg
├── src
├── app
│ ├── favicon.ico
│ ├── page.tsx
│ ├── tasks
│ │ └── page.tsx
│ ├── layout.tsx
│ └── globals.css
├── providers
│ ├── theme-provider.tsx
│ └── convex-client-provider.tsx
├── middleware.ts
├── components
│ ├── home
│ │ ├── date-indicator.tsx
│ │ ├── chat-bubble-avatar.tsx
│ │ ├── chat-placeholder.tsx
│ │ ├── message-container.tsx
│ │ ├── right-panel.tsx
│ │ ├── chat-avatar-actions.tsx
│ │ ├── group-members-dialog.tsx
│ │ ├── conversation.tsx
│ │ ├── left-panel.tsx
│ │ ├── message-input.tsx
│ │ ├── chat-bubble.tsx
│ │ ├── media-dropdown.tsx
│ │ └── user-list-dialog.tsx
│ ├── ui
│ │ ├── input.tsx
│ │ ├── avatar.tsx
│ │ ├── button.tsx
│ │ ├── dialog.tsx
│ │ └── dropdown-menu.tsx
│ └── theme-switch.tsx
├── lib
│ ├── svgs.tsx
│ └── utils.ts
├── hooks
│ └── useComponentVisible.ts
├── store
│ └── chat-store.ts
└── dummy
│ └── db.ts
├── next.config.mjs
├── postcss.config.mjs
├── convex
├── auth.convex.ts
├── _generated
│ ├── api.js
│ ├── api.d.ts
│ ├── dataModel.d.ts
│ ├── server.js
│ └── server.d.ts
├── clerk.ts
├── tsconfig.json
├── schema.ts
├── http.ts
├── README.md
├── users.ts
├── conversations.ts
└── messages.ts
├── components.json
├── .gitignore
├── tsconfig.json
├── package.json
└── tailwind.config.ts
/README.md:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/public/gpt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arbab-Mustafa/Watts-app-Clone/HEAD/public/gpt.png
--------------------------------------------------------------------------------
/public/bg-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arbab-Mustafa/Watts-app-Clone/HEAD/public/bg-dark.png
--------------------------------------------------------------------------------
/public/bg-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arbab-Mustafa/Watts-app-Clone/HEAD/public/bg-light.png
--------------------------------------------------------------------------------
/public/dall-e.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arbab-Mustafa/Watts-app-Clone/HEAD/public/dall-e.png
--------------------------------------------------------------------------------
/public/poopenai.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arbab-Mustafa/Watts-app-Clone/HEAD/public/poopenai.png
--------------------------------------------------------------------------------
/public/whatsapp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arbab-Mustafa/Watts-app-Clone/HEAD/public/whatsapp.png
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arbab-Mustafa/Watts-app-Clone/HEAD/src/app/favicon.ico
--------------------------------------------------------------------------------
/public/desktop-hero.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arbab-Mustafa/Watts-app-Clone/HEAD/public/desktop-hero.png
--------------------------------------------------------------------------------
/public/placeholder.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Arbab-Mustafa/Watts-app-Clone/HEAD/public/placeholder.png
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | export default nextConfig;
5 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/convex/auth.convex.ts:
--------------------------------------------------------------------------------
1 | const authConfig = {
2 | providers: [
3 | {
4 | domain: "https://exotic-osprey-18.clerk.accounts.dev",
5 | applicationID: "convex",
6 | },
7 | ],
8 | };
9 |
10 | export default authConfig;
11 |
--------------------------------------------------------------------------------
/src/providers/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { ThemeProvider as NextThemesProvider } from "next-themes";
5 | import { type ThemeProviderProps } from "next-themes/dist/types";
6 |
7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
8 | return {children};
9 | }
10 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { clerkMiddleware, authMiddleware } from "@clerk/nextjs/server";
2 |
3 | // export default clerkMiddleware()
4 | export default authMiddleware({});
5 |
6 | export const config = {
7 | matcher: [
8 | // Skip Next.js internals and all static files, unless found in search params
9 | "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
10 | // Always run for API routes
11 | "/(api|trpc)(.*)",
12 | ],
13 | };
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/convex/_generated/api.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated `api` utility.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.14.1.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import { anyApi } from "convex/server";
13 |
14 | /**
15 | * A utility for referencing Convex functions in your app's API.
16 | *
17 | * Usage:
18 | * ```js
19 | * const myFunctionReference = api.myModule.myFunction;
20 | * ```
21 | */
22 | export const api = anyApi;
23 | export const internal = anyApi;
24 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import RightPanel from "@/components/home/right-panel";
2 | import LeftPanel from "@/components/home/left-panel";
3 |
4 | export default function Home() {
5 | return (
6 |
7 |
8 | {/* Green background decorator for Light Mode */}
9 |
10 |
11 |
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/convex/clerk.ts:
--------------------------------------------------------------------------------
1 | "use node";
2 |
3 | import type { WebhookEvent } from "@clerk/clerk-sdk-node";
4 | import { v } from "convex/values";
5 |
6 | import { Webhook } from "svix";
7 |
8 | import { internalAction } from "./_generated/server";
9 |
10 | const WEB_HOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET as string;
11 |
12 | export const fulfill = internalAction({
13 | args: {
14 | headers: v.any(),
15 | payload: v.string(),
16 | },
17 | handler: async (ctx, args) => {
18 | const wh = new Webhook(WEB_HOOK_SECRET);
19 | const payload = wh.verify(args.payload, args.headers) as WebhookEvent;
20 | return payload;
21 | },
22 | });
23 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./src/*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------
/src/providers/convex-client-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ReactNode } from "react";
4 |
5 | import { ClerkProvider, useAuth } from "@clerk/nextjs";
6 | import { ConvexProviderWithClerk } from "convex/react-clerk";
7 | import { ConvexReactClient } from "convex/react";
8 |
9 | const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
10 |
11 | export function ConvexClientProvider({ children }: { children: ReactNode }) {
12 | return (
13 |
16 |
17 | {children}
18 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/home/date-indicator.tsx:
--------------------------------------------------------------------------------
1 | import { getRelativeDateTime, isSameDay } from "@/lib/utils";
2 | import { IMessage } from "@/store/chat-store";
3 |
4 | type DateIndicatorProps = {
5 | message: IMessage;
6 | previousMessage?: IMessage;
7 | };
8 | const DateIndicator = ({ message, previousMessage }: DateIndicatorProps) => {
9 | return (
10 | <>
11 | {!previousMessage ||
12 | !isSameDay(previousMessage._creationTime, message._creationTime) ? (
13 |
14 |
15 | {getRelativeDateTime(message, previousMessage)}
16 |
17 |
18 | ) : null}
19 | >
20 | );
21 | };
22 | export default DateIndicator;
23 |
--------------------------------------------------------------------------------
/src/lib/svgs.tsx:
--------------------------------------------------------------------------------
1 | export const MessageSeenSvg = (props: React.SVGProps) => (
2 |
9 | );
10 |
--------------------------------------------------------------------------------
/convex/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | /* This TypeScript project config describes the environment that
3 | * Convex functions run in and is used to typecheck them.
4 | * You can modify it, but some settings required to use Convex.
5 | */
6 | "compilerOptions": {
7 | /* These settings are not required by Convex and can be modified. */
8 | "allowJs": true,
9 | "strict": true,
10 | "moduleResolution": "Bundler",
11 | "jsx": "react-jsx",
12 | "skipLibCheck": true,
13 | "allowSyntheticDefaultImports": true,
14 |
15 | /* These compiler options are required by Convex */
16 | "target": "ESNext",
17 | "lib": ["ES2021", "dom"],
18 | "forceConsistentCasingInFileNames": true,
19 | "module": "ESNext",
20 | "isolatedModules": true,
21 | "noEmit": true
22 | },
23 | "include": ["./**/*"],
24 | "exclude": ["./_generated"]
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/src/hooks/useComponentVisible.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef } from "react";
2 |
3 | interface ComponentVisibleHook {
4 | ref: React.RefObject;
5 | isComponentVisible: boolean;
6 | setIsComponentVisible: React.Dispatch>;
7 | }
8 |
9 | export default function useComponentVisible(
10 | initialIsVisible: boolean
11 | ): ComponentVisibleHook {
12 | const [isComponentVisible, setIsComponentVisible] =
13 | useState(initialIsVisible);
14 | const ref = useRef(null);
15 |
16 | const handleClickOutside = (event: MouseEvent) => {
17 | if (ref.current && !ref.current.contains(event.target as Node)) {
18 | setIsComponentVisible(false);
19 | }
20 | };
21 |
22 | useEffect(() => {
23 | document.addEventListener("click", handleClickOutside, true);
24 | return () => {
25 | document.removeEventListener("click", handleClickOutside, true);
26 | };
27 | }, []);
28 |
29 | return { ref, isComponentVisible, setIsComponentVisible };
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/home/chat-bubble-avatar.tsx:
--------------------------------------------------------------------------------
1 | import { IMessage } from "@/store/chat-store";
2 | import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
3 |
4 | type ChatBubbleAvatarProps = {
5 | message: IMessage;
6 | isMember: boolean;
7 | isGroup: boolean | undefined;
8 | };
9 |
10 | const ChatBubbleAvatar = ({
11 | isGroup,
12 | isMember,
13 | message,
14 | }: ChatBubbleAvatarProps) => {
15 | return (
16 |
17 | {message.sender.isOnline && isMember && (
18 |
19 | )}
20 |
24 |
25 |
26 |
27 |
28 | );
29 | };
30 | export default ChatBubbleAvatar;
31 |
--------------------------------------------------------------------------------
/convex/schema.ts:
--------------------------------------------------------------------------------
1 | import { defineSchema, defineTable } from "convex/server";
2 | import { v } from "convex/values";
3 |
4 | export default defineSchema({
5 | users: defineTable({
6 | name: v.optional(v.string()),
7 | email: v.string(),
8 | image: v.string(),
9 | tokenIdentifier: v.string(),
10 | isOnline: v.boolean(),
11 | }).index("by_tokenIdentifier", ["tokenIdentifier"]),
12 |
13 | conversations: defineTable({
14 | participants: v.array(v.id("users")),
15 | isGroup: v.boolean(),
16 | groupName: v.optional(v.string()),
17 | groupImage: v.optional(v.string()),
18 | admin: v.optional(v.id("users")),
19 | }),
20 |
21 | messages: defineTable({
22 | conversation: v.id("conversations"),
23 | sender: v.string(), // should be string so that it doesn't throw errors in openai part ("ChatGPT")
24 | content: v.string(),
25 | messageType: v.union(
26 | v.literal("text"),
27 | v.literal("image"),
28 | v.literal("video")
29 | ),
30 | }).index("by_conversation", ["conversation"]),
31 | });
32 |
--------------------------------------------------------------------------------
/src/app/tasks/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useMutation, useQuery } from "convex/react";
3 | import React from "react";
4 | import { api } from "../../../convex/_generated/api";
5 | import { Button } from "@/components/ui/button";
6 |
7 | const TaskPage = () => {
8 | const products = useQuery(api.products.getAllProducts);
9 | const deleteProduct = useMutation(api.products.deleteProduct);
10 |
11 | if (!products) {
12 | return Loading...
;
13 | }
14 |
15 | return (
16 |
17 |
All Products are in Real Time
18 |
19 | {products.map((p) => (
20 |
21 |
{p.name}
22 |
{p.price}
23 |
30 |
31 | ))}
32 |
33 | );
34 | };
35 |
36 | export default TaskPage;
37 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "./globals.css";
4 | import { ThemeProvider } from "@/providers/theme-provider";
5 | import { ConvexClientProvider } from "@/providers/convex-client-provider";
6 | import { Toaster } from "react-hot-toast";
7 |
8 | const inter = Inter({ subsets: ["latin"] });
9 |
10 | export const metadata: Metadata = {
11 | title: "Watts App",
12 | description: "Generated by create next app",
13 | };
14 |
15 | export default function RootLayout({
16 | children,
17 | }: Readonly<{
18 | children: React.ReactNode;
19 | }>) {
20 | return (
21 |
22 |
23 |
29 |
30 | {children}
31 |
32 |
33 |
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/convex/_generated/api.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated `api` utility.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.14.1.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import type {
13 | ApiFromModules,
14 | FilterApi,
15 | FunctionReference,
16 | } from "convex/server";
17 | import type * as clerk from "../clerk.js";
18 | import type * as conversations from "../conversations.js";
19 | import type * as http from "../http.js";
20 | import type * as messages from "../messages.js";
21 | import type * as users from "../users.js";
22 |
23 | /**
24 | * A utility for referencing Convex functions in your app's API.
25 | *
26 | * Usage:
27 | * ```js
28 | * const myFunctionReference = api.myModule.myFunction;
29 | * ```
30 | */
31 | declare const fullApi: ApiFromModules<{
32 | clerk: typeof clerk;
33 | conversations: typeof conversations;
34 | http: typeof http;
35 | messages: typeof messages;
36 | users: typeof users;
37 | }>;
38 | export declare const api: FilterApi<
39 | typeof fullApi,
40 | FunctionReference
41 | >;
42 | export declare const internal: FilterApi<
43 | typeof fullApi,
44 | FunctionReference
45 | >;
46 |
--------------------------------------------------------------------------------
/src/components/home/chat-placeholder.tsx:
--------------------------------------------------------------------------------
1 | import { Lock } from "lucide-react";
2 | import Image from "next/image";
3 | import { Button } from "../ui/button";
4 |
5 | const ChatPlaceHolder = () => {
6 | return (
7 |
8 |
9 |
10 |
11 | Download WhatsApp for Windows
12 |
13 |
14 | Make calls, share your screen and get a faster experience when you
15 | download the Windows app.
16 |
17 |
18 |
21 |
22 |
23 | Your personal messages are end-to-end encrypted
24 |
25 |
26 | );
27 | };
28 | export default ChatPlaceHolder;
29 |
--------------------------------------------------------------------------------
/src/store/chat-store.ts:
--------------------------------------------------------------------------------
1 | import { Id } from "../../convex/_generated/dataModel";
2 | import { create } from "zustand";
3 |
4 | export type Conversation = {
5 | _id: Id<"conversations">;
6 | image?: string;
7 | participants: Id<"users">[];
8 | isGroup: boolean;
9 | name?: string;
10 | groupImage?: string;
11 | groupName?: string;
12 | admin?: Id<"users">;
13 | isOnline?: boolean;
14 | lastMessage?: {
15 | _id: Id<"messages">;
16 | conversation: Id<"conversations">;
17 | content: string;
18 | sender: Id<"users">;
19 | };
20 | };
21 |
22 | type ConversationStore = {
23 | selectedConversation: Conversation | null;
24 | setSelectedConversation: (conversation: Conversation | null) => void;
25 | };
26 |
27 | export const useConversationStore = create((set) => ({
28 | selectedConversation: null,
29 | setSelectedConversation: (conversation) =>
30 | set({ selectedConversation: conversation }),
31 | }));
32 |
33 | export interface IMessage {
34 | _id: string;
35 | content: string;
36 | _creationTime: number;
37 | messageType: "text" | "image" | "video";
38 | sender: {
39 | _id: Id<"users">;
40 | image: string;
41 | name?: string;
42 | tokenIdentifier: string;
43 | email: string;
44 | _creationTime: number;
45 | isOnline: boolean;
46 | };
47 | }
48 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/home/message-container.tsx:
--------------------------------------------------------------------------------
1 | import ChatBubble from "./chat-bubble";
2 | import { useQuery } from "convex/react";
3 | import { api } from "../../../convex/_generated/api";
4 | import { useConversationStore } from "@/store/chat-store";
5 | import { useEffect, useRef } from "react";
6 |
7 | const MessageContainer = () => {
8 | const { selectedConversation } = useConversationStore();
9 | const messages = useQuery(api.messages.getMessages, {
10 | conversation: selectedConversation!._id,
11 | });
12 | const me = useQuery(api.users.getMe);
13 | const lastMessageRef = useRef(null);
14 |
15 | useEffect(() => {
16 | setTimeout(() => {
17 | lastMessageRef.current?.scrollIntoView({ behavior: "smooth" });
18 | }, 100);
19 | }, [messages]);
20 |
21 | return (
22 |
23 |
24 | {messages?.map((msg, idx) => (
25 |
26 | 0 ? messages[idx - 1] : undefined}
30 | />
31 |
32 | ))}
33 |
34 |
35 | );
36 | };
37 | export default MessageContainer;
38 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "wattsapp",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@clerk/nextjs": "^4.29.9",
13 | "@radix-ui/react-avatar": "^1.0.4",
14 | "@radix-ui/react-dialog": "^1.0.5",
15 | "@radix-ui/react-dropdown-menu": "^2.0.6",
16 | "@radix-ui/react-icons": "^1.3.0",
17 | "@radix-ui/react-slot": "^1.0.2",
18 | "@zegocloud/zego-uikit-prebuilt": "^2.0.1",
19 | "class-variance-authority": "^0.7.0",
20 | "clsx": "^2.1.0",
21 | "convex": "^1.10.0",
22 | "emoji-picker-react": "^4.9.2",
23 | "lucide-react": "^0.363.0",
24 | "next": "14.1.4",
25 | "next-themes": "^0.3.0",
26 | "openai": "^4.32.0",
27 | "react": "^18",
28 | "react-dom": "^18",
29 | "react-hot-toast": "^2.4.1",
30 | "react-player": "^2.15.1",
31 | "svix": "^1.21.0",
32 | "tailwind-merge": "^2.2.2",
33 | "tailwindcss-animate": "^1.0.7",
34 | "update": "^0.4.2",
35 | "zustand": "^4.5.2"
36 | },
37 | "devDependencies": {
38 | "@types/node": "^20",
39 | "@types/react": "^18",
40 | "@types/react-dom": "^18",
41 | "autoprefixer": "^10.0.1",
42 | "eslint": "^8",
43 | "eslint-config-next": "14.1.4",
44 | "postcss": "^8",
45 | "tailwindcss": "^3.3.0",
46 | "typescript": "^5"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/theme-switch.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Button } from "@/components/ui/button";
3 | import {
4 | DropdownMenu,
5 | DropdownMenuContent,
6 | DropdownMenuItem,
7 | DropdownMenuTrigger,
8 | } from "@/components/ui/dropdown-menu";
9 | import { useTheme } from "next-themes";
10 | import { MoonIcon, SunIcon } from "@radix-ui/react-icons";
11 |
12 | const ThemeSwitch = () => {
13 | const { setTheme } = useTheme();
14 |
15 | return (
16 |
17 |
18 |
23 |
24 |
25 | setTheme("light")}>
26 | Light
27 |
28 | setTheme("dark")}>
29 | Dark
30 |
31 | setTheme("system")}>
32 | System
33 |
34 |
35 |
36 | );
37 | };
38 | export default ThemeSwitch;
39 |
--------------------------------------------------------------------------------
/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | Avatar.displayName = AvatarPrimitive.Root.displayName
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ))
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49 |
50 | export { Avatar, AvatarImage, AvatarFallback }
51 |
--------------------------------------------------------------------------------
/convex/_generated/dataModel.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated data model types.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.14.1.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import type {
13 | DataModelFromSchemaDefinition,
14 | DocumentByName,
15 | TableNamesInDataModel,
16 | SystemTableNames,
17 | } from "convex/server";
18 | import type { GenericId } from "convex/values";
19 | import schema from "../schema.js";
20 |
21 | /**
22 | * The names of all of your Convex tables.
23 | */
24 | export type TableNames = TableNamesInDataModel;
25 |
26 | /**
27 | * The type of a document stored in Convex.
28 | *
29 | * @typeParam TableName - A string literal type of the table name (like "users").
30 | */
31 | export type Doc = DocumentByName<
32 | DataModel,
33 | TableName
34 | >;
35 |
36 | /**
37 | * An identifier for a document in Convex.
38 | *
39 | * Convex documents are uniquely identified by their `Id`, which is accessible
40 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
41 | *
42 | * Documents can be loaded using `db.get(id)` in query and mutation functions.
43 | *
44 | * IDs are just strings at runtime, but this type can be used to distinguish them from other
45 | * strings when type checking.
46 | *
47 | * @typeParam TableName - A string literal type of the table name (like "users").
48 | */
49 | export type Id =
50 | GenericId;
51 |
52 | /**
53 | * A type describing your Convex data model.
54 | *
55 | * This type includes information about what tables you have, the type of
56 | * documents stored in those tables, and the indexes defined on them.
57 | *
58 | * This type is used to parameterize methods like `queryGeneric` and
59 | * `mutationGeneric` to make them type-safe.
60 | */
61 | export type DataModel = DataModelFromSchemaDefinition;
62 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | );
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean;
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button";
45 | return (
46 |
51 | );
52 | }
53 | );
54 | Button.displayName = "Button";
55 |
56 | export { Button, buttonVariants };
57 |
--------------------------------------------------------------------------------
/convex/http.ts:
--------------------------------------------------------------------------------
1 | import { httpRouter } from "convex/server";
2 | import { httpAction } from "./_generated/server";
3 | import { internal } from "./_generated/api";
4 |
5 | const http = httpRouter();
6 |
7 | http.route({
8 | path: "/clerk",
9 | method: "POST",
10 | handler: httpAction(async (ctx, req) => {
11 | const payloadString = await req.text();
12 | const headerPayload = req.headers;
13 |
14 | try {
15 | const result = await ctx.runAction(internal.clerk.fulfill, {
16 | payload: payloadString,
17 | headers: {
18 | "svix-id": headerPayload.get("svix-id")!,
19 | "svix-signature": headerPayload.get("svix-signature")!,
20 | "svix-timestamp": headerPayload.get("svix-timestamp")!,
21 | },
22 | });
23 |
24 | switch (result.type) {
25 | case "user.created":
26 | await ctx.runMutation(internal.users.createUser, {
27 | tokenIdentifier: `${process.env.CLERK_APP_DOMAIN}|${result.data.id}`,
28 | email: result.data.email_addresses[0]?.email_address,
29 | name: `${result.data.first_name ?? "Guest"} ${result.data.last_name ?? ""}`,
30 | image: result.data.image_url,
31 | });
32 | break;
33 | case "user.updated":
34 | await ctx.runMutation(internal.users.updateUser, {
35 | tokenIdentifier: `${process.env.CLERK_APP_DOMAIN}|${result.data.id}`,
36 | image: result.data.image_url,
37 | });
38 | break;
39 | case "session.created":
40 | await ctx.runMutation(internal.users.setUserOnline, {
41 | tokenIdentifier: `${process.env.CLERK_APP_DOMAIN}|${result.data.user_id}`,
42 | });
43 | break;
44 | case "session.ended":
45 | await ctx.runMutation(internal.users.setUserOffline, {
46 | tokenIdentifier: `${process.env.CLERK_APP_DOMAIN}|${result.data.user_id}`,
47 | });
48 | break;
49 | }
50 |
51 | return new Response(null, {
52 | status: 200,
53 | });
54 | } catch (error) {
55 | console.log("Webhook Error🔥🔥", error);
56 | return new Response("Webhook Error", {
57 | status: 400,
58 | });
59 | }
60 | }),
61 | });
62 |
63 | export default http;
64 |
--------------------------------------------------------------------------------
/src/components/home/right-panel.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
3 | import { Video, X } from "lucide-react";
4 | import MessageInput from "./message-input";
5 | import MessageContainer from "./message-container";
6 | import ChatPlaceHolder from "@/components/home/chat-placeholder";
7 | import GroupMembersDialog from "./group-members-dialog";
8 | import { useConversationStore } from "@/store/chat-store";
9 | import { useConvexAuth } from "convex/react";
10 | import Link from "next/link";
11 |
12 | const RightPanel = () => {
13 | const { selectedConversation, setSelectedConversation } =
14 | useConversationStore();
15 | const { isLoading } = useConvexAuth();
16 |
17 | if (isLoading) return null;
18 | if (!selectedConversation) return ;
19 |
20 | const conversationName =
21 | selectedConversation.groupName || selectedConversation.name;
22 | const conversationImage =
23 | selectedConversation.groupImage || selectedConversation.image;
24 |
25 | return (
26 |
27 |
28 | {/* Header */}
29 |
30 |
31 |
32 |
36 |
37 |
38 |
39 |
40 |
41 |
{conversationName}
42 | {selectedConversation.isGroup && (
43 |
46 | )}
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | setSelectedConversation(null)}
58 | />
59 |
60 |
61 |
62 | {/* CHAT MESSAGES */}
63 |
64 |
65 | {/* INPUT */}
66 |
67 |
68 | );
69 | };
70 | export default RightPanel;
71 |
--------------------------------------------------------------------------------
/src/components/home/chat-avatar-actions.tsx:
--------------------------------------------------------------------------------
1 | import { IMessage, useConversationStore } from "@/store/chat-store";
2 | import { useMutation } from "convex/react";
3 | import { Ban, LogOut } from "lucide-react";
4 | import toast from "react-hot-toast";
5 | import { api } from "../../../convex/_generated/api";
6 | import React from "react";
7 |
8 | type ChatAvatarActionsProps = {
9 | message: IMessage;
10 | me: any;
11 | };
12 |
13 | const ChatAvatarActions = ({ me, message }: ChatAvatarActionsProps) => {
14 | const { selectedConversation, setSelectedConversation } =
15 | useConversationStore();
16 |
17 | const isMember = selectedConversation?.participants.includes(
18 | message.sender._id
19 | );
20 | const kickUser = useMutation(api.conversations.kickUser);
21 | const createConversation = useMutation(api.conversations.createConversation);
22 |
23 | const isGroup = selectedConversation?.isGroup;
24 |
25 | const handleKickUser = async (e: React.MouseEvent) => {
26 | e.stopPropagation();
27 | if (!selectedConversation) return;
28 | try {
29 | await kickUser({
30 | conversationId: selectedConversation._id,
31 | userId: message.sender._id,
32 | });
33 |
34 | setSelectedConversation({
35 | ...selectedConversation,
36 | participants: selectedConversation.participants.filter(
37 | (id) => id !== message.sender._id
38 | ),
39 | });
40 | } catch (error) {
41 | toast.error("Failed to kick user");
42 | }
43 | };
44 |
45 | const handleCreateConversation = async () => {
46 | try {
47 | const conversationId = await createConversation({
48 | isGroup: false,
49 | participants: [me._id, message.sender._id],
50 | });
51 |
52 | setSelectedConversation({
53 | _id: conversationId,
54 | name: message.sender.name,
55 | participants: [me._id, message.sender._id],
56 | isGroup: false,
57 | isOnline: message.sender.isOnline,
58 | image: message.sender.image,
59 | });
60 | } catch (error) {
61 | toast.error("Failed to create conversation");
62 | }
63 | };
64 |
65 | return (
66 |
70 | {isGroup && message.sender.name}
71 | {isGroup && isMember && selectedConversation?.admin === me._id && (
72 |
77 | )}
78 |
79 | );
80 | };
81 | export default ChatAvatarActions;
82 |
--------------------------------------------------------------------------------
/src/components/home/group-members-dialog.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Dialog,
3 | DialogContent,
4 | DialogDescription,
5 | DialogHeader,
6 | DialogTitle,
7 | DialogTrigger,
8 | } from "@/components/ui/dialog";
9 | import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
10 | import { Crown } from "lucide-react";
11 | import { Conversation } from "@/store/chat-store";
12 | import { useQuery } from "convex/react";
13 | import { api } from "../../../convex/_generated/api";
14 |
15 | type GroupMembersDialogProps = {
16 | selectedConversation: Conversation;
17 | };
18 |
19 | const GroupMembersDialog = ({
20 | selectedConversation,
21 | }: GroupMembersDialogProps) => {
22 | const users = useQuery(api.users.getGroupMembers, {
23 | conversationId: selectedConversation._id,
24 | });
25 | return (
26 |
71 | );
72 | };
73 | export default GroupMembersDialog;
74 |
--------------------------------------------------------------------------------
/convex/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to your Convex functions directory!
2 |
3 | Write your Convex functions here.
4 | See https://docs.convex.dev/functions for more.
5 |
6 | A query function that takes two arguments looks like:
7 |
8 | ```ts
9 | // functions.js
10 | import { query } from "./_generated/server";
11 | import { v } from "convex/values";
12 |
13 | export const myQueryFunction = query({
14 | // Validators for arguments.
15 | args: {
16 | first: v.number(),
17 | second: v.string(),
18 | },
19 |
20 | // Function implementation.
21 | handler: async (ctx, args) => {
22 | // Read the database as many times as you need here.
23 | // See https://docs.convex.dev/database/reading-data.
24 | const documents = await ctx.db.query("tablename").collect();
25 |
26 | // Arguments passed from the client are properties of the args object.
27 | console.log(args.first, args.second);
28 |
29 | // Write arbitrary JavaScript here: filter, aggregate, build derived data,
30 | // remove non-public properties, or create new objects.
31 | return documents;
32 | },
33 | });
34 | ```
35 |
36 | Using this query function in a React component looks like:
37 |
38 | ```ts
39 | const data = useQuery(api.functions.myQueryFunction, {
40 | first: 10,
41 | second: "hello",
42 | });
43 | ```
44 |
45 | A mutation function looks like:
46 |
47 | ```ts
48 | // functions.js
49 | import { mutation } from "./_generated/server";
50 | import { v } from "convex/values";
51 |
52 | export const myMutationFunction = mutation({
53 | // Validators for arguments.
54 | args: {
55 | first: v.string(),
56 | second: v.string(),
57 | },
58 |
59 | // Function implementation.
60 | handler: async (ctx, args) => {
61 | // Insert or modify documents in the database here.
62 | // Mutations can also read from the database like queries.
63 | // See https://docs.convex.dev/database/writing-data.
64 | const message = { body: args.first, author: args.second };
65 | const id = await ctx.db.insert("messages", message);
66 |
67 | // Optionally, return a value from your mutation.
68 | return await ctx.db.get(id);
69 | },
70 | });
71 | ```
72 |
73 | Using this mutation function in a React component looks like:
74 |
75 | ```ts
76 | const mutation = useMutation(api.functions.myMutationFunction);
77 | function handleButtonPress() {
78 | // fire and forget, the most common way to use mutations
79 | mutation({ first: "Hello!", second: "me" });
80 | // OR
81 | // use the result once the mutation has completed
82 | mutation({ first: "Hello!", second: "me" }).then((result) =>
83 | console.log(result),
84 | );
85 | }
86 | ```
87 |
88 | Use the Convex CLI to push your functions to a deployment. See everything
89 | the Convex CLI can do by running `npx convex -h` in your project root
90 | directory. To learn more, launch the docs with `npx convex docs`.
91 |
--------------------------------------------------------------------------------
/src/components/home/conversation.tsx:
--------------------------------------------------------------------------------
1 | import { formatDate } from "@/lib/utils";
2 | import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
3 | import { MessageSeenSvg } from "@/lib/svgs";
4 | import { ImageIcon, Users, VideoIcon } from "lucide-react";
5 | import { useQuery } from "convex/react";
6 | import { api } from "../../../convex/_generated/api";
7 | import { useConversationStore } from "@/store/chat-store";
8 |
9 | const Conversation = ({ conversation }: { conversation: any }) => {
10 | const conversationImage = conversation.groupImage || conversation.image;
11 | const conversationName = conversation.groupName || conversation.name;
12 | const lastMessage = conversation.lastMessage;
13 | const lastMessageType = lastMessage?.messageType;
14 | const me = useQuery(api.users.getMe);
15 |
16 | const { setSelectedConversation, selectedConversation } =
17 | useConversationStore();
18 | const activeBgClass = selectedConversation?._id === conversation._id;
19 |
20 | return (
21 | <>
22 | setSelectedConversation(conversation)}
27 | >
28 |
29 | {conversation.isOnline && (
30 |
31 | )}
32 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
{conversationName}
43 |
44 | {formatDate(
45 | lastMessage?._creationTime || conversation._creationTime
46 | )}
47 |
48 |
49 |
50 | {lastMessage?.sender === me?._id ? : ""}
51 | {conversation.isGroup && }
52 | {!lastMessage && "Say Hi!"}
53 | {lastMessageType === "text" ? (
54 | lastMessage?.content.length > 30 ? (
55 | {lastMessage?.content.slice(0, 30)}...
56 | ) : (
57 | {lastMessage?.content}
58 | )
59 | ) : null}
60 | {lastMessageType === "image" && }
61 | {lastMessageType === "video" && }
62 |
63 |
64 |
65 |
66 | >
67 | );
68 | };
69 | export default Conversation;
70 |
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 48, 8%, 88%; /* CHANGED */
8 | --foreground: 222.2 84% 4.9%;
9 |
10 | /* CLASSES ADDED BY US */
11 | --container: 0 0 100%;
12 | --left-panel: 203, 32%, 10%;
13 |
14 | --gray-primary: 216, 20%, 95%;
15 | --gray-secondary: 216, 20%, 95%;
16 |
17 | --left-panel: 100, 100%, 100%;
18 | --chat-hover: 180, 5%, 96%;
19 |
20 | --green-primary: 167, 100%, 33%;
21 | --green-chat: 111, 91%, 91%;
22 | /* CLASSES ADDED BY US */
23 |
24 | --card: 0 0% 100%;
25 | --card-foreground: 222.2 84% 4.9%;
26 |
27 | --popover: 0 0% 100%;
28 | --popover-foreground: 222.2 84% 4.9%;
29 |
30 | --primary: 222.2 47.4% 11.2%;
31 | --primary-foreground: 210 40% 98%;
32 |
33 | --secondary: 210 40% 96.1%;
34 | --secondary-foreground: 222.2 47.4% 11.2%;
35 |
36 | --muted: 210 40% 96.1%;
37 | --muted-foreground: 215.4 16.3% 46.9%;
38 |
39 | --accent: 210 40% 96.1%;
40 | --accent-foreground: 222.2 47.4% 11.2%;
41 |
42 | --destructive: 0 84.2% 60.2%;
43 | --destructive-foreground: 210 40% 98%;
44 |
45 | --border: 214.3 31.8% 91.4%;
46 | --input: 214.3 31.8% 91.4%;
47 | --ring: 222.2 84% 4.9%;
48 |
49 | --radius: 0.5rem;
50 | }
51 |
52 | .dark {
53 | --background: 202, 31%, 7%; /* CHANGED */
54 | --foreground: 210 40% 98%;
55 |
56 | /* CLASSES ADDED BY US: */
57 | --container: 202, 31%, 7%;
58 |
59 | --gray-primary: 202, 23%, 16%;
60 | --gray-secondary: 202, 22%, 17%;
61 |
62 | --left-panel: 203, 32%, 10%;
63 | --chat-hover: 202, 23%, 16%;
64 |
65 | --green-primary: 167, 100%, 33%;
66 | --green-secondary: 165, 100%, 39%;
67 | --green-chat: 169, 100%, 18%;
68 |
69 | --gray-tertiary: 203, 22%, 21%;
70 | /* CLASSES ADDED BY US */
71 |
72 | --card: 222.2 84% 4.9%;
73 | --card-foreground: 210 40% 98%;
74 |
75 | --popover: 222.2 84% 4.9%;
76 | --popover-foreground: 210 40% 98%;
77 |
78 | --primary: 210 40% 98%;
79 | --primary-foreground: 222.2 47.4% 11.2%;
80 |
81 | --secondary: 217.2 32.6% 17.5%;
82 | --secondary-foreground: 210 40% 98%;
83 |
84 | --muted: 217.2 32.6% 17.5%;
85 | --muted-foreground: 215 20.2% 65.1%;
86 |
87 | --accent: 217.2 32.6% 17.5%;
88 | --accent-foreground: 210 40% 98%;
89 |
90 | --destructive: 0 62.8% 30.6%;
91 | --destructive-foreground: 210 40% 98%;
92 |
93 | --border: 217.2 32.6% 17.5%;
94 | --input: 217.2 32.6% 17.5%;
95 | --ring: 212.7 26.8% 83.9%;
96 | }
97 | }
98 |
99 | @layer base {
100 | * {
101 | @apply border-border;
102 | }
103 | body {
104 | @apply bg-background text-foreground;
105 | }
106 | }
107 |
108 | /* WE ADDED => DARK MODE THIN SCROLLBAR */
109 | @layer components {
110 | ::-webkit-scrollbar {
111 | width: 8px;
112 | }
113 | ::-webkit-scrollbar-thumb {
114 | background-color: hsl(var(--gray-primary));
115 | border-radius: 4px;
116 | }
117 | ::-webkit-scrollbar-track {
118 | background-color: hsl(var(--container));
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/src/dummy/db.ts:
--------------------------------------------------------------------------------
1 | export const conversations = [
2 | {
3 | _id: "1",
4 | admin: "user1",
5 | groupImage: null,
6 | groupName: "Group A",
7 | participants: ["user1", "user2", "user3"],
8 | _creationTime: 1638232272, // Unix timestamp for 2021-11-30 12:04:32 UTC
9 | lastMessage: {
10 | _id: "1",
11 | messageType: "text",
12 | content: "Hello everyone!",
13 | sender: "user1",
14 | },
15 | sender: "user1",
16 | isOnline: true,
17 | },
18 | {
19 | _id: "2",
20 | admin: null,
21 | groupImage: "https://avatars.githubusercontent.com/u/75279146?v=4",
22 | groupName: null,
23 | participants: ["user4", "user5"],
24 | _creationTime: 1638235872, // Unix timestamp for 2021-11-30 13:04:32 UTC
25 | lastMessage: {
26 | _id: "2",
27 | messageType: "text",
28 | content: "Hey there!",
29 | sender: "user2",
30 | },
31 | sender: "user4",
32 | isOnline: true,
33 | },
34 | {
35 | _id: "3",
36 | admin: null,
37 | groupImage: null,
38 | groupName: null,
39 | participants: ["user6", "user7"],
40 | _creationTime: 1638239472, // Unix timestamp for 2021-11-30 14:04:32 UTC
41 | lastMessage: {
42 | _id: "3",
43 | messageType: "image",
44 | content: "image_url.jpg",
45 | sender: "user6",
46 | },
47 | sender: "user6",
48 | isOnline: false,
49 | },
50 | {
51 | _id: "4",
52 | admin: null,
53 | groupImage:
54 | "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/React-icon.svg/2300px-React-icon.svg.png",
55 | groupName: null,
56 | participants: ["user8", "user9", "user10"],
57 | _creationTime: 1638243072, // Unix timestamp for 2021-11-30 15:04:32 UTC
58 | lastMessage: {
59 | _id: "4",
60 | messageType: "video",
61 | content: "video_url.mp4",
62 | sender: "user9",
63 | },
64 | sender: "user9",
65 | isOnline: true,
66 | },
67 | ];
68 |
69 | export const messages = [
70 | {
71 | _id: "1",
72 | content: "Hello everyone!",
73 | sender: "user1",
74 | messageType: "text",
75 | },
76 | {
77 | _id: "2",
78 | content: "Hey there!",
79 | sender: "user2",
80 | messageType: "text",
81 | },
82 | {
83 | _id: "3",
84 | content: "How's it going!?",
85 | sender: "user1",
86 | messageType: "text",
87 | },
88 | {
89 | _id: "4",
90 | content: "Fine, thanks!",
91 | sender: "user2",
92 | messageType: "text",
93 | },
94 | ];
95 |
96 | export const users = [
97 | {
98 | _id: "user1",
99 | name: "John Doe",
100 | email: "johndoe@email.com",
101 | image: "https://randomuser.me/api/portraits/men/67.jpg",
102 | admin: true,
103 | isOnline: true,
104 | },
105 | {
106 | _id: "user2",
107 | name: "Jane Doe",
108 | email: "janedoe@email.com",
109 | image: "https://randomuser.me/api/portraits/women/67.jpg",
110 | isOnline: true,
111 | },
112 | {
113 | _id: "user3",
114 | name: "Alice",
115 | email: "alice@email.com",
116 | image: "https://randomuser.me/api/portraits/women/68.jpg",
117 | isOnline: false,
118 | },
119 | ];
120 |
--------------------------------------------------------------------------------
/src/components/home/left-panel.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { ListFilter, Search } from "lucide-react";
3 | import { Input } from "../ui/input";
4 |
5 | import Conversation from "./conversation";
6 | import { UserButton } from "@clerk/nextjs";
7 |
8 | import { useConvexAuth, useQuery } from "convex/react";
9 | import { api } from "../../../convex/_generated/api";
10 | import { useEffect } from "react";
11 | import { useConversationStore } from "@/store/chat-store";
12 | import ThemeSwitch from "../theme-switch";
13 | import UserListDialog from "./user-list-dialog";
14 |
15 | const LeftPanel = () => {
16 | const { isAuthenticated, isLoading } = useConvexAuth();
17 | const conversations = useQuery(
18 | api.conversations.getMyConversations,
19 | isAuthenticated ? undefined : "skip"
20 | );
21 |
22 | const { selectedConversation, setSelectedConversation } =
23 | useConversationStore();
24 |
25 | useEffect(() => {
26 | const conversationIds = conversations?.map(
27 | (conversation) => conversation._id
28 | );
29 | if (
30 | selectedConversation &&
31 | conversationIds &&
32 | !conversationIds.includes(selectedConversation._id)
33 | ) {
34 | setSelectedConversation(null);
35 | }
36 | }, [conversations, selectedConversation, setSelectedConversation]);
37 |
38 | if (isLoading) return null;
39 |
40 | return (
41 |
42 |
43 | {/* Header */}
44 |
45 |
46 |
47 |
48 | {isAuthenticated && }
49 |
50 |
51 |
52 |
53 | {/* Search */}
54 |
55 |
59 |
64 |
65 |
66 |
67 |
68 |
69 | {/* Chat List */}
70 |
71 | {/* Conversations will go here*/}
72 | {conversations?.map((conversation) => (
73 |
74 | ))}
75 |
76 | {conversations?.length === 0 && (
77 | <>
78 |
79 | No conversations yet
80 |
81 |
82 | We understand {"you're"} an introvert, but {"you've"} got to start
83 | somewhere 😊
84 |
85 | >
86 | )}
87 |
88 |
89 | );
90 | };
91 | export default LeftPanel;
92 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | "./pages/**/*.{ts,tsx}",
7 | "./components/**/*.{ts,tsx}",
8 | "./app/**/*.{ts,tsx}",
9 | "./src/**/*.{ts,tsx}",
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | border: "hsl(var(--border))",
23 | input: "hsl(var(--input))",
24 | ring: "hsl(var(--ring))",
25 | background: "hsl(var(--background))",
26 | foreground: "hsl(var(--foreground))",
27 | primary: {
28 | DEFAULT: "hsl(var(--primary))",
29 | foreground: "hsl(var(--primary-foreground))",
30 | },
31 | secondary: {
32 | DEFAULT: "hsl(var(--secondary))",
33 | foreground: "hsl(var(--secondary-foreground))",
34 | },
35 | destructive: {
36 | DEFAULT: "hsl(var(--destructive))",
37 | foreground: "hsl(var(--destructive-foreground))",
38 | },
39 | muted: {
40 | DEFAULT: "hsl(var(--muted))",
41 | foreground: "hsl(var(--muted-foreground))",
42 | },
43 | accent: {
44 | DEFAULT: "hsl(var(--accent))",
45 | foreground: "hsl(var(--accent-foreground))",
46 | },
47 | popover: {
48 | DEFAULT: "hsl(var(--popover))",
49 | foreground: "hsl(var(--popover-foreground))",
50 | },
51 | card: {
52 | DEFAULT: "hsl(var(--card))",
53 | foreground: "hsl(var(--card-foreground))",
54 | },
55 | },
56 | // Other objects...
57 |
58 | backgroundColor: {
59 | container: "hsl(var(--container))",
60 | "gray-primary": "hsl(var(--gray-primary))",
61 | "gray-secondary": "hsl(var(--gray-secondary))",
62 | "gray-tertiary": "hsl(var(--gray-tertiary))",
63 | "left-panel": "hsl(var(--left-panel))",
64 | "chat-hover": "hsl(var(--chat-hover))",
65 | "green-primary": "hsl(var(--green-primary))",
66 | "green-secondary": "hsl(var(--green-secondary))",
67 | "green-chat": "hsl(var(--green-chat))",
68 | },
69 | backgroundImage: {
70 | "chat-tile-light": "url('/bg-light.png')",
71 | "chat-tile-dark": "url('/bg-dark.png')",
72 | },
73 |
74 | // Rest of the file ...
75 | borderRadius: {
76 | lg: "var(--radius)",
77 | md: "calc(var(--radius) - 2px)",
78 | sm: "calc(var(--radius) - 4px)",
79 | },
80 | keyframes: {
81 | "accordion-down": {
82 | from: { height: "0" },
83 | to: { height: "var(--radix-accordion-content-height)" },
84 | },
85 | "accordion-up": {
86 | from: { height: "var(--radix-accordion-content-height)" },
87 | to: { height: "0" },
88 | },
89 | },
90 | animation: {
91 | "accordion-down": "accordion-down 0.2s ease-out",
92 | "accordion-up": "accordion-up 0.2s ease-out",
93 | },
94 | },
95 | },
96 | plugins: [require("tailwindcss-animate")],
97 | } satisfies Config;
98 |
99 | export default config;
100 |
--------------------------------------------------------------------------------
/src/components/home/message-input.tsx:
--------------------------------------------------------------------------------
1 | import { Laugh, Mic, Plus, Send } from "lucide-react";
2 | import { Input } from "../ui/input";
3 | import { useState } from "react";
4 | import { Button } from "../ui/button";
5 | import { useMutation, useQuery } from "convex/react";
6 | import { api } from "../../../convex/_generated/api";
7 | import { useConversationStore } from "@/store/chat-store";
8 | import toast from "react-hot-toast";
9 | import EmojiPicker, { Theme } from "emoji-picker-react";
10 |
11 | import useComponentVisible from "@/hooks/useComponentVisible";
12 | import MediaDropdown from "./media-dropdown";
13 |
14 | const MessageInput = () => {
15 | const [msgText, setMsgText] = useState("");
16 | const { selectedConversation } = useConversationStore();
17 | const { ref, isComponentVisible, setIsComponentVisible } =
18 | useComponentVisible(false);
19 |
20 | const me = useQuery(api.users.getMe);
21 | const sendTextMsg = useMutation(api.messages.sendTextMessage);
22 |
23 | const handleSendTextMsg = async (e: React.FormEvent) => {
24 | e.preventDefault();
25 | try {
26 | await sendTextMsg({
27 | content: msgText,
28 | conversation: selectedConversation!._id,
29 | sender: me!._id,
30 | });
31 | setMsgText("");
32 | } catch (err: any) {
33 | toast.error(err.message);
34 | console.error(err);
35 | }
36 | };
37 |
38 | return (
39 |
40 |
41 | {/* EMOJI PICKER WILL GO HERE */}
42 |
setIsComponentVisible(true)}>
43 | {isComponentVisible && (
44 | {
47 | setMsgText((prev) => prev + emojiObject.emoji);
48 | }}
49 | style={{
50 | position: "absolute",
51 | bottom: "1.5rem",
52 | left: "1rem",
53 | zIndex: 50,
54 | }}
55 | />
56 | )}
57 |
58 |
59 |
60 |
61 |
91 |
92 | );
93 | };
94 | export default MessageInput;
95 |
--------------------------------------------------------------------------------
/convex/_generated/server.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated utilities for implementing server-side Convex query and mutation functions.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.14.1.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import {
13 | actionGeneric,
14 | httpActionGeneric,
15 | queryGeneric,
16 | mutationGeneric,
17 | internalActionGeneric,
18 | internalMutationGeneric,
19 | internalQueryGeneric,
20 | } from "convex/server";
21 |
22 | /**
23 | * Define a query in this Convex app's public API.
24 | *
25 | * This function will be allowed to read your Convex database and will be accessible from the client.
26 | *
27 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
28 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
29 | */
30 | export const query = queryGeneric;
31 |
32 | /**
33 | * Define a query that is only accessible from other Convex functions (but not from the client).
34 | *
35 | * This function will be allowed to read from your Convex database. It will not be accessible from the client.
36 | *
37 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
38 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
39 | */
40 | export const internalQuery = internalQueryGeneric;
41 |
42 | /**
43 | * Define a mutation in this Convex app's public API.
44 | *
45 | * This function will be allowed to modify your Convex database and will be accessible from the client.
46 | *
47 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
48 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
49 | */
50 | export const mutation = mutationGeneric;
51 |
52 | /**
53 | * Define a mutation that is only accessible from other Convex functions (but not from the client).
54 | *
55 | * This function will be allowed to modify your Convex database. It will not be accessible from the client.
56 | *
57 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
58 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
59 | */
60 | export const internalMutation = internalMutationGeneric;
61 |
62 | /**
63 | * Define an action in this Convex app's public API.
64 | *
65 | * An action is a function which can execute any JavaScript code, including non-deterministic
66 | * code and code with side-effects, like calling third-party services.
67 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
68 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
69 | *
70 | * @param func - The action. It receives an {@link ActionCtx} as its first argument.
71 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
72 | */
73 | export const action = actionGeneric;
74 |
75 | /**
76 | * Define an action that is only accessible from other Convex functions (but not from the client).
77 | *
78 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
79 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
80 | */
81 | export const internalAction = internalActionGeneric;
82 |
83 | /**
84 | * Define a Convex HTTP action.
85 | *
86 | * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object
87 | * as its second.
88 | * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
89 | */
90 | export const httpAction = httpActionGeneric;
91 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | export function formatDate(date_ms: number) {
9 | // Convert milliseconds to seconds
10 | let date_seconds = date_ms / 1000;
11 |
12 | // Convert to Date object
13 | let date_obj = new Date(date_seconds * 1000);
14 |
15 | // Get current date and time
16 | let current_date = new Date();
17 | current_date.setHours(0, 0, 0, 0); // Set hours, minutes, seconds, and milliseconds to 0
18 | let current_time = current_date.getTime();
19 |
20 | // Get the date part of the provided date
21 | let provided_date = new Date(date_obj);
22 | provided_date.setHours(0, 0, 0, 0); // Set hours, minutes, seconds, and milliseconds to 0
23 |
24 | // Check if it's today
25 | if (provided_date.getTime() === current_time) {
26 | return date_obj.toLocaleTimeString([], {
27 | hour: "2-digit",
28 | minute: "2-digit",
29 | hour12: true,
30 | });
31 | }
32 |
33 | // Check if it's yesterday
34 | let yesterday = new Date();
35 | yesterday.setDate(yesterday.getDate() - 1);
36 | yesterday.setHours(0, 0, 0, 0); // Set hours, minutes, seconds, and milliseconds to 0
37 | if (provided_date.getTime() === yesterday.getTime()) {
38 | return "Yesterday";
39 | }
40 |
41 | // Check if it's a different day of the week
42 | if (provided_date.getDay() < current_date.getDay()) {
43 | let days = [
44 | "Sunday",
45 | "Monday",
46 | "Tuesday",
47 | "Wednesday",
48 | "Thursday",
49 | "Friday",
50 | "Saturday",
51 | ];
52 | return days[provided_date.getDay()];
53 | }
54 |
55 | // If none of the above conditions match, return in a different format
56 | return (
57 | provided_date.getMonth() +
58 | 1 +
59 | "/" +
60 | provided_date.getDate() +
61 | "/" +
62 | provided_date.getFullYear()
63 | );
64 | }
65 |
66 | export const isSameDay = (timestamp1: number, timestamp2: number): boolean => {
67 | const date1 = new Date(timestamp1);
68 | const date2 = new Date(timestamp2);
69 | return (
70 | date1.getFullYear() === date2.getFullYear() &&
71 | date1.getMonth() === date2.getMonth() &&
72 | date1.getDate() === date2.getDate()
73 | );
74 | };
75 |
76 | // Define getRelativeDateTime function
77 | export const getRelativeDateTime = (message: any, previousMessage: any) => {
78 | const today = new Date();
79 | const yesterday = new Date(today);
80 | yesterday.setDate(yesterday.getDate() - 1);
81 | const lastWeek = new Date(today);
82 | lastWeek.setDate(lastWeek.getDate() - 7);
83 |
84 | const messageDate = new Date(message._creationTime);
85 |
86 | if (
87 | !previousMessage ||
88 | !isSameDay(previousMessage._creationTime, messageDate.getTime())
89 | ) {
90 | if (isSameDay(messageDate.getTime(), today.getTime())) {
91 | return "Today";
92 | } else if (isSameDay(messageDate.getTime(), yesterday.getTime())) {
93 | return "Yesterday";
94 | } else if (messageDate.getTime() > lastWeek.getTime()) {
95 | const options: Intl.DateTimeFormatOptions = {
96 | weekday: "long",
97 | };
98 | return messageDate.toLocaleDateString(undefined, options);
99 | } else {
100 | const options: Intl.DateTimeFormatOptions = {
101 | day: "2-digit",
102 | month: "2-digit",
103 | year: "numeric",
104 | };
105 | return messageDate.toLocaleDateString(undefined, options);
106 | }
107 | }
108 | };
109 |
110 | export function randomID(len: number) {
111 | let result = "";
112 | if (result) return result;
113 | var chars = "12345qwertyuiopasdfgh67890jklmnbvcxzMNBVCZXASDQWERTYHGFUIOLKJP",
114 | maxPos = chars.length,
115 | i;
116 | len = len || 5;
117 | for (i = 0; i < len; i++) {
118 | result += chars.charAt(Math.floor(Math.random() * maxPos));
119 | }
120 | return result;
121 | }
122 |
--------------------------------------------------------------------------------
/convex/users.ts:
--------------------------------------------------------------------------------
1 | import { ConvexError, v } from "convex/values";
2 | import { internalMutation, query } from "./_generated/server";
3 |
4 | export const createUser = internalMutation({
5 | args: {
6 | tokenIdentifier: v.string(),
7 | email: v.string(),
8 | name: v.string(),
9 | image: v.string(),
10 | },
11 | handler: async (ctx, args) => {
12 | await ctx.db.insert("users", {
13 | tokenIdentifier: args.tokenIdentifier,
14 | email: args.email,
15 | name: args.name,
16 | image: args.image,
17 | isOnline: true,
18 | });
19 | },
20 | });
21 |
22 | export const updateUser = internalMutation({
23 | args: { tokenIdentifier: v.string(), image: v.string() },
24 | async handler(ctx, args) {
25 | const user = await ctx.db
26 | .query("users")
27 | .withIndex("by_tokenIdentifier", (q) =>
28 | q.eq("tokenIdentifier", args.tokenIdentifier)
29 | )
30 | .unique();
31 |
32 | if (!user) {
33 | throw new ConvexError("User not found");
34 | }
35 |
36 | await ctx.db.patch(user._id, {
37 | image: args.image,
38 | });
39 | },
40 | });
41 |
42 | export const setUserOnline = internalMutation({
43 | args: { tokenIdentifier: v.string() },
44 | handler: async (ctx, args) => {
45 | const user = await ctx.db
46 | .query("users")
47 | .withIndex("by_tokenIdentifier", (q) =>
48 | q.eq("tokenIdentifier", args.tokenIdentifier)
49 | )
50 | .unique();
51 |
52 | if (!user) {
53 | throw new ConvexError("User not found");
54 | }
55 |
56 | await ctx.db.patch(user._id, { isOnline: true });
57 | },
58 | });
59 |
60 | export const setUserOffline = internalMutation({
61 | args: { tokenIdentifier: v.string() },
62 | handler: async (ctx, args) => {
63 | const user = await ctx.db
64 | .query("users")
65 | .withIndex("by_tokenIdentifier", (q) =>
66 | q.eq("tokenIdentifier", args.tokenIdentifier)
67 | )
68 | .unique();
69 |
70 | if (!user) {
71 | throw new ConvexError("User not found");
72 | }
73 |
74 | await ctx.db.patch(user._id, { isOnline: false });
75 | },
76 | });
77 |
78 | export const getUsers = query({
79 | args: {},
80 | handler: async (ctx, args) => {
81 | const identity = await ctx.auth.getUserIdentity();
82 | if (!identity) {
83 | throw new ConvexError("Unauthorized");
84 | }
85 |
86 | const users = await ctx.db.query("users").collect();
87 | return users.filter(
88 | (user) => user.tokenIdentifier !== identity.tokenIdentifier
89 | );
90 | },
91 | });
92 |
93 | export const getMe = query({
94 | args: {},
95 | handler: async (ctx, args) => {
96 | const identity = await ctx.auth.getUserIdentity();
97 | if (!identity) {
98 | throw new ConvexError("Unauthorized");
99 | }
100 |
101 | const user = await ctx.db
102 | .query("users")
103 | .withIndex("by_tokenIdentifier", (q) =>
104 | q.eq("tokenIdentifier", identity.tokenIdentifier)
105 | )
106 | .unique();
107 |
108 | if (!user) {
109 | throw new ConvexError("User not found");
110 | }
111 |
112 | return user;
113 | },
114 | });
115 |
116 | export const getGroupMembers = query({
117 | args: { conversationId: v.id("conversations") },
118 | handler: async (ctx, args) => {
119 | const identity = await ctx.auth.getUserIdentity();
120 |
121 | if (!identity) {
122 | throw new ConvexError("Unauthorized");
123 | }
124 |
125 | const conversation = await ctx.db
126 | .query("conversations")
127 | .filter((q) => q.eq(q.field("_id"), args.conversationId))
128 | .first();
129 | if (!conversation) {
130 | throw new ConvexError("Conversation not found");
131 | }
132 |
133 | const users = await ctx.db.query("users").collect();
134 | const groupMembers = users.filter((user) =>
135 | conversation.participants.includes(user._id)
136 | );
137 |
138 | return groupMembers;
139 | },
140 | });
141 |
--------------------------------------------------------------------------------
/convex/conversations.ts:
--------------------------------------------------------------------------------
1 | import { ConvexError, v } from "convex/values";
2 | import { mutation, query } from "./_generated/server";
3 |
4 | export const createConversation = mutation({
5 | args: {
6 | participants: v.array(v.id("users")),
7 | isGroup: v.boolean(),
8 | groupName: v.optional(v.string()),
9 | groupImage: v.optional(v.id("_storage")),
10 | admin: v.optional(v.id("users")),
11 | },
12 | handler: async (ctx, args) => {
13 | const identity = await ctx.auth.getUserIdentity();
14 | if (!identity) throw new ConvexError("Unauthorized");
15 |
16 | // jane and john
17 | // [jane, john]
18 | // [john, jane]
19 |
20 | const existingConversation = await ctx.db
21 | .query("conversations")
22 | .filter((q) =>
23 | q.or(
24 | q.eq(q.field("participants"), args.participants),
25 | q.eq(q.field("participants"), args.participants.reverse())
26 | )
27 | )
28 | .first();
29 |
30 | if (existingConversation) {
31 | return existingConversation._id;
32 | }
33 |
34 | let groupImage;
35 |
36 | if (args.groupImage) {
37 | groupImage = (await ctx.storage.getUrl(args.groupImage)) as string;
38 | }
39 |
40 | const conversationId = await ctx.db.insert("conversations", {
41 | participants: args.participants,
42 | isGroup: args.isGroup,
43 | groupName: args.groupName,
44 | groupImage,
45 | admin: args.admin,
46 | });
47 |
48 | return conversationId;
49 | },
50 | });
51 |
52 | export const getMyConversations = query({
53 | args: {},
54 | handler: async (ctx, args) => {
55 | const identity = await ctx.auth.getUserIdentity();
56 | if (!identity) throw new ConvexError("Unauthorized");
57 |
58 | const user = await ctx.db
59 | .query("users")
60 | .withIndex("by_tokenIdentifier", (q) =>
61 | q.eq("tokenIdentifier", identity.tokenIdentifier)
62 | )
63 | .unique();
64 |
65 | if (!user) throw new ConvexError("User not found");
66 |
67 | const conversations = await ctx.db.query("conversations").collect();
68 |
69 | const myConversations = conversations.filter((conversation) => {
70 | return conversation.participants.includes(user._id);
71 | });
72 |
73 | const conversationsWithDetails = await Promise.all(
74 | myConversations.map(async (conversation) => {
75 | let userDetails = {};
76 |
77 | if (!conversation.isGroup) {
78 | const otherUserId = conversation.participants.find(
79 | (id) => id !== user._id
80 | );
81 | const userProfile = await ctx.db
82 | .query("users")
83 | .filter((q) => q.eq(q.field("_id"), otherUserId))
84 | .take(1);
85 |
86 | userDetails = userProfile[0];
87 | }
88 |
89 | const lastMessage = await ctx.db
90 | .query("messages")
91 | .filter((q) => q.eq(q.field("conversation"), conversation._id))
92 | .order("desc")
93 | .take(1);
94 |
95 | // return should be in this order, otherwise _id field will be overwritten
96 | return {
97 | ...userDetails,
98 | ...conversation,
99 | lastMessage: lastMessage[0] || null,
100 | };
101 | })
102 | );
103 |
104 | return conversationsWithDetails;
105 | },
106 | });
107 |
108 | export const kickUser = mutation({
109 | args: {
110 | conversationId: v.id("conversations"),
111 | userId: v.id("users"),
112 | },
113 | handler: async (ctx, args) => {
114 | const identity = await ctx.auth.getUserIdentity();
115 | if (!identity) throw new ConvexError("Unauthorized");
116 |
117 | const conversation = await ctx.db
118 | .query("conversations")
119 | .filter((q) => q.eq(q.field("_id"), args.conversationId))
120 | .unique();
121 |
122 | if (!conversation) throw new ConvexError("Conversation not found");
123 |
124 | await ctx.db.patch(args.conversationId, {
125 | participants: conversation.participants.filter(
126 | (id) => id !== args.userId
127 | ),
128 | });
129 | },
130 | });
131 |
132 | export const generateUploadUrl = mutation(async (ctx) => {
133 | return await ctx.storage.generateUploadUrl();
134 | });
135 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as DialogPrimitive from "@radix-ui/react-dialog";
5 | import { X } from "lucide-react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const Dialog = DialogPrimitive.Root;
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger;
12 |
13 | const DialogPortal = DialogPrimitive.Portal;
14 |
15 | const DialogClose = DialogPrimitive.Close;
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ));
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ));
54 | DialogContent.displayName = DialogPrimitive.Content.displayName;
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | );
68 | DialogHeader.displayName = "DialogHeader";
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | );
82 | DialogFooter.displayName = "DialogFooter";
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ));
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ));
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogClose,
116 | DialogTrigger,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | };
123 |
--------------------------------------------------------------------------------
/convex/messages.ts:
--------------------------------------------------------------------------------
1 | import { ConvexError, v } from "convex/values";
2 | import { mutation, query } from "./_generated/server";
3 | import { api } from "./_generated/api";
4 |
5 | export const sendTextMessage = mutation({
6 | args: {
7 | sender: v.string(),
8 | content: v.string(),
9 | conversation: v.id("conversations"),
10 | },
11 | handler: async (ctx, args) => {
12 | const identity = await ctx.auth.getUserIdentity();
13 | if (!identity) {
14 | throw new ConvexError("Not authenticated");
15 | }
16 |
17 | const user = await ctx.db
18 | .query("users")
19 | .withIndex("by_tokenIdentifier", (q) =>
20 | q.eq("tokenIdentifier", identity.tokenIdentifier)
21 | )
22 | .unique();
23 |
24 | if (!user) {
25 | throw new ConvexError("User not found");
26 | }
27 |
28 | const conversation = await ctx.db
29 | .query("conversations")
30 | .filter((q) => q.eq(q.field("_id"), args.conversation))
31 | .first();
32 |
33 | if (!conversation) {
34 | throw new ConvexError("Conversation not found");
35 | }
36 |
37 | if (!conversation.participants.includes(user._id)) {
38 | throw new ConvexError("You are not part of this conversation");
39 | }
40 |
41 | await ctx.db.insert("messages", {
42 | sender: args.sender,
43 | content: args.content,
44 | conversation: args.conversation,
45 | messageType: "text",
46 | });
47 |
48 | // TODO => add @gpt check later
49 | },
50 | });
51 |
52 | export const sendChatGPTMessage = mutation({
53 | args: {
54 | content: v.string(),
55 | conversation: v.id("conversations"),
56 | messageType: v.union(v.literal("text"), v.literal("image")),
57 | },
58 | handler: async (ctx, args) => {
59 | await ctx.db.insert("messages", {
60 | content: args.content,
61 | sender: "ChatGPT",
62 | messageType: args.messageType,
63 | conversation: args.conversation,
64 | });
65 | },
66 | });
67 |
68 | // Optimized
69 | export const getMessages = query({
70 | args: {
71 | conversation: v.id("conversations"),
72 | },
73 | handler: async (ctx, args) => {
74 | const identity = await ctx.auth.getUserIdentity();
75 | if (!identity) {
76 | throw new Error("Unauthorized");
77 | }
78 |
79 | const messages = await ctx.db
80 | .query("messages")
81 | .withIndex("by_conversation", (q) =>
82 | q.eq("conversation", args.conversation)
83 | )
84 | .collect();
85 |
86 | const userProfileCache = new Map();
87 |
88 | const messagesWithSender = await Promise.all(
89 | messages.map(async (message) => {
90 | if (message.sender === "ChatGPT") {
91 | const image =
92 | message.messageType === "text" ? "/gpt.png" : "dall-e.png";
93 | return { ...message, sender: { name: "ChatGPT", image } };
94 | }
95 | let sender;
96 | // Check if sender profile is in cache
97 | if (userProfileCache.has(message.sender)) {
98 | sender = userProfileCache.get(message.sender);
99 | } else {
100 | // Fetch sender profile from the database
101 | sender = await ctx.db
102 | .query("users")
103 | .filter((q) => q.eq(q.field("_id"), message.sender))
104 | .first();
105 | // Cache the sender profile
106 | userProfileCache.set(message.sender, sender);
107 | }
108 |
109 | return { ...message, sender };
110 | })
111 | );
112 |
113 | return messagesWithSender;
114 | },
115 | });
116 |
117 | export const sendImage = mutation({
118 | args: {
119 | imgId: v.id("_storage"),
120 | sender: v.id("users"),
121 | conversation: v.id("conversations"),
122 | },
123 | handler: async (ctx, args) => {
124 | const identity = await ctx.auth.getUserIdentity();
125 | if (!identity) {
126 | throw new ConvexError("Unauthorized");
127 | }
128 |
129 | const content = (await ctx.storage.getUrl(args.imgId)) as string;
130 |
131 | await ctx.db.insert("messages", {
132 | content: content,
133 | sender: args.sender,
134 | messageType: "image",
135 | conversation: args.conversation,
136 | });
137 | },
138 | });
139 |
140 | export const sendVideo = mutation({
141 | args: {
142 | videoId: v.id("_storage"),
143 | sender: v.id("users"),
144 | conversation: v.id("conversations"),
145 | },
146 | handler: async (ctx, args) => {
147 | const identity = await ctx.auth.getUserIdentity();
148 | if (!identity) {
149 | throw new ConvexError("Unauthorized");
150 | }
151 |
152 | const content = (await ctx.storage.getUrl(args.videoId)) as string;
153 |
154 | await ctx.db.insert("messages", {
155 | content: content,
156 | sender: args.sender,
157 | messageType: "video",
158 | conversation: args.conversation,
159 | });
160 | },
161 | });
162 |
163 | // unoptimized
164 |
165 | // export const getMessages = query({
166 | // args:{
167 | // conversation: v.id("conversations"),
168 | // },
169 | // handler: async (ctx, args) => {
170 | // const identity = await ctx.auth.getUserIdentity();
171 | // if (!identity) {
172 | // throw new ConvexError("Not authenticated");
173 | // }
174 |
175 | // const messages = await ctx.db
176 | // .query("messages")
177 | // .withIndex("by_conversation", q=> q.eq("conversation", args.conversation))
178 | // .collect();
179 |
180 | // // john => 200 , 1
181 | // const messagesWithSender = await Promise.all(
182 | // messages.map(async (message) => {
183 | // const sender = await ctx.db
184 | // .query("users")
185 | // .filter(q => q.eq(q.field("_id"), message.sender))
186 | // .first();
187 |
188 | // return {...message,sender}
189 | // })
190 | // )
191 |
192 | // return messagesWithSender;
193 | // }
194 | // });
195 |
--------------------------------------------------------------------------------
/convex/_generated/server.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated utilities for implementing server-side Convex query and mutation functions.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.14.1.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import {
13 | ActionBuilder,
14 | HttpActionBuilder,
15 | MutationBuilder,
16 | QueryBuilder,
17 | GenericActionCtx,
18 | GenericMutationCtx,
19 | GenericQueryCtx,
20 | GenericDatabaseReader,
21 | GenericDatabaseWriter,
22 | } from "convex/server";
23 | import type { DataModel } from "./dataModel.js";
24 |
25 | /**
26 | * Define a query in this Convex app's public API.
27 | *
28 | * This function will be allowed to read your Convex database and will be accessible from the client.
29 | *
30 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
31 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
32 | */
33 | export declare const query: QueryBuilder;
34 |
35 | /**
36 | * Define a query that is only accessible from other Convex functions (but not from the client).
37 | *
38 | * This function will be allowed to read from your Convex database. It will not be accessible from the client.
39 | *
40 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
41 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
42 | */
43 | export declare const internalQuery: QueryBuilder;
44 |
45 | /**
46 | * Define a mutation in this Convex app's public API.
47 | *
48 | * This function will be allowed to modify your Convex database and will be accessible from the client.
49 | *
50 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
51 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
52 | */
53 | export declare const mutation: MutationBuilder;
54 |
55 | /**
56 | * Define a mutation that is only accessible from other Convex functions (but not from the client).
57 | *
58 | * This function will be allowed to modify your Convex database. It will not be accessible from the client.
59 | *
60 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
61 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
62 | */
63 | export declare const internalMutation: MutationBuilder;
64 |
65 | /**
66 | * Define an action in this Convex app's public API.
67 | *
68 | * An action is a function which can execute any JavaScript code, including non-deterministic
69 | * code and code with side-effects, like calling third-party services.
70 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
71 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
72 | *
73 | * @param func - The action. It receives an {@link ActionCtx} as its first argument.
74 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
75 | */
76 | export declare const action: ActionBuilder;
77 |
78 | /**
79 | * Define an action that is only accessible from other Convex functions (but not from the client).
80 | *
81 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
82 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
83 | */
84 | export declare const internalAction: ActionBuilder;
85 |
86 | /**
87 | * Define an HTTP action.
88 | *
89 | * This function will be used to respond to HTTP requests received by a Convex
90 | * deployment if the requests matches the path and method where this action
91 | * is routed. Be sure to route your action in `convex/http.js`.
92 | *
93 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
94 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
95 | */
96 | export declare const httpAction: HttpActionBuilder;
97 |
98 | /**
99 | * A set of services for use within Convex query functions.
100 | *
101 | * The query context is passed as the first argument to any Convex query
102 | * function run on the server.
103 | *
104 | * This differs from the {@link MutationCtx} because all of the services are
105 | * read-only.
106 | */
107 | export type QueryCtx = GenericQueryCtx;
108 |
109 | /**
110 | * A set of services for use within Convex mutation functions.
111 | *
112 | * The mutation context is passed as the first argument to any Convex mutation
113 | * function run on the server.
114 | */
115 | export type MutationCtx = GenericMutationCtx;
116 |
117 | /**
118 | * A set of services for use within Convex action functions.
119 | *
120 | * The action context is passed as the first argument to any Convex action
121 | * function run on the server.
122 | */
123 | export type ActionCtx = GenericActionCtx;
124 |
125 | /**
126 | * An interface to read from the database within Convex query functions.
127 | *
128 | * The two entry points are {@link DatabaseReader.get}, which fetches a single
129 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts
130 | * building a query.
131 | */
132 | export type DatabaseReader = GenericDatabaseReader;
133 |
134 | /**
135 | * An interface to read from and write to the database within Convex mutation
136 | * functions.
137 | *
138 | * Convex guarantees that all writes within a single mutation are
139 | * executed atomically, so you never have to worry about partial writes leaving
140 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
141 | * for the guarantees Convex provides your functions.
142 | */
143 | export type DatabaseWriter = GenericDatabaseWriter;
144 |
--------------------------------------------------------------------------------
/src/components/home/chat-bubble.tsx:
--------------------------------------------------------------------------------
1 | import { MessageSeenSvg } from "@/lib/svgs";
2 | import { IMessage, useConversationStore } from "@/store/chat-store";
3 |
4 | import Image from "next/image";
5 | import { useState } from "react";
6 | import { Dialog, DialogContent, DialogDescription } from "../ui/dialog";
7 | import ReactPlayer from "react-player";
8 |
9 | import { Bot } from "lucide-react";
10 | import ChatBubbleAvatar from "./chat-bubble-avatar";
11 | import ChatAvatarActions from "./chat-avatar-actions";
12 | import DateIndicator from "./date-indicator";
13 |
14 | type ChatBubbleProps = {
15 | message: IMessage;
16 | me: any;
17 | previousMessage?: IMessage;
18 | };
19 |
20 | const ChatBubble = ({ me, message, previousMessage }: ChatBubbleProps) => {
21 | const date = new Date(message._creationTime);
22 | const hour = date.getHours().toString().padStart(2, "0");
23 | const minute = date.getMinutes().toString().padStart(2, "0");
24 | const time = `${hour}:${minute}`;
25 |
26 | const { selectedConversation } = useConversationStore();
27 | const isMember =
28 | selectedConversation?.participants.includes(message.sender?._id) || false;
29 | const isGroup = selectedConversation?.isGroup;
30 | const fromMe = message.sender?._id === me._id;
31 |
32 | const bgClass = fromMe ? "bg-green-chat" : "bg-white dark:bg-gray-primary";
33 |
34 | console.log(message.sender);
35 | const [open, setOpen] = useState(false);
36 |
37 | const renderMessageContent = () => {
38 | switch (message.messageType) {
39 | case "text":
40 | return ;
41 | case "image":
42 | return (
43 | setOpen(true)} />
44 | );
45 | case "video":
46 | return ;
47 | default:
48 | return null;
49 | }
50 | };
51 |
52 | if (!fromMe) {
53 | return (
54 | <>
55 |
56 |
57 |
62 |
65 | {}
66 | {renderMessageContent()}
67 | {open && (
68 | setOpen(false)}
72 | />
73 | )}
74 |
75 |
76 |
77 | >
78 | );
79 | }
80 |
81 | return (
82 | <>
83 |
84 |
85 |
86 |
89 |
90 | {renderMessageContent()}
91 | {open && (
92 | setOpen(false)}
96 | />
97 | )}
98 |
99 |
100 |
101 | >
102 | );
103 | };
104 | export default ChatBubble;
105 |
106 | const VideoMessage = ({ message }: { message: IMessage }) => {
107 | return (
108 |
115 | );
116 | };
117 |
118 | const ImageMessage = ({
119 | message,
120 | handleClick,
121 | }: {
122 | message: IMessage;
123 | handleClick: () => void;
124 | }) => {
125 | return (
126 |
127 |
134 |
135 | );
136 | };
137 |
138 | const ImageDialog = ({
139 | src,
140 | onClose,
141 | open,
142 | }: {
143 | open: boolean;
144 | src: string;
145 | onClose: () => void;
146 | }) => {
147 | return (
148 |
165 | );
166 | };
167 |
168 | const MessageTime = ({ time, fromMe }: { time: string; fromMe: boolean }) => {
169 | return (
170 |
171 | {time} {fromMe && }
172 |
173 | );
174 | };
175 |
176 | const OtherMessageIndicator = () => (
177 |
178 | );
179 |
180 | const SelfMessageIndicator = () => (
181 |
182 | );
183 |
184 | const TextMessage = ({ message }: { message: IMessage }) => {
185 | const isLink = /^(ftp|http|https):\/\/[^ "]+$/.test(message.content); // Check if the content is a URL
186 |
187 | return (
188 |
202 | );
203 | };
204 |
--------------------------------------------------------------------------------
/src/components/home/media-dropdown.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react";
2 | import {
3 | DropdownMenu,
4 | DropdownMenuContent,
5 | DropdownMenuItem,
6 | DropdownMenuTrigger,
7 | } from "../ui/dropdown-menu";
8 | import { ImageIcon, Plus, Video } from "lucide-react";
9 | import { Dialog, DialogContent, DialogDescription } from "../ui/dialog";
10 | import { Button } from "../ui/button";
11 | import Image from "next/image";
12 | import ReactPlayer from "react-player";
13 | import toast from "react-hot-toast";
14 | import { useMutation, useQuery } from "convex/react";
15 | import { api } from "../../../convex/_generated/api";
16 | import { useConversationStore } from "@/store/chat-store";
17 |
18 | const MediaDropdown = () => {
19 | const imageInput = useRef(null);
20 | const videoInput = useRef(null);
21 | const [selectedImage, setSelectedImage] = useState(null);
22 | const [selectedVideo, setSelectedVideo] = useState(null);
23 |
24 | const [isLoading, setIsLoading] = useState(false);
25 |
26 | const generateUploadUrl = useMutation(api.conversations.generateUploadUrl);
27 | const sendImage = useMutation(api.messages.sendImage);
28 | const sendVideo = useMutation(api.messages.sendVideo);
29 | const me = useQuery(api.users.getMe);
30 |
31 | const { selectedConversation } = useConversationStore();
32 |
33 | const handleSendImage = async () => {
34 | setIsLoading(true);
35 | try {
36 | // Step 1: Get a short-lived upload URL
37 | const postUrl = await generateUploadUrl();
38 | // Step 2: POST the file to the URL
39 | const result = await fetch(postUrl, {
40 | method: "POST",
41 | headers: { "Content-Type": selectedImage!.type },
42 | body: selectedImage,
43 | });
44 |
45 | const { storageId } = await result.json();
46 | // Step 3: Save the newly allocated storage id to the database
47 | await sendImage({
48 | conversation: selectedConversation!._id,
49 | imgId: storageId,
50 | sender: me!._id,
51 | });
52 |
53 | setSelectedImage(null);
54 | } catch (err) {
55 | toast.error("Failed to send image");
56 | } finally {
57 | setIsLoading(false);
58 | }
59 | };
60 |
61 | const handleSendVideo = async () => {
62 | setIsLoading(true);
63 | try {
64 | const postUrl = await generateUploadUrl();
65 | const result = await fetch(postUrl, {
66 | method: "POST",
67 | headers: { "Content-Type": selectedVideo!.type },
68 | body: selectedVideo,
69 | });
70 |
71 | const { storageId } = await result.json();
72 |
73 | await sendVideo({
74 | videoId: storageId,
75 | conversation: selectedConversation!._id,
76 | sender: me!._id,
77 | });
78 |
79 | setSelectedVideo(null);
80 | } catch (error) {
81 | } finally {
82 | setIsLoading(false);
83 | }
84 | };
85 |
86 | return (
87 | <>
88 | setSelectedImage(e.target.files![0])}
93 | hidden
94 | />
95 |
96 | setSelectedVideo(e.target?.files![0])}
101 | hidden
102 | />
103 |
104 | {selectedImage && (
105 | setSelectedImage(null)}
108 | selectedImage={selectedImage}
109 | isLoading={isLoading}
110 | handleSendImage={handleSendImage}
111 | />
112 | )}
113 |
114 | {selectedVideo && (
115 | setSelectedVideo(null)}
118 | selectedVideo={selectedVideo}
119 | isLoading={isLoading}
120 | handleSendVideo={handleSendVideo}
121 | />
122 | )}
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 | imageInput.current!.click()}>
131 | Photo
132 |
133 | videoInput.current!.click()}>
134 |
135 | Video
136 |
137 |
138 |
139 | >
140 | );
141 | };
142 | export default MediaDropdown;
143 |
144 | type MediaImageDialogProps = {
145 | isOpen: boolean;
146 | onClose: () => void;
147 | selectedImage: File;
148 | isLoading: boolean;
149 | handleSendImage: () => void;
150 | };
151 |
152 | const MediaImageDialog = ({
153 | isOpen,
154 | onClose,
155 | selectedImage,
156 | isLoading,
157 | handleSendImage,
158 | }: MediaImageDialogProps) => {
159 | const [renderedImage, setRenderedImage] = useState(null);
160 |
161 | useEffect(() => {
162 | if (!selectedImage) return;
163 | const reader = new FileReader();
164 | reader.onload = (e) => setRenderedImage(e.target?.result as string);
165 | reader.readAsDataURL(selectedImage);
166 | }, [selectedImage]);
167 |
168 | return (
169 |
195 | );
196 | };
197 |
198 | type MediaVideoDialogProps = {
199 | isOpen: boolean;
200 | onClose: () => void;
201 | selectedVideo: File;
202 | isLoading: boolean;
203 | handleSendVideo: () => void;
204 | };
205 |
206 | const MediaVideoDialog = ({
207 | isOpen,
208 | onClose,
209 | selectedVideo,
210 | isLoading,
211 | handleSendVideo,
212 | }: MediaVideoDialogProps) => {
213 | const renderedVideo = URL.createObjectURL(
214 | new Blob([selectedVideo], { type: "video/mp4" })
215 | );
216 |
217 | return (
218 |
240 | );
241 | };
242 |
--------------------------------------------------------------------------------
/src/components/home/user-list-dialog.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react";
2 | import Image from "next/image";
3 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
4 | import {
5 | Dialog,
6 | DialogClose,
7 | DialogContent,
8 | DialogDescription,
9 | DialogHeader,
10 | DialogTitle,
11 | DialogTrigger,
12 | } from "@/components/ui/dialog";
13 | import { Input } from "../ui/input";
14 | import { Button } from "../ui/button";
15 | import { ImageIcon, MessageSquareDiff } from "lucide-react";
16 | import { Id } from "../../../convex/_generated/dataModel";
17 | import { useMutation, useQuery } from "convex/react";
18 | import { api } from "../../../convex/_generated/api";
19 | import toast from "react-hot-toast";
20 | import { useConversationStore } from "@/store/chat-store";
21 |
22 | const UserListDialog = () => {
23 | const [selectedUsers, setSelectedUsers] = useState[]>([]);
24 | const [groupName, setGroupName] = useState("");
25 | const [isLoading, setIsLoading] = useState(false);
26 | const [selectedImage, setSelectedImage] = useState(null);
27 | const [renderedImage, setRenderedImage] = useState("");
28 |
29 | const imgRef = useRef(null);
30 | const dialogCloseRef = useRef(null);
31 |
32 | const createConversation = useMutation(api.conversations.createConversation);
33 | const generateUploadUrl = useMutation(api.conversations.generateUploadUrl);
34 | const me = useQuery(api.users.getMe);
35 | const users = useQuery(api.users.getUsers);
36 |
37 | const { setSelectedConversation } = useConversationStore();
38 |
39 | const handleCreateConversation = async () => {
40 | if (selectedUsers.length === 0) return;
41 | setIsLoading(true);
42 | try {
43 | const isGroup = selectedUsers.length > 1;
44 |
45 | let conversationId;
46 | if (!isGroup) {
47 | conversationId = await createConversation({
48 | participants: [...selectedUsers, me?._id!],
49 | isGroup: false,
50 | });
51 | } else {
52 | const postUrl = await generateUploadUrl();
53 |
54 | const result = await fetch(postUrl, {
55 | method: "POST",
56 | headers: { "Content-Type": selectedImage?.type! },
57 | body: selectedImage,
58 | });
59 |
60 | const { storageId } = await result.json();
61 |
62 | conversationId = await createConversation({
63 | participants: [...selectedUsers, me?._id!],
64 | isGroup: true,
65 | admin: me?._id!,
66 | groupName,
67 | groupImage: storageId,
68 | });
69 | }
70 |
71 | dialogCloseRef.current?.click();
72 | setSelectedUsers([]);
73 | setGroupName("");
74 | setSelectedImage(null);
75 |
76 | // TODO => Update a global state called "selectedConversation"
77 | const conversationName = isGroup
78 | ? groupName
79 | : users?.find((user) => user._id === selectedUsers[0])?.name;
80 |
81 | setSelectedConversation({
82 | _id: conversationId,
83 | participants: selectedUsers,
84 | isGroup,
85 | image: isGroup
86 | ? renderedImage
87 | : users?.find((user) => user._id === selectedUsers[0])?.image,
88 | name: conversationName,
89 | admin: me?._id!,
90 | });
91 | } catch (err) {
92 | toast.error("Failed to create conversation");
93 | console.error(err);
94 | } finally {
95 | setIsLoading(false);
96 | }
97 | };
98 |
99 | useEffect(() => {
100 | if (!selectedImage) return setRenderedImage("");
101 | const reader = new FileReader();
102 | reader.onload = (e) => setRenderedImage(e.target?.result as string);
103 | reader.readAsDataURL(selectedImage);
104 | }, [selectedImage]);
105 |
106 | return (
107 |
214 | );
215 | };
216 | export default UserListDialog;
217 |
--------------------------------------------------------------------------------
/src/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import { Check, ChevronRight, Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ))
40 | DropdownMenuSubTrigger.displayName =
41 | DropdownMenuPrimitive.SubTrigger.displayName
42 |
43 | const DropdownMenuSubContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, ...props }, ref) => (
47 |
55 | ))
56 | DropdownMenuSubContent.displayName =
57 | DropdownMenuPrimitive.SubContent.displayName
58 |
59 | const DropdownMenuContent = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, sideOffset = 4, ...props }, ref) => (
63 |
64 |
73 |
74 | ))
75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
76 |
77 | const DropdownMenuItem = React.forwardRef<
78 | React.ElementRef,
79 | React.ComponentPropsWithoutRef & {
80 | inset?: boolean
81 | }
82 | >(({ className, inset, ...props }, ref) => (
83 |
92 | ))
93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
94 |
95 | const DropdownMenuCheckboxItem = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, children, checked, ...props }, ref) => (
99 |
108 |
109 |
110 |
111 |
112 |
113 | {children}
114 |
115 | ))
116 | DropdownMenuCheckboxItem.displayName =
117 | DropdownMenuPrimitive.CheckboxItem.displayName
118 |
119 | const DropdownMenuRadioItem = React.forwardRef<
120 | React.ElementRef,
121 | React.ComponentPropsWithoutRef
122 | >(({ className, children, ...props }, ref) => (
123 |
131 |
132 |
133 |
134 |
135 |
136 | {children}
137 |
138 | ))
139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
140 |
141 | const DropdownMenuLabel = React.forwardRef<
142 | React.ElementRef,
143 | React.ComponentPropsWithoutRef & {
144 | inset?: boolean
145 | }
146 | >(({ className, inset, ...props }, ref) => (
147 |
156 | ))
157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
158 |
159 | const DropdownMenuSeparator = React.forwardRef<
160 | React.ElementRef,
161 | React.ComponentPropsWithoutRef
162 | >(({ className, ...props }, ref) => (
163 |
168 | ))
169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
170 |
171 | const DropdownMenuShortcut = ({
172 | className,
173 | ...props
174 | }: React.HTMLAttributes) => {
175 | return (
176 |
180 | )
181 | }
182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
183 |
184 | export {
185 | DropdownMenu,
186 | DropdownMenuTrigger,
187 | DropdownMenuContent,
188 | DropdownMenuItem,
189 | DropdownMenuCheckboxItem,
190 | DropdownMenuRadioItem,
191 | DropdownMenuLabel,
192 | DropdownMenuSeparator,
193 | DropdownMenuShortcut,
194 | DropdownMenuGroup,
195 | DropdownMenuPortal,
196 | DropdownMenuSub,
197 | DropdownMenuSubContent,
198 | DropdownMenuSubTrigger,
199 | DropdownMenuRadioGroup,
200 | }
201 |
--------------------------------------------------------------------------------