├── 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 | 3 | {"msg-dblcheck"} 4 | 8 | 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 | Hero 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 |
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 | 27 | 28 |

See members

29 |
30 | 31 | 32 | Current Members 33 | 34 |
35 | {users?.map((user) => ( 36 |
40 | 41 | {user.isOnline && ( 42 |
43 | )} 44 | 48 | 49 |
50 |
51 | 52 | 53 |
54 |
55 |

56 | {/* johndoe@gmail.com */} 57 | {user.name || user.email.split("@")[0]} 58 |

59 | {user._id === selectedConversation.admin && ( 60 | 61 | )} 62 |
63 |
64 |
65 | ))} 66 |
67 | 68 | 69 | 70 |
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 |
62 |
63 | setMsgText(e.target.value)} 69 | /> 70 |
71 |
72 | {msgText.length > 0 ? ( 73 | 80 | ) : ( 81 | 88 | )} 89 |
90 |
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 | image 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 | { 151 | if (!isOpen) onClose(); 152 | }} 153 | > 154 | 155 | 156 | image 162 | 163 | 164 | 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 |
189 | {isLink ? ( 190 | 196 | {message.content} 197 | 198 | ) : ( 199 |

{message.content}

200 | )} 201 |
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 | 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 | { 172 | if (!isOpen) onClose(); 173 | }} 174 | > 175 | 176 | 177 | {renderedImage && ( 178 | selected image 184 | )} 185 | 192 | 193 | 194 | 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 | { 221 | if (!isOpen) onClose(); 222 | }} 223 | > 224 | 225 | Video 226 |
227 | {renderedVideo && ( 228 | 229 | )} 230 |
231 | 238 |
239 |
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 | 108 | 109 | 110 | 111 | 112 | 113 | {/* TODO: will be here */} 114 | 115 | USERS 116 | 117 | 118 | Start a new chat 119 | {renderedImage && ( 120 |
121 | user image 127 |
128 | )} 129 | {/* TODO: input file */} 130 | setSelectedImage(e.target.files![0])} 136 | /> 137 | {selectedUsers.length > 1 && ( 138 | <> 139 | setGroupName(e.target.value)} 143 | /> 144 | 151 | 152 | )} 153 |
154 | {users?.map((user) => ( 155 |
{ 161 | if (selectedUsers.includes(user._id)) { 162 | setSelectedUsers( 163 | selectedUsers.filter((id) => id !== user._id) 164 | ); 165 | } else { 166 | setSelectedUsers([...selectedUsers, user._id]); 167 | } 168 | }} 169 | > 170 | 171 | {user.isOnline && ( 172 |
173 | )} 174 | 175 | 179 | 180 |
181 |
182 | 183 | 184 |
185 |
186 |

187 | {user.name || user.email.split("@")[0]} 188 |

189 |
190 |
191 |
192 | ))} 193 |
194 |
195 | 196 | 211 |
212 | 213 |
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 | --------------------------------------------------------------------------------