From c7e44a4eb631c8ed0e206939b8c6cd1f9a229252 Mon Sep 17 00:00:00 2001 From: Rohank3 Date: Sat, 11 Apr 2026 21:30:37 +0530 Subject: [PATCH 01/10] Implemented the group Chat feature --- app/api/chat/messages/[id]/route.ts | 109 +++++++++++ app/api/chat/messages/route.ts | 86 +++++++++ app/chat/page.tsx | 276 ++++++++++++++++++++++------ app/layout.jsx | 2 + components/chat/ChatMessage.tsx | 204 ++++++++++++++++++++ components/chat/ChatWidget.tsx | 261 ++++++++++++++++++++++++++ components/message.tsx | 34 ---- middleware.ts | 2 + model/Message.ts | 30 +++ 9 files changed, 916 insertions(+), 88 deletions(-) create mode 100644 app/api/chat/messages/[id]/route.ts create mode 100644 app/api/chat/messages/route.ts create mode 100644 components/chat/ChatMessage.tsx create mode 100644 components/chat/ChatWidget.tsx delete mode 100644 components/message.tsx create mode 100644 model/Message.ts diff --git a/app/api/chat/messages/[id]/route.ts b/app/api/chat/messages/[id]/route.ts new file mode 100644 index 0000000..04f52ef --- /dev/null +++ b/app/api/chat/messages/[id]/route.ts @@ -0,0 +1,109 @@ +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth/next' +import { authOptions } from '@/app/api/auth/[...nextauth]/options' +import dbConnect from '@/lib/dbConnect' +import Message from '@/model/Message' + +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> } // In Next 15+ we technically should await params if it's dynamic but let's check config, typically Next 14 handles it synchronously but App Router changed params to Promise in Next 15 depending on usage. Actually, Next 15 recommends `await params`. Let's do `const { id } = await params`. +) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + const { content } = await req.json() + + if (!content || !content.trim()) { + return NextResponse.json( + { error: 'Message content is required' }, + { status: 400 } + ) + } + + await dbConnect() + + const message = await Message.findById(id) + if (!message) { + return NextResponse.json({ error: 'Message not found' }, { status: 404 }) + } + + // Verify ownership + if (message.sender.toString() !== session.user.id) { + return NextResponse.json( + { error: 'You can only edit your own messages' }, + { status: 403 } + ) + } + + if (message.isDeleted) { + return NextResponse.json( + { error: 'Cannot edit a deleted message' }, + { status: 400 } + ) + } + + message.content = content.trim() + message.isEdited = true + await message.save() + + return NextResponse.json(message, { status: 200 }) + } catch (error) { + console.error('Error updating message:', error) + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ) + } +} + +export async function DELETE( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + + await dbConnect() + + const message = await Message.findById(id) + if (!message) { + return NextResponse.json({ error: 'Message not found' }, { status: 404 }) + } + + // Verify ownership or check if admin. But since roles are available on session, we can optionally allow admins. + // However, specifically the user should be the sender. Let's strictly enforce sender for now. + if ( + message.sender.toString() !== session.user.id && + !session.user.roles?.includes('admin') + ) { + return NextResponse.json( + { error: 'You can only delete your own messages' }, + { status: 403 } + ) + } + + // Soft delete + message.content = '🚫 This message was deleted.' + message.isDeleted = true + await message.save() + + return NextResponse.json({ success: true }, { status: 200 }) + } catch (error) { + console.error('Error deleting message:', error) + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ) + } +} diff --git a/app/api/chat/messages/route.ts b/app/api/chat/messages/route.ts new file mode 100644 index 0000000..0ff24e9 --- /dev/null +++ b/app/api/chat/messages/route.ts @@ -0,0 +1,86 @@ +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth/next' +import { authOptions } from '@/app/api/auth/[...nextauth]/options' +import dbConnect from '@/lib/dbConnect' +import Message from '@/model/Message' + +export const dynamic = 'force-dynamic' + +export async function GET() { + try { + await dbConnect() + + // Fetch latest 100 messages + const messages = await Message.find() + .sort({ timestamp: -1 }) + .limit(100) + .populate('sender', 'name image email') // Populate sender with limited fields + .populate({ + path: 'replyTo', + populate: { path: 'sender', select: 'name' }, // Populate the sender of the replied message + }) + .exec() + + // Return messages in chronological order (oldest first) + return NextResponse.json(messages.reverse(), { status: 200 }) + } catch (error) { + console.error('Error fetching messages:', error) + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ) + } +} + +export async function POST(req: Request) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Access control: only @iiitl.ac.in emails can send messages + if (!session.user.email.toLowerCase().endsWith('@iiitl.ac.in')) { + return NextResponse.json( + { error: 'Only IIITL students can send messages' }, + { status: 403 } + ) + } + + const { content, replyTo } = await req.json() + + if (!content || !content.trim()) { + return NextResponse.json( + { error: 'Message content is required' }, + { status: 400 } + ) + } + + await dbConnect() + + const newMessage = await Message.create({ + sender: session.user.id, + email: session.user.email, + content: content.trim(), + replyTo: replyTo || null, + }) + + // Populate sender before returning to immediately show in UI if needed + await newMessage.populate('sender', 'name image email') + if (newMessage.replyTo) { + await newMessage.populate({ + path: 'replyTo', + populate: { path: 'sender', select: 'name' }, + }) + } + + return NextResponse.json(newMessage, { status: 201 }) + } catch (error) { + console.error('Error creating message:', error) + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ) + } +} diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 969e275..fea6aac 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -1,69 +1,237 @@ 'use client' -import Message from '@/components/message' -import Sidebar from '@/components/sidebar' + +import React, { useState, useEffect, useRef } from 'react' +import { useSession } from 'next-auth/react' +import { MessageCircle, Send, XCircle } from 'lucide-react' import { Button } from '@/components/ui/button' -import sample_message from '@/data/sample_message' -import React, { useState } from 'react' +import ChatMessage, { MessageData } from '@/components/chat/ChatMessage' -interface Participant { - name: string - avatar: string -} +export default function ChatPage() { + const { data: session } = useSession() + const [messages, setMessages] = useState([]) + const [inputText, setInputText] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState('') -interface MessageType { - text: string - sender: string - senderAvatar: string - timestamp: string -} -export default function Page() { - const [message, setMessage] = useState('') - const [messages, setMessages] = useState( - sample_message.user_message - ) - const [participants] = useState(sample_message.participants) - - const handleSendMessage = () => { - if (message.trim()) { - const newMessage: MessageType = { - text: message, - sender: 'User1', - senderAvatar: - 'https://th.bing.com/th/id/OIP.7SSen49kIgXR90Ii5VLYUAHaJQ?w=136&h=180&c=7&r=0&o=5&dpr=1.3&pid=1.7', - timestamp: new Date().toLocaleTimeString(), + const [editingMessage, setEditingMessage] = useState(null) + const [replyingTo, setReplyingTo] = useState(null) + + const messagesEndRef = useRef(null) + const isIIITLUser = session?.user?.email?.toLowerCase()?.endsWith('@iiitl.ac.in') + const currentUserId = session?.user?.id + + // Polling logic + useEffect(() => { + const fetchMessages = async () => { + try { + const res = await fetch('/api/chat/messages') + if (res.ok) { + const data = await res.json() + setMessages(data) + } + } catch (err) { + console.error('Failed to fetch messages:', err) + } + } + + fetchMessages() // Fetch immediately on mount + const interval = setInterval(fetchMessages, 3000) // Poll every 3 seconds + + return () => clearInterval(interval) + }, []) + + // Scroll to bottom when new messages arrive if not editing + useEffect(() => { + if (!editingMessage && messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }) + } + }, [messages, editingMessage]) + + const handleSend = async () => { + if (!inputText.trim()) return + + setIsLoading(true) + setError('') + + try { + if (editingMessage) { + // Edit flow + const res = await fetch(`/api/chat/messages/${editingMessage._id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: inputText }), + }) + if (!res.ok) throw new Error((await res.json()).error) + setEditingMessage(null) + } else { + // Send / Reply flow + const payload = replyingTo + ? { content: inputText, replyTo: replyingTo._id } + : { content: inputText } + + const res = await fetch('/api/chat/messages', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + if (!res.ok) throw new Error((await res.json()).error) + setReplyingTo(null) + } + + setInputText('') + const fetchRes = await fetch('/api/chat/messages') + if (fetchRes.ok) setMessages(await fetchRes.json()) + } catch (err: unknown) { + if (err instanceof Error) { + setError(err.message || 'Failed to send message') + } else { + setError('Failed to send message') } - setMessages([...messages, newMessage]) - setMessage('') + } finally { + setIsLoading(false) } } + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + + const startReply = (msg: MessageData) => { + setEditingMessage(null) + setReplyingTo(msg) + setInputText('') + } + + const startEdit = (msg: MessageData) => { + if (msg.isDeleted) return + setReplyingTo(null) + setEditingMessage(msg) + setInputText(msg.content) + } + + const handleDeleteMessage = async (id: string) => { + try { + const res = await fetch(`/api/chat/messages/${id}`, { + method: 'DELETE', + }) + if (!res.ok) throw new Error('Failed to delete') + + const fetchRes = await fetch('/api/chat/messages') + if (fetchRes.ok) setMessages(await fetchRes.json()) + + if (editingMessage?._id === id) { + cancelAction() + } + } catch (err: unknown) { + if (err instanceof Error) { + alert(err.message) + } else { + alert('An error occurred') + } + } + } + + const cancelAction = () => { + setReplyingTo(null) + setEditingMessage(null) + setInputText('') + } + return ( -
-
- -
-
- {messages.map((msg, index) => ( - - ))} +
+
+ {/* Header */} +
+
+
+

