├── app ├── components │ ├── MessageList.jsx │ ├── ImageModal.jsx │ ├── UsernamePrompt.jsx │ ├── VideoYhumbnail.jsx │ ├── UserList.jsx │ ├── ReactionComponent.jsx │ ├── VideoPlayer.jsx │ └── MessageInput.jsx ├── favicon.ico ├── channel │ ├── [channel] │ │ └── page.jsx │ └── page.jsx ├── page.js ├── globals.css ├── layout.js ├── hooks │ ├── useFileUpload.js │ ├── usePusher.js │ └── useMessages.js ├── api │ ├── upload │ │ └── route.js │ ├── pusher │ │ └── auth │ │ │ └── route.js │ └── messages │ │ ├── seen │ │ └── route.js │ │ └── route.js └── pages │ └── ChatBox.js ├── images ├── NewUserJoins.png ├── SetChannelPage.png └── TwoUserConversation.png ├── public ├── assets │ └── background.png ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── jsconfig.json ├── next.config.mjs ├── postcss.config.mjs ├── eslint.config.mjs ├── tailwind.config.mjs ├── lib ├── mongodb.js └── pusher.js ├── utils └── cloudinary.js ├── .gitignore ├── package.json ├── LICENSE └── README.md /app/components/MessageList.jsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jiniyasshah/messenger/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /images/NewUserJoins.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jiniyasshah/messenger/HEAD/images/NewUserJoins.png -------------------------------------------------------------------------------- /images/SetChannelPage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jiniyasshah/messenger/HEAD/images/SetChannelPage.png -------------------------------------------------------------------------------- /public/assets/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jiniyasshah/messenger/HEAD/public/assets/background.png -------------------------------------------------------------------------------- /images/TwoUserConversation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jiniyasshah/messenger/HEAD/images/TwoUserConversation.png -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./*"] 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/channel/[channel]/page.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import ChatBox from "@/app/pages/ChatBox"; 4 | 5 | export default function ChannelPage() { 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/page.js: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import HomePage from "./channel/page"; 4 | const page = () => { 5 | return ; 6 | }; 7 | 8 | export default page; 9 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [...compat.extends("next/core-web-vitals")]; 13 | 14 | export default eslintConfig; 15 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --background: #ffffff; 7 | --foreground: #171717; 8 | } 9 | 10 | @media (prefers-color-scheme: dark) { 11 | :root { 12 | --background: #0a0a0a; 13 | --foreground: #ededed; 14 | } 15 | } 16 | 17 | body { 18 | color: var(--foreground); 19 | background: var(--background); 20 | font-family: Arial, Helvetica, sans-serif; 21 | } 22 | -------------------------------------------------------------------------------- /tailwind.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 5 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 7 | ], 8 | theme: { 9 | extend: { 10 | colors: { 11 | background: "var(--background)", 12 | foreground: "var(--foreground)", 13 | }, 14 | }, 15 | }, 16 | plugins: [], 17 | }; 18 | -------------------------------------------------------------------------------- /lib/mongodb.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const MONGODB_URI = process.env.DATABASE_URL; 4 | 5 | let cached = global.mongoose; 6 | 7 | if (!cached) { 8 | cached = global.mongoose = { conn: null, promise: null }; 9 | } 10 | 11 | export default async function dbConnect() { 12 | if (cached.conn) return cached.conn; 13 | 14 | if (!cached.promise) { 15 | cached.promise = mongoose.connect(MONGODB_URI); 16 | } 17 | cached.conn = await cached.promise; 18 | return cached.conn; 19 | } 20 | -------------------------------------------------------------------------------- /lib/pusher.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const MONGODB_URI = process.env.DATABASE_URL; 4 | 5 | let cached = global.mongoose; 6 | 7 | if (!cached) { 8 | cached = global.mongoose = { conn: null, promise: null }; 9 | } 10 | 11 | export default async function dbConnect() { 12 | if (cached.conn) return cached.conn; 13 | 14 | if (!cached.promise) { 15 | cached.promise = mongoose.connect(MONGODB_URI); 16 | } 17 | cached.conn = await cached.promise; 18 | return cached.conn; 19 | } 20 | -------------------------------------------------------------------------------- /utils/cloudinary.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | export const uploadToCloudinary = async (file) => { 4 | const formData = new FormData(); 5 | formData.append("file", file); 6 | formData.append( 7 | "upload_preset", 8 | process.env.NEXT_PUBLIC_CLOUDINARY_PRESET_NAME 9 | ); 10 | 11 | const { data } = await axios.post( 12 | `https://api.cloudinary.com/v1_1/${process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME}/image/upload`, 13 | formData 14 | ); 15 | return data.secure_url; 16 | }; 17 | -------------------------------------------------------------------------------- /app/layout.js: -------------------------------------------------------------------------------- 1 | import "./globals.css"; // Import the global CSS file for Tailwind classes and custom styles 2 | 3 | export const metadata = { 4 | title: "Alihs", 5 | description: 6 | "A real-time messenger app with beautiful dark UI and responsive design.", 7 | }; 8 | 9 | export default function RootLayout({ children }) { 10 | return ( 11 | 12 | 13 |
{children}
14 | 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /.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.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /app/components/ImageModal.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { AiOutlineClose } from "react-icons/ai"; 3 | 4 | export function ImageModal({ image, onClose }) { 5 | if (!image) return null; 6 | 7 | return ( 8 |
9 |
10 | Selected 11 | 17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alihs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@headlessui/react": "^2.2.0", 13 | "axios": "^1.7.9", 14 | "cloudinary": "^2.5.1", 15 | "mongoose": "^8.9.0", 16 | "multer": "^1.4.5-lts.1", 17 | "next": "15.1.0", 18 | "pusher": "^5.2.0", 19 | "pusher-js": "^8.4.0-rc2", 20 | "react": "^19.0.0", 21 | "react-dom": "^19.0.0", 22 | "react-icons": "^5.4.0", 23 | "react-player": "^2.16.0", 24 | "shadcn-ui": "^0.9.4", 25 | "streamifier": "^0.1.1", 26 | "uuid": "^11.0.3" 27 | }, 28 | "devDependencies": { 29 | "@eslint/eslintrc": "^3", 30 | "eslint": "^9", 31 | "eslint-config-next": "15.1.0", 32 | "postcss": "^8", 33 | "tailwindcss": "^3.4.1" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Jiniyas Shah 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 21 | IN THE SOFTWARE. -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/components/UsernamePrompt.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import usePusher from "../hooks/usePusher"; 3 | export function UsernamePrompt({ channel, onSubmit }) { 4 | const [tempUsername, setTempUsername] = useState(""); 5 | const { initializePusher } = usePusher(channel); 6 | const handleSubmit = (e) => { 7 | e.preventDefault(); 8 | if (tempUsername.trim()) { 9 | initializePusher(tempUsername); 10 | onSubmit(tempUsername); 11 | } 12 | }; 13 | 14 | return ( 15 |
16 |
17 |

Join {channel}

18 |
19 | setTempUsername(e.target.value)} 24 | className="p-2 rounded-lg bg-gray-700 text-white focus:outline-none" 25 | /> 26 | 32 |
33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /app/channel/page.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { useRouter } from "next/navigation"; 5 | 6 | export default function HomePage() { 7 | const [name, setName] = useState(""); 8 | const [channel, setChannel] = useState(""); 9 | const router = useRouter(); 10 | 11 | const handleSubmit = (e) => { 12 | e.preventDefault(); 13 | if (name.trim() && channel.trim()) { 14 | // Save the name to localStorage for access in the chat page 15 | localStorage.setItem("username", name); 16 | router.push(`/channel/${encodeURIComponent(channel).trim()}`); 17 | } 18 | }; 19 | 20 | return ( 21 |
22 |
26 |

Join a Channel

27 |
28 | {/* Name Input */} 29 | setName(e.target.value)} 34 | className="p-2 rounded-lg bg-gray-700 text-white focus:outline-none" 35 | /> 36 | {/* Channel Name Input */} 37 | setChannel(e.target.value)} 42 | className="p-2 rounded-lg bg-gray-700 text-white focus:outline-none" 43 | /> 44 | {/* Submit Button */} 45 | 51 |
52 |
53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /app/hooks/useFileUpload.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import axios from "axios"; 3 | import { v4 as uuidv4 } from "uuid"; 4 | 5 | export function useFileUpload(channel, username, setMessages) { 6 | const [uploadProgress, setUploadProgress] = useState(0); 7 | 8 | const uploadFile = async (file) => { 9 | if (!file) return; 10 | 11 | // Create a new message with "sending" status 12 | const newMessage = { 13 | id: uuidv4(), 14 | username, 15 | content: URL.createObjectURL(file), // Show file preview immediately 16 | type: file.type.startsWith("video") ? "video" : "image", 17 | timestamp: new Date().toLocaleTimeString(), 18 | status: "sending", 19 | }; 20 | 21 | setMessages((prev) => [...prev, newMessage]); 22 | 23 | const formData = new FormData(); 24 | formData.append("image", file); 25 | 26 | try { 27 | // Upload the image via API 28 | const response = await axios.post("/api/upload", formData); 29 | 30 | if (response.data.success) { 31 | const uploadedMessage = { 32 | ...newMessage, 33 | content: response.data.imageUrl, // Update with the uploaded URL 34 | status: "sent", // Update status to "sent" 35 | }; 36 | 37 | // Update the message in the state 38 | setMessages((prev) => 39 | prev.map((msg) => (msg.id === newMessage.id ? uploadedMessage : msg)) 40 | ); 41 | 42 | // Optionally send the message to the server/channel 43 | await axios.post("/api/messages", { 44 | channel, 45 | message: uploadedMessage, 46 | }); 47 | } else { 48 | throw new Error(response.data.error); 49 | } 50 | } catch (error) { 51 | console.error("File upload failed:", error.message); 52 | setMessages((prev) => 53 | prev.map((msg) => 54 | msg.id === newMessage.id ? { ...msg, status: "failed" } : msg 55 | ) 56 | ); 57 | } 58 | }; 59 | 60 | return { uploadFile, uploadProgress }; 61 | } 62 | -------------------------------------------------------------------------------- /app/api/upload/route.js: -------------------------------------------------------------------------------- 1 | import { v2 as cloudinary } from "cloudinary"; 2 | import streamifier from "streamifier"; 3 | import { NextResponse } from "next/server"; 4 | 5 | // Cloudinary config 6 | cloudinary.config({ 7 | cloud_name: process.env.CLOUDINARY_CLOUD_NAME, 8 | api_key: process.env.CLOUDINARY_API_KEY, 9 | api_secret: process.env.CLOUDINARY_API_SECRET, 10 | secure: true, 11 | }); 12 | 13 | export const POST = async (req) => { 14 | try { 15 | const data = await req.formData(); 16 | const file = await data.get("file"); // Expect "file" field for the uploaded file 17 | 18 | if (!file) { 19 | return NextResponse.json({ error: "No file provided" }, { status: 400 }); 20 | } 21 | 22 | const fileBuffer = await file.arrayBuffer(); 23 | const mime = file.type; // MIME type (image/jpeg, video/mp4, etc.) 24 | 25 | // Determine resource type based on MIME type 26 | const resourceType = mime.startsWith("image/") 27 | ? "image" 28 | : mime.startsWith("video/") 29 | ? "video" 30 | : "raw"; // Default to "raw" for unsupported types 31 | 32 | const uploadToCloudinary = () => { 33 | return new Promise((resolve, reject) => { 34 | const uploadStream = cloudinary.uploader.upload_stream( 35 | { 36 | resource_type: "auto", // Dynamically determine resource type 37 | invalidate: true, 38 | }, 39 | (error, result) => { 40 | if (error) { 41 | console.error("Upload Error:", error); 42 | reject(error); 43 | } else { 44 | resolve(result); 45 | } 46 | } 47 | ); 48 | 49 | // Stream the file to Cloudinary using streamifier 50 | streamifier 51 | .createReadStream(Buffer.from(fileBuffer)) 52 | .pipe(uploadStream); 53 | }); 54 | }; 55 | 56 | const result = await uploadToCloudinary(); 57 | 58 | return NextResponse.json( 59 | { success: true, fileUrl: result.secure_url, resourceType: resourceType }, 60 | { status: 200 } 61 | ); 62 | } catch (error) { 63 | console.error("Server Error:", error); 64 | return NextResponse.json( 65 | { error: "Internal Server Error" }, 66 | { status: 500 } 67 | ); 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /app/components/VideoYhumbnail.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | const VideoThumbnailPreview = ({ file }) => { 4 | const [thumbnail, setThumbnail] = useState(null); 5 | 6 | useEffect(() => { 7 | if (!file) return; 8 | 9 | // Create a video element 10 | const videoElement = document.createElement("video"); 11 | 12 | // Create a canvas to capture a frame 13 | const canvas = document.createElement("canvas"); 14 | const ctx = canvas.getContext("2d"); 15 | 16 | const generateThumbnail = () => { 17 | videoElement.currentTime = 1; // Seek to 1 second to grab a frame 18 | videoElement.onloadeddata = () => { 19 | // Draw the current frame to the canvas 20 | ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height); 21 | const dataUrl = canvas.toDataURL(); // Convert canvas to image data URL 22 | setThumbnail(dataUrl); // Set the thumbnail image 23 | }; 24 | }; 25 | 26 | const objectUrl = URL.createObjectURL(file); 27 | videoElement.src = objectUrl; 28 | 29 | videoElement.onloadedmetadata = () => { 30 | canvas.width = videoElement.videoWidth / 3; // Adjust width for preview size 31 | canvas.height = videoElement.videoHeight / 3; // Adjust height for preview size 32 | generateThumbnail(); 33 | }; 34 | 35 | return () => { 36 | URL.revokeObjectURL(objectUrl); // Clean up URL after use 37 | }; 38 | }, [file]); 39 | 40 | return ( 41 |
42 | {thumbnail ? ( 43 | Video thumbnail 48 | ) : ( 49 |
50 | Loading... 51 |
52 | )} 53 | 62 |
63 | ); 64 | }; 65 | 66 | export default VideoThumbnailPreview; 67 | -------------------------------------------------------------------------------- /app/components/UserList.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | export default function UserStatusNotification({ activeUsers }) { 4 | const [notification, setNotification] = useState(null); 5 | const [prevUsersList, setPrevUsersList] = useState([]); 6 | 7 | useEffect(() => { 8 | // Find added and removed users by comparing current and previous lists 9 | const addedUsers = activeUsers.filter( 10 | (user) => 11 | !prevUsersList.find((prevUser) => prevUser.username === user.username) 12 | ); 13 | 14 | const removedUsers = prevUsersList.filter( 15 | (user) => 16 | !activeUsers.find( 17 | (currentUser) => currentUser.username === user.username 18 | ) 19 | ); 20 | 21 | // Create notification message if there are changes 22 | if (addedUsers.length > 0) { 23 | setNotification({ 24 | type: "joined", 25 | users: addedUsers, 26 | timestamp: new Date(), 27 | }); 28 | } else if (removedUsers.length > 0) { 29 | setNotification({ 30 | type: "left", 31 | users: removedUsers, 32 | timestamp: new Date(), 33 | }); 34 | } 35 | 36 | // Clear notification after 3 seconds 37 | const timer = setTimeout(() => { 38 | setNotification(null); 39 | }, 3000); 40 | 41 | // Update previous users list 42 | setPrevUsersList(activeUsers); 43 | 44 | // Cleanup timer 45 | return () => clearTimeout(timer); 46 | }, [activeUsers]); 47 | 48 | if (!notification) return null; 49 | 50 | return ( 51 |
58 |
59 | {notification.type === "joined" ? ( 60 |
61 | {notification.users.length === 1 ? ( 62 | {notification.users[0].username} joined 63 | ) : ( 64 | {notification.users.length} users joined 65 | )} 66 |
67 | ) : ( 68 |
69 | {notification.users.length === 1 ? ( 70 | {notification.users[0].username} left 71 | ) : ( 72 | {notification.users.length} users left 73 | )} 74 |
75 | )} 76 |
77 |
78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /app/api/pusher/auth/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import Pusher from "pusher"; 3 | 4 | const pusher = new Pusher({ 5 | appId: process.env.PUSHER_APP_ID, 6 | key: process.env.NEXT_PUBLIC_PUSHER_APP_KEY, 7 | secret: process.env.PUSHER_SECRET, 8 | cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER, 9 | useTLS: true, 10 | }); 11 | 12 | const activeUsersMap = new Map(); 13 | 14 | // Cleanup inactive users every 30 seconds 15 | const CLEANUP_INTERVAL = 30000; 16 | const INACTIVE_THRESHOLD = 60000; // 1 minute 17 | 18 | setInterval(() => { 19 | const now = Date.now(); 20 | for (const [channelName, activeUsers] of activeUsersMap.entries()) { 21 | for (const [username, data] of activeUsers.entries()) { 22 | if (now - new Date(data.timestamp).getTime() > INACTIVE_THRESHOLD) { 23 | activeUsers.delete(username); 24 | 25 | // Trigger user-status-change for the specific channel 26 | pusher.trigger(channelName, "user-status-change", { 27 | username, 28 | status: "offline", 29 | timestamp: new Date().toISOString(), 30 | activeUsers: Array.from(activeUsers.entries()).map( 31 | ([username, data]) => ({ 32 | username, 33 | ...data, 34 | }) 35 | ), 36 | }); 37 | } 38 | } 39 | if (activeUsers.size === 0) { 40 | activeUsersMap.delete(channelName); // Clean up empty channel entries 41 | } 42 | } 43 | }, CLEANUP_INTERVAL); 44 | 45 | export async function POST(req) { 46 | const { username, status, channelName } = await req.json(); 47 | if (!channelName) { 48 | return NextResponse.json( 49 | { error: "Channel name is required." }, 50 | { status: 400 } 51 | ); 52 | } 53 | const timestamp = new Date().toISOString(); 54 | 55 | // Get or initialize the active users for the channel 56 | if (!activeUsersMap.has(channelName)) { 57 | activeUsersMap.set(channelName, new Map()); 58 | } 59 | const activeUsers = activeUsersMap.get(channelName); 60 | 61 | if (status === "online") { 62 | activeUsers.set(username, { timestamp }); 63 | } else if (status === "offline") { 64 | activeUsers.delete(username); 65 | } 66 | 67 | // Trigger user-status-change for the specific channel 68 | await pusher.trigger(channelName, "user-status-change", { 69 | username, 70 | status, 71 | timestamp, 72 | activeUsers: Array.from(activeUsers.entries()).map(([username, data]) => ({ 73 | username, 74 | ...data, 75 | })), 76 | }); 77 | 78 | return NextResponse.json({ success: true }); 79 | } 80 | -------------------------------------------------------------------------------- /app/api/messages/seen/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { MongoClient } from "mongodb"; 3 | import Pusher from "pusher"; 4 | 5 | const pusher = new Pusher({ 6 | appId: process.env.PUSHER_APP_ID, 7 | key: process.env.NEXT_PUBLIC_PUSHER_APP_KEY, 8 | secret: process.env.PUSHER_SECRET, 9 | cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER, 10 | useTLS: true, 11 | }); 12 | const uri = process.env.DATABASE_URL; 13 | let client; 14 | 15 | async function connectToDB() { 16 | if (!client) { 17 | client = new MongoClient(uri); 18 | await client.connect(); 19 | } 20 | return client.db("realtime_chat").collection("messages"); 21 | } 22 | 23 | // Helper function for error responses 24 | 25 | // Helper function for error responses 26 | const errorResponse = (message, status = 400) => { 27 | return NextResponse.json({ success: false, error: message }, { status }); 28 | }; 29 | 30 | export async function PATCH(request) { 31 | try { 32 | const body = await request.json(); 33 | const { messageId, username } = body; 34 | 35 | // Input validation 36 | if (!messageId || typeof messageId !== "string") { 37 | return errorResponse("Valid messageId is required"); 38 | } 39 | 40 | if (!username || typeof username !== "string") { 41 | return errorResponse("Valid username is required"); 42 | } 43 | 44 | const messagesCollection = await connectToDB(); 45 | 46 | // Atomic update operation 47 | const updateResult = await messagesCollection.findOneAndUpdate( 48 | { 49 | id: messageId, 50 | messageSeen: { $ne: username }, // Prevent duplicate seen status 51 | }, 52 | { 53 | $addToSet: { messageSeenBy: username }, // Add to array of users who've seen 54 | $set: { 55 | lastSeenAt: new Date(), 56 | messageSeen: username, // Maintain backwards compatibility 57 | }, 58 | }, 59 | { 60 | returnDocument: "after", 61 | projection: { messageSeenBy: 1, messageSeen: 1 }, 62 | } 63 | ); 64 | 65 | // Handle case where message doesn't exist or was already seen 66 | if (!updateResult) { 67 | const messageExists = await messagesCollection.findOne( 68 | { id: messageId }, 69 | { projection: { messageSeen: 1 } } 70 | ); 71 | 72 | if (!messageExists) { 73 | return errorResponse("Message not found", 404); 74 | } 75 | 76 | return NextResponse.json({ 77 | success: true, 78 | messageSeen: messageExists.messageSeen, 79 | messageSeenBy: messageExists.messageSeenBy || [], 80 | }); 81 | } 82 | 83 | // Broadcast update via Pusher 84 | const eventData = { 85 | messageId, 86 | messageSeen: username, 87 | messageSeenBy: updateResult.messageSeenBy, 88 | timestamp: new Date().toISOString(), 89 | }; 90 | 91 | await pusher.trigger("message-updates", "message-seen", eventData); 92 | 93 | return NextResponse.json({ 94 | success: true, 95 | ...eventData, 96 | }); 97 | } catch (error) { 98 | console.error("Error updating message seen status:", error); 99 | return errorResponse( 100 | process.env.NODE_ENV === "development" 101 | ? error.message 102 | : "Internal Server Error", 103 | 500 104 | ); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Real-time Messenger Application 2 | 3 | A modern real-time chat application built with Next.js and Pusher, featuring a beautiful dark UI and rich messaging capabilities. 4 | 5 | ## Live Demo 6 | 7 | Check out the live demo here: [Live Demo](https://alihs.vercel.app/) 8 | 9 | ```bash 10 | https://alihs.vercel.app/ 11 | ``` 12 | 13 | > **Note:** The demo provides a preview of the app's main features. 14 | 15 | ## Features 16 | 17 | - 💬 Real-time messaging 18 | - 🌓 Dark theme UI 19 | - 👥 User presence detection 20 | - 📷 Image sharing with previews 21 | - 🎥 Video sharing with custom player 22 | - 😊 Emoji reactions 23 | - ✅ Message status indicators 24 | - 🔗 Link previews 25 | - 📱 Responsive design 26 | - 💾 File sharing support 27 | 28 | ## Technologies 29 | 30 | - Next.js 13+ 31 | - Pusher for real-time functionality 32 | - MongoDB for message storage 33 | - TailwindCSS for styling 34 | - Cloudinary for media storage 35 | 36 | ## Setup 37 | 38 | 1. Clone the repository 39 | 40 | ```bash 41 | git clone https://github.com/jiniyasshah/messenger.git 42 | cd messenger 43 | ``` 44 | 45 | 2. Install dependencies 46 | 47 | ```bash 48 | npm install 49 | ``` 50 | 51 | 3. Set up environment variables 52 | 53 | ```bash 54 | PUSHER_APP_ID= 55 | NEXT_PUBLIC_PUSHER_APP_KEY= 56 | PUSHER_SECRET= 57 | NEXT_PUBLIC_PUSHER_CLUSTER= 58 | DATABASE_URL= 59 | CLOUDINARY_CLOUD_NAME= 60 | CLOUDINARY_API_KEY= 61 | CLOUDINARY_API_SECRET= 62 | ``` 63 | 64 | 4.Run the development server 65 | 66 | ```bash 67 | npm run dev 68 | ``` 69 | 70 | ## Usage 71 | 72 | 1. Enter your name and channel on the home page 73 | 2. Start chatting in real-time 74 | 3. Share images and videos by clicking the attachment button 75 | 4. React to messages by clicking on them 76 | 5. See user presence and message status in real-time 77 | 78 | ## Project Structure 79 | 80 | ``` 81 | app/ 82 | ├── api/ # API routes 83 | ├── channel/ # Channel pages 84 | ├── components/ # Reusable components 85 | ├── hooks/ # Custom hooks 86 | └── pages/ # App pages 87 | ``` 88 | 89 | ## Security & Future Goals 90 | 91 | ### Planned Features 92 | 93 | - End-to-end encryption 94 | - Backend user presence logic 95 | - Message persistence 96 | - User authentication (optional) 97 | - Private channels 98 | - Group chat support 99 | - Voice messages 100 | - Video calls 101 | 102 | ### Security Roadmap 103 | 104 | - Implement E2E encryption using Signal Protocol 105 | - Secure file sharing 106 | - Rate limiting 107 | - Message validation 108 | - Input sanitization 109 | 110 | ## Screenshots 111 | 112 | ### Landing Page 113 | 114 | ![Landing Page](./images/SetChannelPage.png) 115 | 116 | ### Conversation & File Sharing 117 | 118 | ![Conversation & File Sharing](./images/TwoUserConversation.png) 119 | 120 | ### Limited Message Visibility 121 | 122 | - Only users present during the communication can actually see the message. 123 | 124 | ![Active Users](./images/NewUserJoins.png) 125 | 126 | ## License 127 | 128 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 129 | 130 | ## Contributing 131 | 132 | 1. Fork the repository. 133 | 2. Create a new branch (`git checkout -b feature-branch`). 134 | 3. Make your changes and commit them (`git commit -am 'Add feature'`). 135 | 4. Push to the branch (`git push origin feature-branch`). 136 | 5. Create a new pull request. 137 | 138 | We welcome all contributions! Please make sure your code adheres to the existing coding style, and include tests for new functionality where applicable. 139 | -------------------------------------------------------------------------------- /app/hooks/usePusher.js: -------------------------------------------------------------------------------- 1 | // hooks/usePusher.js 2 | import { useState, useEffect, useRef } from "react"; 3 | import PusherClient from "pusher-js"; 4 | 5 | let pusherInstance = null; 6 | 7 | export const usePusher = (currentUsername, channelName) => { 8 | const [activeUsers, setActiveUsers] = useState([]); 9 | const [latestActiveUser, setLatestActiveUser] = useState(null); 10 | const channelRef = useRef(null); 11 | const heartbeatIntervalRef = useRef(null); 12 | 13 | useEffect(() => { 14 | if (!currentUsername || !channelName) return; 15 | 16 | // Initialize Pusher only once 17 | if (!pusherInstance) { 18 | pusherInstance = new PusherClient( 19 | process.env.NEXT_PUBLIC_PUSHER_APP_KEY, 20 | { 21 | cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER, 22 | } 23 | ); 24 | } 25 | 26 | // Function to update user status 27 | const updateUserStatus = async (status) => { 28 | try { 29 | await fetch("/api/pusher/auth", { 30 | method: "POST", 31 | headers: { "Content-Type": "application/json" }, 32 | body: JSON.stringify({ 33 | username: currentUsername, 34 | status, 35 | channelName, 36 | }), 37 | keepalive: true, 38 | }); 39 | } catch (error) { 40 | console.error("Error updating user status:", error); 41 | } 42 | }; 43 | 44 | // Subscribe to channel 45 | if (!channelRef.current) { 46 | channelRef.current = pusherInstance.subscribe(channelName); 47 | } 48 | 49 | // Set up event handler 50 | const handleUserStatusChange = (data) => { 51 | setActiveUsers(data.activeUsers); 52 | if (data.status === "online" && data.username !== currentUsername) { 53 | setLatestActiveUser({ 54 | username: data.username, 55 | timestamp: data.timestamp, 56 | }); 57 | } 58 | }; 59 | 60 | // Listen for Pusher connection state changes 61 | pusherInstance.connection.bind("state_change", ({ current }) => { 62 | if (current === "connected") { 63 | updateUserStatus("online"); 64 | } else if (current === "disconnected") { 65 | updateUserStatus("offline"); 66 | } 67 | }); 68 | 69 | // Set up heartbeat to keep user active 70 | heartbeatIntervalRef.current = setInterval(() => { 71 | updateUserStatus("online"); 72 | }, 30000); // Send heartbeat every 30 seconds 73 | 74 | // Handle page unload 75 | const handleBeforeUnload = () => { 76 | updateUserStatus("offline"); 77 | }; 78 | 79 | // Handle tab visibility change 80 | const handleVisibilityChange = () => { 81 | if (document.hidden) { 82 | updateUserStatus("offline"); 83 | } else { 84 | updateUserStatus("online"); 85 | } 86 | }; 87 | 88 | // Set up event listeners 89 | channelRef.current.bind("user-status-change", handleUserStatusChange); 90 | window.addEventListener("beforeunload", handleBeforeUnload); 91 | document.addEventListener("visibilitychange", handleVisibilityChange); 92 | 93 | // Initial status update 94 | updateUserStatus("online"); 95 | 96 | // Cleanup function 97 | return () => { 98 | // Clear heartbeat interval 99 | if (heartbeatIntervalRef.current) { 100 | clearInterval(heartbeatIntervalRef.current); 101 | } 102 | 103 | // Remove event listeners 104 | if (channelRef.current) { 105 | channelRef.current.unbind("user-status-change", handleUserStatusChange); 106 | } 107 | window.removeEventListener("beforeunload", handleBeforeUnload); 108 | document.removeEventListener("visibilitychange", handleVisibilityChange); 109 | 110 | // Set user as offline 111 | updateUserStatus("offline"); 112 | }; 113 | }, [currentUsername, channelName]); 114 | 115 | return { activeUsers, latestActiveUser }; 116 | }; 117 | -------------------------------------------------------------------------------- /app/components/ReactionComponent.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { IoMdCheckmarkCircle } from "react-icons/io"; 3 | import { MdOutlineRadioButtonUnchecked } from "react-icons/md"; 4 | import { RxCrossCircled } from "react-icons/rx"; 5 | const ReactionComponent = ({ username, msg }) => { 6 | // Function to count occurrences of each emoji 7 | 8 | const generateGradient = (name) => { 9 | // Simple gradient generation based on name (or initials) 10 | const charCodeSum = name.charCodeAt(0) + name.charCodeAt(name.length - 1); // Simple method of hashing 11 | const gradientIndex = charCodeSum % 5; // Limit gradients to 5 possibilities 12 | 13 | const gradients = [ 14 | "from-emerald-400 to-cyan-400", // Sky to Cyan 15 | "from-red-500 to-orange-500", // Pink to Purple 16 | "from-fuchsia-500 to-pink-500", // Green to Blue 17 | "from-lime-400 to-lime-500", // Yellow to Red 18 | "from-emerald-400 to-cyan-400", // Indigo to Purple 19 | ]; 20 | 21 | return gradients[gradientIndex]; 22 | }; 23 | 24 | return ( 25 | <> 26 | {msg.reactions && Object.values(msg.reactions).length > 0 && ( 27 |
28 |
31 | {Object.entries( 32 | Object.values(msg.reactions).reduce((acc, emoji) => { 33 | acc[emoji] = (acc[emoji] || 0) + 1; // Count occurrences of each emoji 34 | return acc; 35 | }, {}) 36 | ).map(([emoji, count], index) => ( 37 |
41 | {count > 1 ? ( 42 |
43 | {emoji} 44 |
{`+${count}`}
47 |
48 | ) : ( 49 | <> 50 | {emoji} 51 |
msg.reactions[user] === emoji 55 | ) 56 | )} rounded-full text-[0.7rem] text-center text-white w-4 h-4 flex items-center justify-center`} 57 | > 58 | {Object.keys(msg.reactions) 59 | .find((user) => msg.reactions[user] === emoji) 60 | ?.charAt(0) 61 | .toUpperCase()} 62 |
63 | 64 | )} 65 |
66 | ))} 67 |
68 | {msg.type !== "text" && ( 69 |
70 |
{msg.timestamp}
71 | {msg.username === username && 72 | (msg.status === "sent" ? ( 73 |
74 | 75 |
76 | ) : msg.status === "sending" ? ( 77 |
78 | 79 |
80 | ) : ( 81 |
82 | 83 |
84 | ))} 85 |
86 | )} 87 |
88 | )} 89 | 90 | ); 91 | }; 92 | 93 | export default ReactionComponent; 94 | -------------------------------------------------------------------------------- /app/components/VideoPlayer.jsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState, useEffect } from "react"; 2 | import { 3 | MdOutlinePlayCircle, 4 | MdOutlinePauseCircleFilled, 5 | } from "react-icons/md"; 6 | import { MdVolumeOff, MdVolumeUp } from "react-icons/md"; 7 | const VideoPlayer = ({ 8 | msg, 9 | handleVideoLoad, 10 | handleVideoPlay, 11 | setCurrentVideo, 12 | }) => { 13 | const videoRef = useRef(null); 14 | const [isPlaying, setIsPlaying] = useState(false); 15 | const [currentTime, setCurrentTime] = useState(0); 16 | const [duration, setDuration] = useState(0); 17 | const [isMuted, setIsMuted] = useState(false); 18 | 19 | useEffect(() => { 20 | const video = videoRef.current; 21 | if (video) { 22 | const updateCurrentTime = () => setCurrentTime(video.currentTime); 23 | video.addEventListener("timeupdate", updateCurrentTime); 24 | return () => video.removeEventListener("timeupdate", updateCurrentTime); 25 | } 26 | }, []); 27 | const handleVolumeToggle = () => { 28 | const video = videoRef.current; 29 | if (video) { 30 | video.muted = !video.muted; 31 | setIsMuted(!isMuted); 32 | } 33 | }; 34 | 35 | const handlePlay = () => { 36 | videoRef.current.play(); 37 | setIsPlaying(true); 38 | }; 39 | 40 | const handlePause = () => { 41 | videoRef.current.pause(); 42 | setIsPlaying(false); 43 | }; 44 | 45 | const handleSeek = (e) => { 46 | const video = videoRef.current; 47 | const newTime = (e.target.value / 100) * video.duration; 48 | video.currentTime = newTime; 49 | setCurrentTime(newTime); 50 | }; 51 | 52 | const handleLoadedMetadata = () => { 53 | const video = videoRef.current; 54 | setDuration(video.duration); 55 | }; 56 | 57 | const formatTime = (time) => { 58 | const minutes = Math.floor(time / 60); 59 | const seconds = Math.floor(time % 60); 60 | return `${minutes}:${seconds < 10 ? `0${seconds}` : seconds}`; 61 | }; 62 | 63 | const handleVPlay = (e, content) => { 64 | setIsPlaying(true); 65 | handleVideoPlay(e.target, content); 66 | }; 67 | 68 | return ( 69 |
70 | {/* Video element */} 71 |
72 |
125 | {/* Custom controls */} 126 |
127 | ); 128 | }; 129 | 130 | export default VideoPlayer; 131 | -------------------------------------------------------------------------------- /app/api/messages/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import Pusher from "pusher"; 3 | import { MongoClient } from "mongodb"; 4 | 5 | const pusher = new Pusher({ 6 | appId: process.env.PUSHER_APP_ID, 7 | key: process.env.NEXT_PUBLIC_PUSHER_APP_KEY, 8 | secret: process.env.PUSHER_SECRET, 9 | cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER, 10 | useTLS: true, 11 | }); 12 | 13 | // MongoDB Connection 14 | const uri = process.env.DATABASE_URL; 15 | let client; 16 | 17 | async function connectToDB() { 18 | if (!client) { 19 | client = new MongoClient(uri); 20 | await client.connect(); 21 | } 22 | return client.db("realtime_chat").collection("messages"); 23 | } 24 | 25 | export async function POST(request) { 26 | try { 27 | const body = await request.json(); 28 | const { channel, message } = body; 29 | 30 | if (!channel || !message) { 31 | return NextResponse.json( 32 | { error: "Channel and message are required." }, 33 | { status: 400 } 34 | ); 35 | } 36 | 37 | const messagesCollection = await connectToDB(); 38 | 39 | // Save the message to MongoDB 40 | await messagesCollection.insertOne({ 41 | channel, 42 | ...message, 43 | createdAt: new Date(), 44 | }); 45 | 46 | // Trigger Pusher event to broadcast the message 47 | await pusher.trigger(channel, "new-message", message); 48 | 49 | return NextResponse.json({ success: true, message }); 50 | } catch (error) { 51 | console.error("Error saving or broadcasting message:", error); 52 | return NextResponse.json( 53 | { error: "Internal Server Error" }, 54 | { status: 500 } 55 | ); 56 | } 57 | } 58 | 59 | export async function GET(request) { 60 | try { 61 | const url = new URL(request.url); 62 | const channel = url.searchParams.get("channel"); 63 | console.log("fetching messages:"); 64 | if (!channel) { 65 | return NextResponse.json( 66 | { error: "Channel is required." }, 67 | { status: 400 } 68 | ); 69 | } 70 | 71 | const messagesCollection = await connectToDB(); 72 | 73 | // Retrieve messages for the specific channel 74 | const messages = await messagesCollection 75 | .find({ channel }) 76 | .sort({ createdAt: 1 }) // Sort messages by creation time (ascending) 77 | .toArray(); 78 | 79 | // Return the messages as JSON response 80 | return NextResponse.json({ success: true, messages }); 81 | } catch (error) { 82 | console.error("Error fetching messages:", error); 83 | return NextResponse.json( 84 | { error: "Internal Server Error" }, 85 | { status: 500 } 86 | ); 87 | } 88 | } 89 | 90 | export async function PATCH(request) { 91 | try { 92 | const body = await request.json(); 93 | const { messageId, emoji, username } = body; 94 | 95 | if (!messageId || !emoji || !username) { 96 | return NextResponse.json( 97 | { error: "Message ID, emoji, and username are required." }, 98 | { status: 400 } 99 | ); 100 | } 101 | 102 | const messagesCollection = await connectToDB(); 103 | 104 | // Retrieve the current message 105 | const currentMessage = await messagesCollection.findOne({ id: messageId }); 106 | 107 | if (!currentMessage) { 108 | return NextResponse.json( 109 | { error: "Message not found." }, 110 | { status: 404 } 111 | ); 112 | } 113 | 114 | const currentReactions = currentMessage.reactions || {}; 115 | 116 | // Determine whether to add or remove the reaction 117 | if (currentReactions[username] === emoji) { 118 | // Remove the reaction if it already exists 119 | delete currentReactions[username]; 120 | } else { 121 | // Add/update the reaction 122 | currentReactions[username] = emoji; 123 | } 124 | 125 | // Update the reactions in the database 126 | const updateResult = await messagesCollection.updateOne( 127 | { id: messageId }, 128 | { 129 | $set: { reactions: currentReactions }, 130 | } 131 | ); 132 | 133 | if (!updateResult.matchedCount) { 134 | return NextResponse.json( 135 | { error: "Failed to update reactions." }, 136 | { status: 500 } 137 | ); 138 | } 139 | 140 | // Notify others via Pusher 141 | await pusher.trigger("reactions", "updated", { 142 | messageId, 143 | reactions: currentReactions, 144 | }); 145 | 146 | return NextResponse.json({ 147 | success: true, 148 | reactions: currentReactions, 149 | }); 150 | } catch (error) { 151 | console.error("Error updating reactions:", error); 152 | return NextResponse.json( 153 | { error: "Internal Server Error" }, 154 | { status: 500 } 155 | ); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /app/components/MessageInput.jsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect, useState } from "react"; 2 | import { MdPhotoSizeSelectActual } from "react-icons/md"; 3 | import { MdSend } from "react-icons/md"; 4 | import { RxCross2 } from "react-icons/rx"; 5 | const MessageInput = ({ 6 | input, 7 | setInput, 8 | sendMessage, 9 | sendFile, 10 | setHandleMessageSend, 11 | activeUsers, 12 | }) => { 13 | const fileInputRef = useRef(null); 14 | const textAreaRef = useRef(null); 15 | 16 | // State to track the device type 17 | const [isMobile, setIsMobile] = useState(false); 18 | 19 | // State to temporarily hold the selected/pasted file 20 | const [selectedFile, setSelectedFile] = useState(null); 21 | const [previewImage, setPreviewImage] = useState(null); 22 | const [previewVideo, setPreviewVideo] = useState(null); 23 | const [previewFileIcon, setPreviewFileIcon] = useState(null); 24 | 25 | // Detect device type 26 | useEffect(() => { 27 | const userAgent = navigator.userAgent.toLowerCase(); 28 | const isMobileDevice = 29 | /android|webos|iphone|ipod|blackberry|iemobile|opera mini/i.test( 30 | userAgent 31 | ); 32 | setIsMobile(isMobileDevice); 33 | }, []); 34 | 35 | // Auto-resize textarea based on input 36 | useEffect(() => { 37 | const textarea = textAreaRef.current; 38 | textarea.style.height = "auto"; 39 | textarea.style.height = `${textarea.scrollHeight}px`; 40 | }, [input]); 41 | 42 | // Handle input changes 43 | const handleInputChange = (e) => { 44 | setInput(e.target.value); 45 | }; 46 | 47 | // Handle pasted images 48 | const handlePaste = (e) => { 49 | const items = e.clipboardData?.items; 50 | if (items) { 51 | for (const item of items) { 52 | if (item.type.startsWith("image/")) { 53 | e.preventDefault(); 54 | const file = item.getAsFile(); 55 | if (file) { 56 | const imageURL = URL.createObjectURL(file); 57 | setSelectedFile(file); // Store the file temporarily 58 | setPreviewImage(imageURL); // Set image preview 59 | } 60 | } 61 | } 62 | } 63 | }; 64 | 65 | // Handle uploaded files (via file input) 66 | const handleFileUpload = (e) => { 67 | const file = e.target.files[0]; 68 | if (file) { 69 | if (file.type.startsWith("image/")) { 70 | const imageURL = URL.createObjectURL(file); 71 | setPreviewImage(imageURL); // Set image preview 72 | } else if (file.type.startsWith("video/")) { 73 | const videoURL = URL.createObjectURL(file); 74 | setPreviewVideo(videoURL); // Set video preview 75 | } else { 76 | setPreviewFileIcon(true); // Set file icon preview 77 | } 78 | setSelectedFile(file); // Store the file temporarily 79 | } 80 | }; 81 | // Handle submit button or Enter key 82 | const handleSubmit = (e) => { 83 | e.preventDefault(); 84 | setHandleMessageSend(true); 85 | const hasFile = selectedFile !== null; 86 | const hasText = input.trim() !== ""; 87 | console.log(activeUsers); 88 | if (hasFile) { 89 | sendFile(selectedFile, hasText ? input : null, activeUsers); // Send file with text only if text exists 90 | setSelectedFile(null); 91 | setPreviewImage(null); 92 | setPreviewVideo(null); 93 | setPreviewFileIcon(null); 94 | } 95 | 96 | if (hasText && !hasFile) { 97 | sendMessage(e, activeUsers); // Send message only if no file was sent 98 | } 99 | 100 | // Clear the input field after sending the message 101 | setInput(""); 102 | }; 103 | 104 | // Handle Enter key press 105 | const handleKeyDown = (e) => { 106 | if (e.key === "Enter" && !e.shiftKey) { 107 | e.preventDefault(); 108 | handleSubmit(e); // Trigger submit 109 | } 110 | }; 111 | 112 | return ( 113 |
114 | {/* Main Form */} 115 |
116 | {/* Preview Image Above Textarea */} 117 | {previewImage && ( 118 |
119 | Preview 124 | 134 |
135 | )} 136 | {previewVideo && ( 137 |
138 |
155 | )} 156 | {previewFileIcon && ( 157 |
158 |
159 | 160 |
161 | 171 |
172 | )} 173 |
174 | {/* File Upload Button */} 175 |
fileInputRef.current.click()} 178 | className="bg-transparent rounded-lg text-blue-400 hover:text-blue-500 cursor-pointer" 179 | > 180 | 181 |
182 | 189 | {/* Textarea Input */} 190 | {!previewImage ? ( 191 |