├── 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 |

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 |
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 |
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 |

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 | 
115 |
116 | ### Conversation & File Sharing
117 |
118 | 
119 |
120 | ### Limited Message Visibility
121 |
122 | - Only users present during the communication can actually see the message.
123 |
124 | 
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 |
227 |
228 | );
229 | };
230 |
231 | export default MessageInput;
232 |
--------------------------------------------------------------------------------
/app/hooks/useMessages.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useCallback } from "react";
2 | import { v4 as uuidv4 } from "uuid";
3 | import axios from "axios";
4 | import Pusher from "pusher-js";
5 |
6 | export const useSendMessage = (username, channel) => {
7 | const [messages, setMessages] = useState([]);
8 | const [input, setInput] = useState("");
9 |
10 | // Helper function: Handle API errors
11 | const handleApiError = (error, context) => {
12 | console.error(`Error in ${context}:`, error.message || error);
13 | };
14 |
15 | // Fetch existing messages
16 | const fetchMessages = useCallback(async () => {
17 | try {
18 | const { data } = await axios.get(`/api/messages?channel=${channel}`);
19 | if (data.success) {
20 | const messagesWithStatus = data.messages.map((msg) => ({
21 | ...msg,
22 | status: "sent",
23 | }));
24 | setMessages(messagesWithStatus);
25 | }
26 | } catch (error) {
27 | handleApiError(error, "fetchMessages");
28 | }
29 | }, [channel]);
30 |
31 | useEffect(() => {
32 | const pusher = new Pusher(process.env.NEXT_PUBLIC_PUSHER_APP_KEY, {
33 | cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER,
34 | });
35 |
36 | // Use a consistent channel structure
37 | const channels = {
38 | messages: pusher.subscribe(channel),
39 | updates: pusher.subscribe("message-updates"),
40 | };
41 |
42 | // Message events
43 | channels.messages.bind("new-message", (data) => {
44 | setMessages((prev) => {
45 | if (!prev.some((msg) => msg.id === data.id)) {
46 | return [...prev, data];
47 | }
48 | return prev;
49 | });
50 | });
51 |
52 | // Reaction events
53 | const reactionsChannel = pusher.subscribe("reactions");
54 | reactionsChannel.bind("updated", (data) => {
55 | const { messageId, reactions } = data;
56 |
57 | setMessages((prev) =>
58 | prev.map((msg) => (msg.id === messageId ? { ...msg, reactions } : msg))
59 | );
60 | });
61 |
62 | // Seen status events
63 | channels.updates.bind("message-seen", (data) => {
64 | const { messageId, messageSeen } = data;
65 | setMessages((prev) =>
66 | prev.map((msg) =>
67 | msg.id === messageId ? { ...msg, messageSeen } : msg
68 | )
69 | );
70 | });
71 |
72 | // Cleanup
73 | return () => {
74 | Object.values(channels).forEach((channel) => channel.unbind_all());
75 | pusher.unsubscribe(channel);
76 | pusher.unsubscribe("reactions");
77 | pusher.unsubscribe("message-updates");
78 | };
79 | }, [channel]);
80 |
81 | // Send a text message
82 | const sendMessage = useCallback(
83 | async (e, activeUsers) => {
84 | e.preventDefault();
85 | if (!input.trim()) return;
86 |
87 | const newMessage = {
88 | id: uuidv4(),
89 | username,
90 | content: input,
91 | type: "text",
92 | timestamp: new Date().toLocaleTimeString([], {
93 | hour: "numeric",
94 | minute: "2-digit",
95 | }),
96 | status: "sending",
97 | reactions: {}, // Add reactions object
98 | activeUsers: activeUsers,
99 | messageSeen: "",
100 | };
101 |
102 | setMessages((prev) => [...prev, newMessage]);
103 | setInput("");
104 |
105 | try {
106 | await axios.post("/api/messages", { channel, message: newMessage });
107 | setMessages((prev) =>
108 | prev.map((msg) =>
109 | msg.id === newMessage.id ? { ...msg, status: "sent" } : msg
110 | )
111 | );
112 | } catch (error) {
113 | setMessages((prev) =>
114 | prev.map((msg) =>
115 | msg.id === newMessage.id ? { ...msg, status: "failed" } : msg
116 | )
117 | );
118 | handleApiError(error, "sendMessage");
119 | }
120 | },
121 | [input, channel, username]
122 | );
123 |
124 | // Send a file
125 | const sendFile = useCallback(
126 | async (file, imageCaption, activeUsers) => {
127 | if (!file) return;
128 |
129 | const filePreviewUrl = URL.createObjectURL(file);
130 |
131 | // Determine file type
132 | const fileType = file.type.startsWith("video")
133 | ? "video"
134 | : file.type.startsWith("image")
135 | ? "image"
136 | : "file"; // Generic file type for others
137 |
138 | const newMessage = {
139 | id: uuidv4(),
140 | username,
141 | content: filePreviewUrl, // Local preview for the file
142 | type: fileType,
143 | timestamp: new Date().toLocaleTimeString([], {
144 | hour: "numeric",
145 | minute: "2-digit",
146 | }),
147 | imageCaption: imageCaption || "",
148 | status: "sending",
149 | reactions: {}, // Add reactions object
150 | activeUsers: activeUsers,
151 | messageSeen: "",
152 | };
153 |
154 | setMessages((prev) => [...prev, newMessage]);
155 |
156 | const formData = new FormData();
157 | formData.append("file", file); // Use 'file' as the form data key
158 |
159 | try {
160 | // Upload file to the server
161 | const { data } = await axios.post("/api/upload", formData);
162 |
163 | if (data.success) {
164 | const uploadedMessage = {
165 | ...newMessage,
166 | content: data.fileUrl, // Replace preview with actual file URL
167 | status: "sent",
168 | };
169 |
170 | // Update message locally with uploaded file URL
171 | setMessages((prev) =>
172 | prev.map((msg) =>
173 | msg.id === newMessage.id ? uploadedMessage : msg
174 | )
175 | );
176 |
177 | // Send final message to the backend
178 | await axios.post("/api/messages", {
179 | channel,
180 | message: uploadedMessage,
181 | });
182 | } else {
183 | throw new Error(data.error || "File upload failed");
184 | }
185 | } catch (error) {
186 | setMessages((prev) =>
187 | prev.map((msg) =>
188 | msg.id === newMessage.id ? { ...msg, status: "failed" } : msg
189 | )
190 | );
191 | handleApiError(error, "sendFile");
192 | } finally {
193 | URL.revokeObjectURL(filePreviewUrl); // Clean up memory
194 | }
195 | },
196 | [channel, username]
197 | );
198 |
199 | const addReaction = async (messageId, emoji) => {
200 | // Optimistically update the UI
201 | setMessages((prev) =>
202 | prev.map((msg) => {
203 | if (msg.id === messageId) {
204 | const updatedReactions = { ...msg.reactions };
205 | if (updatedReactions[username] === emoji) {
206 | // If the same emoji exists, remove it
207 | delete updatedReactions[username];
208 | } else {
209 | // Otherwise, add/update the reaction
210 | updatedReactions[username] = emoji;
211 | }
212 | return { ...msg, reactions: updatedReactions };
213 | }
214 | return msg;
215 | })
216 | );
217 |
218 | try {
219 | // Send the API request
220 | const { data } = await axios.patch("/api/messages", {
221 | messageId,
222 | emoji: emoji, // Send the emoji to add or remove
223 | username, // Ensure this is passed
224 | });
225 |
226 | // Update state with confirmed reactions from the server
227 | setMessages((prev) =>
228 | prev.map((msg) =>
229 | msg.id === messageId ? { ...msg, reactions: data.reactions } : msg
230 | )
231 | );
232 | } catch (error) {
233 | console.error("Error in addReaction:", error.message || error);
234 |
235 | // Revert the optimistic update if the API call fails
236 | setMessages((prev) =>
237 | prev.map((msg) => {
238 | if (msg.id === messageId) {
239 | const updatedReactions = { ...msg.reactions };
240 | if (updatedReactions[username] === emoji) {
241 | // Re-add the removed reaction on failure
242 | updatedReactions[username] = emoji;
243 | } else {
244 | // Remove the optimistic reaction if it was added
245 | delete updatedReactions[username];
246 | }
247 | return { ...msg, reactions: updatedReactions };
248 | }
249 | return msg;
250 | })
251 | );
252 | }
253 | };
254 |
255 | const messageSeen = async (messageId) => {
256 | try {
257 | // Send the API request
258 | const { data } = await axios.patch("/api/messages/seen", {
259 | messageId,
260 | username,
261 | });
262 |
263 | // Update state with confirmed reactions from the server
264 | } catch (error) {
265 | console.error("Error updating message seen:", error.message || error);
266 | }
267 | };
268 |
269 | return {
270 | messages,
271 | setMessages,
272 | setInput,
273 | input,
274 | sendMessage,
275 | sendFile,
276 | fetchMessages,
277 | addReaction,
278 | messageSeen,
279 | };
280 | };
281 |
--------------------------------------------------------------------------------
/app/pages/ChatBox.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, useRef } from "react";
2 | import { useParams } from "next/navigation";
3 | import Link from "next/link";
4 | import { IoMdCheckmarkCircle } from "react-icons/io";
5 | import { MdOutlineRadioButtonUnchecked } from "react-icons/md";
6 | import { RxCrossCircled } from "react-icons/rx";
7 | import ReactionComponent from "../components/ReactionComponent";
8 | import { MdArrowBack } from "react-icons/md";
9 | import MessageInput from "../components/MessageInput";
10 | import { useSendMessage } from "../hooks/useMessages";
11 | import VideoPlayer from "../components/VideoPlayer";
12 | import { usePusher } from "../hooks/usePusher";
13 | import { FaCircle } from "react-icons/fa";
14 |
15 | export default function ChatBox() {
16 | const params = useParams();
17 | const channel = params.channel.replace(/[^a-zA-Z0-9]/g, "specialchars");
18 |
19 | const displayChannel =
20 | decodeURIComponent(params.channel).trim().charAt(0).toUpperCase() +
21 | decodeURIComponent(params.channel).slice(1);
22 | const [username, setUsername] = useState("");
23 | const [clickedMessageId, setClickedMessageId] = useState(null);
24 | const [selectedImage, setSelectedImage] = useState(null);
25 | // State for selected video
26 | const [showUsernamePrompt, setShowUsernamePrompt] = useState(false);
27 | const [tempUsername, setTempUsername] = useState("");
28 | const { activeUsers } = usePusher(username, channel);
29 | const chatEndRef = useRef(null);
30 |
31 | const {
32 | messages,
33 | fetchMessages,
34 | input,
35 | setInput,
36 | sendMessage,
37 | sendFile,
38 | addReaction,
39 | messageSeen,
40 | } = useSendMessage(username, channel);
41 |
42 | useEffect(() => {
43 | const storedName = localStorage.getItem("username");
44 | if (storedName) {
45 | setUsername(storedName);
46 | fetchMessages();
47 | } else {
48 | setShowUsernamePrompt(true);
49 | }
50 | }, [channel]);
51 |
52 | useEffect(() => {
53 | const handleClickOutside = (e) => {
54 | // Don't close if clicking on message or emoji
55 | if (e.target.closest(".message") || e.target.closest(".emoji-panel")) {
56 | return;
57 | }
58 | setClickedMessageId(null);
59 | };
60 |
61 | window.addEventListener("mousedown", handleClickOutside);
62 | return () => window.removeEventListener("mousedown", handleClickOutside);
63 | }, []);
64 |
65 | // Update message click handler
66 |
67 | const handleUsernameSubmit = () => {
68 | if (tempUsername.trim()) {
69 | localStorage.setItem("username", tempUsername);
70 | setUsername(tempUsername);
71 | setShowUsernamePrompt(false);
72 | fetchMessages();
73 | }
74 | };
75 | const [currentVideo, setCurrentVideo] = useState(null);
76 |
77 | const handleVideoPlay = (videoRef, videoSrc) => {
78 | // Pause other videos
79 | if (currentVideo && currentVideo !== videoRef) {
80 | currentVideo.pause();
81 | }
82 |
83 | // Update the current video
84 | setCurrentVideo(videoRef);
85 | };
86 |
87 | const generateGradient = (name) => {
88 | // Simple gradient generation based on name (or initials)
89 | const charCodeSum = name.charCodeAt(0) + name.charCodeAt(name.length - 1); // Simple method of hashing
90 | const gradientIndex = charCodeSum % 5; // Limit gradients to 5 possibilities
91 |
92 | const gradients = [
93 | "from-emerald-400 to-cyan-400", // Sky to Cyan
94 | "from-red-500 to-orange-500", // Pink to Purple
95 | "from-fuchsia-500 to-pink-500", // Green to Blue
96 | "from-lime-400 to-lime-500", // Yellow to Red
97 | "from-emerald-400 to-cyan-400", // Indigo to Purple
98 | ];
99 |
100 | return gradients[gradientIndex];
101 | };
102 |
103 | const [imageLoaded, setImageLoaded] = useState(false);
104 | const handleImageLoad = () => setImageLoaded(true);
105 |
106 | const [videoLoaded, setVideoLoaded] = useState(false);
107 | const handleVideoLoad = () => setVideoLoaded(true);
108 |
109 | const [handleMessageSend, setHandleMessageSend] = useState(false);
110 | // useEffect(() => {
111 | // if (imageLoaded || !messages.some((msg) => msg.type === "image")) {
112 | // chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
113 | // }
114 | // }, [messages, imageLoaded, videoLoaded, clickedMessageId]);
115 | const prevMessagesLength = useRef(messages.length);
116 | useEffect(() => {
117 | if (
118 | messages.length > prevMessagesLength.current ||
119 | handleMessageSend ||
120 | imageLoaded ||
121 | videoLoaded
122 | ) {
123 | chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
124 | setHandleMessageSend(false);
125 | setImageLoaded(false);
126 | setVideoLoaded(false);
127 | }
128 | prevMessagesLength.current = messages.length;
129 | }, [messages, imageLoaded, videoLoaded, handleMessageSend]);
130 |
131 | const messageRefs = useRef({});
132 | // Function to handle regular click on the message
133 | const handleMessageContentClick = (e, msgId, msgType) => {
134 | console.log(msgId);
135 | if (msgType === "text") {
136 | messageRefs.current[msgId]?.scrollIntoView({
137 | behavior: "smooth",
138 | block: "center",
139 | });
140 | } else {
141 | messageRefs.current[msgId]?.scrollIntoView({
142 | behavior: "smooth",
143 | block: "center",
144 | });
145 | }
146 |
147 | e.stopPropagation();
148 | setClickedMessageId((prev) => (prev != msgId ? msgId : null));
149 | };
150 |
151 | const handleEmojiClick = async (messageId, emoji) => {
152 | try {
153 | setClickedMessageId(null);
154 | // Optimistically update the emojiData
155 |
156 | // Call the addReaction method to persist the change
157 | await addReaction(messageId, emoji);
158 | } catch (error) {
159 | console.error("Error adding reaction:", error);
160 |
161 | // Revert the optimistic update if the API call fails
162 | }
163 | };
164 |
165 | const seenMessages = useRef(new Set()); // Set to track seen messages
166 |
167 | useEffect(() => {
168 | const observer = new IntersectionObserver(
169 | (entries) => {
170 | entries.forEach((entry) => {
171 | if (entry.isIntersecting) {
172 | // Find the corresponding message ID from messageRefs
173 | const messageId = Object.keys(messageRefs.current).find(
174 | (id) => messageRefs.current[id] === entry.target
175 | );
176 |
177 | if (messageId && !seenMessages.current.has(messageId)) {
178 | // Add messageId to the seen set
179 | seenMessages.current.add(messageId);
180 | const message = messages.find((msg) => msg.id === messageId);
181 | if (message.username !== username) {
182 | messageSeen(messageId);
183 | }
184 | // Call the callback for the message
185 | }
186 | }
187 | });
188 | },
189 | { threshold: 0.5 } // Adjust threshold as needed
190 | );
191 |
192 | // Observe each message ref
193 | Object.values(messageRefs.current).forEach((ref) => {
194 | if (ref) observer.observe(ref);
195 | });
196 |
197 | return () => {
198 | // Unobserve on cleanup
199 | Object.values(messageRefs.current).forEach((ref) => {
200 | if (ref) observer.unobserve(ref);
201 | });
202 | };
203 | }, [messages]);
204 |
205 | // Add handler for emoji clicks
206 |
207 | return (
208 |
209 | {/* Username Prompt Overlay */}
210 | {showUsernamePrompt && (
211 |
212 |
213 |
217 |
218 | Join {displayChannel}
219 |
220 |
221 | {/* Name Input */}
222 | setTempUsername(e.target.value)}
227 | className="p-2 rounded-lg bg-gray-700 text-white focus:outline-none"
228 | />
229 | {/* Channel Name Input */}
230 |
231 | {/* Submit Button */}
232 |
239 |
240 |
241 |
242 |
243 | )}
244 |
248 |
253 |
254 | {/* Messages */}
255 |
256 |
257 |
263 | {channel.charAt(0).toUpperCase()}
264 |
265 |
{displayChannel}
266 |
267 |
268 |
0 ? "text-[#33ff33]" : "text-gray-500"
271 | }`}
272 | />
273 |
274 |
275 | Online: {activeUsers ? activeUsers?.length : 0}{" "}
276 |
277 |
278 |
279 |
280 | {/* Messages */}
281 | {
282 |
287 | {messages.map((msg, index) => {
288 | const isFirstMessage = index === 0;
289 | const isSameUserAsPrevious =
290 | !isFirstMessage && messages[index - 1].username === msg.username;
291 | const isLastMessageFromUser =
292 | index === messages.length - 1 ||
293 | messages[index + 1].username !== msg.username;
294 | const isUserActive = msg.activeUsers?.some(
295 | (user) => user.username === username
296 | );
297 |
298 | // Check for conditions: either more than one user or exactly one user
299 | const shouldDisplayContent =
300 | (isUserActive && msg.activeUsers?.length > 1) ||
301 | !msg.messageSeen ||
302 | msg.messageSeen === username;
303 |
304 | return (
305 |
(messageRefs.current[msg.id] = el)}
308 | className={` relative flex flex-col ${
309 | msg.username === username ? "items-end" : "items-start "
310 | }`}
311 | >
312 | {clickedMessageId === msg.id && (
313 |
320 |
321 | {["❤️", "😆", "😮", "😢", "😡"].map((emoji) => (
322 |
handleEmojiClick(msg.id, emoji)}
325 | className={`${
326 | msg.reactions && msg.reactions[username] === emoji
327 | ? "selected bg-gray-600 "
328 | : ""
329 | } hover:-translate-y-1 cursor-pointer text-[1.4rem] transition-all rounded-xl duration-200 px-1`}
330 | >
331 | {emoji}
332 |
333 | ))}
334 |
335 |
336 | )}{" "}
337 |
348 | {msg.username !== username && (
349 |
360 | {msg.username.charAt(0).toUpperCase()}
361 |
362 | )}
363 |
364 | {msg.type === "text" && (
365 |
376 | handleMessageContentClick(e, msg.id, msg.type)
377 | } // Regular click
378 | >
379 |
380 | {msg.content.includes("http") ? (
381 |
382 |
387 | {" "}
388 | {msg.content}
389 |
390 |
394 |
395 | ) : (
396 |
397 | {msg.content}{" "}
398 |
402 |
403 | )}
404 |
405 |
406 |
407 | {msg.timestamp}
408 |
409 | {msg.username === username &&
410 | (msg.status === "sent" ? (
411 |
412 |
413 |
414 | ) : msg.status === "sending" ? (
415 |
416 |
417 |
418 | ) : (
419 |
420 |
421 |
422 | ))}
423 |
424 |
425 |
426 | )}
427 |
428 | {msg.type === "image" && (
429 |
430 |
431 |

setSelectedImage(msg.content)}
438 | />
439 |
440 |
441 |
443 | handleMessageContentClick(e, msg.id, msg.type)
444 | }
445 | className={`flex flex-wrap ${
446 | msg.imageCaption ? "justify-between" : "justify-end"
447 | } items-center px-[0.5rem] py-[0.16rem] mt-1 space-y-1 gap-x-1`}
448 | >
449 | {msg.imageCaption && (
450 |
451 | {msg.imageCaption.includes("http") ? (
452 |
457 | {msg.imageCaption}
458 |
459 | ) : (
460 |
461 | {msg.imageCaption}
462 |
463 | )}
464 |
465 | )}
466 |
467 | {!(
468 | Object.values(msg?.reactions || {}).length > 0
469 | ) && (
470 |
471 | {/*
*/}
472 |
473 | {msg.timestamp}
474 |
475 | {msg.username === username &&
476 | (msg.status === "sent" ? (
477 |
478 |
479 |
480 | ) : msg.status === "sending" ? (
481 |
482 |
483 |
484 | ) : (
485 |
486 |
487 |
488 | ))}
489 |
490 | )}
491 |
492 |
493 | {/* {!msg.imageCaption && (
494 |
495 | {msg.timestamp}
496 | {msg.username === username &&
497 | (msg.status === "sent" ? (
498 |
499 | ) : msg.status === "sending" ? (
500 |
501 | ) : (
502 |
503 | ))}
504 |
505 | )} */}
506 | {/* {msg.imageCaption && (
507 |
508 | {msg.imageCaption.includes("http") ? (
509 |
514 | {msg.imageCaption}
515 |
516 | ) : (
517 |
518 | {msg.imageCaption}
519 |
520 | )}
521 | {msg.username === username &&
522 | (msg.status === "sent" ? (
523 |
524 |
525 |
526 | ) : msg.status === "sending" ? (
527 |
528 |
529 |
530 | ) : (
531 |
532 |
533 |
534 | ))}
535 |
536 | )} */}
537 |
538 | )}
539 |
540 | {msg.type === "video" && (
541 |
542 |
547 |
548 |
550 | handleMessageContentClick(e, msg.id, msg.type)
551 | }
552 | className={`flex flex-wrap ${
553 | msg.imageCaption ? "justify-between" : "justify-end"
554 | } items-center px-[0.5rem] py-[0.16rem] mt-1 space-y-1 gap-x-1`}
555 | >
556 | {msg.imageCaption && (
557 |
558 | {msg.imageCaption.includes("http") ? (
559 |
564 | {msg.imageCaption}
565 |
566 | ) : (
567 |
568 | {msg.imageCaption}
569 |
570 | )}
571 |
572 | )}
573 |
574 | {!(
575 | Object.values(msg?.reactions || {}).length > 0
576 | ) && (
577 |
578 | {/*
*/}
579 |
580 | {msg.timestamp}
581 |
582 | {msg.username === username &&
583 | (msg.status === "sent" ? (
584 |
585 |
586 |
587 | ) : msg.status === "sending" ? (
588 |
589 |
590 |
591 | ) : (
592 |
593 |
594 |
595 | ))}
596 |
597 | )}
598 |
599 |
600 | )}
601 |
602 | {msg.username === username && (
603 |
614 | {msg.username.charAt(0).toUpperCase()}
615 |
616 | )}
617 |
618 |
619 | );
620 | })}
621 |
622 |
623 | }
624 |
625 | {/* Input */}
626 | {!selectedImage && (
627 |
635 | )}
636 |
637 | {/* Image Modal */}
638 | {selectedImage && (
639 |
640 |
641 |
648 |

653 |
654 |
655 | )}
656 |
657 | );
658 | }
659 |
--------------------------------------------------------------------------------