IIITL General Chat

-
- setMessage(e.target.value)} - placeholder="Type a message..." - className="flex-1 p-3 border border-muted-foreground rounded-lg mr-3" - /> - +
+ Real-time Community
+ + {/* Message Area */} +
+ {messages.length === 0 ? ( +
+
+ +
+

No messages yet. Be the first to say hi!

+
+ ) : ( +
+ {messages.map((msg) => ( + + ))} +
+
+ )} +
+ + {/* Auth / Read-only warning */} + {!isIIITLUser && ( +
+ Read-only mode. You need an @iiitl.ac.in email to send messages in the group chat. +
+ )} + + {/* Input Area */} + {session && isIIITLUser && ( +
+ {/* Status Banner for Replying/Editing */} + {(replyingTo || editingMessage) && ( +
+ {replyingTo && ( + + Replying to {typeof replyingTo.sender === 'object' && replyingTo.sender !== null ? (replyingTo.sender as { name: string }).name : 'someone'} + + )} + {editingMessage && ( + Editing message + )} + +
+ )} + +
+ setInputText(e.target.value)} + onKeyDown={handleKeyDown} + className="flex-1 bg-muted border border-transparent p-4 rounded-full outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary/50 text-base transition-all" + disabled={isLoading} + /> + +
+ {error && ( +
+ {error} +
+ )} +
+ )}
) diff --git a/app/layout.jsx b/app/layout.jsx index c40e9cc..5279cf3 100644 --- a/app/layout.jsx +++ b/app/layout.jsx @@ -5,6 +5,7 @@ import Header from '@/components/header' import AuthProvider from '@/context/session-provider' import { Analytics } from '@vercel/analytics/next' import Script from 'next/script' +import ChatWidget from '@/components/chat/ChatWidget' const inter = Inter({ subsets: ['latin'] }) @@ -25,6 +26,7 @@ export default function RootLayout({ children }) {
{children}