diff --git a/Backend/app/db/db.py b/Backend/app/db/db.py index dfcfccb..ae0f517 100644 --- a/Backend/app/db/db.py +++ b/Backend/app/db/db.py @@ -19,7 +19,9 @@ # Initialize async SQLAlchemy components try: - engine = create_async_engine(DATABASE_URL, echo=True, connect_args={"ssl": "require"}) + engine = create_async_engine( + DATABASE_URL, echo=True, connect_args={"ssl": "require"} + ) AsyncSessionLocal = sessionmaker( bind=engine, class_=AsyncSession, expire_on_commit=False @@ -31,3 +33,8 @@ engine = None AsyncSessionLocal = None Base = None + + +async def get_db(): + async with AsyncSessionLocal() as session: + yield session diff --git a/Backend/app/db/seed.py b/Backend/app/db/seed.py new file mode 100644 index 0000000..9b8936a --- /dev/null +++ b/Backend/app/db/seed.py @@ -0,0 +1,54 @@ +from datetime import datetime, timezone +from db.db import AsyncSessionLocal +from models.models import User + + +async def seed_db(): + users = [ + { + "id": "aabb1fd8-ba93-4e8c-976e-35e5c40b809c", + "username": "creator1", + "email": "creator1@example.com", + "password": "password123", + "role": "creator", + "bio": "Lifestyle and travel content creator", + }, + { + "id": "6dbfcdd5-795f-49c1-8f7a-a5538b8c6f6f", + "username": "brand1", + "email": "brand1@example.com", + "password": "password123", + "role": "brand", + "bio": "Sustainable fashion brand looking for influencers", + }, + ] + + # Insert or update the users + async with AsyncSessionLocal() as session: + for user_data in users: + # Check if user exists + existing_user = await session.execute( + User.__table__.select().where(User.email == user_data["email"]) + ) + existing_user = existing_user.scalar_one_or_none() + + if existing_user: + continue + else: + # Create new user + user = User( + id=user_data["id"], + username=user_data["username"], + email=user_data["email"], + password_hash=user_data[ + "password" + ], # Using plain password directly + role=user_data["role"], + bio=user_data["bio"], + ) + session.add(user) + print(f"Created user: {user_data['email']}") + + # Commit the session + await session.commit() + print("✅ Users seeded successfully.") diff --git a/Backend/app/main.py b/Backend/app/main.py index 5cd581a..41ef7b7 100644 --- a/Backend/app/main.py +++ b/Backend/app/main.py @@ -1,7 +1,10 @@ from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware from db.db import engine -from models import models +from db.seed import seed_db +from models import models, chat from routes.post import router as post_router +from routes.chat import router as chat_router from sqlalchemy.exc import SQLAlchemyError import logging import os @@ -11,28 +14,44 @@ # Load environment variables load_dotenv() + # Async function to create database tables with exception handling async def create_tables(): try: async with engine.begin() as conn: await conn.run_sync(models.Base.metadata.create_all) + await conn.run_sync(chat.Base.metadata.create_all) print("✅ Tables created successfully or already exist.") except SQLAlchemyError as e: print(f"❌ Error creating tables: {e}") -#Lifespan context manager for startup and shutdown events + +# Lifespan context manager for startup and shutdown events @asynccontextmanager async def lifespan(app: FastAPI): print("App is starting...") await create_tables() + await seed_db() yield print("App is shutting down...") + # Initialize FastAPI app = FastAPI(lifespan=lifespan) +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + # Include the routes app.include_router(post_router) +app.include_router(chat_router) + @app.get("/") async def home(): diff --git a/Backend/app/models/chat.py b/Backend/app/models/chat.py new file mode 100644 index 0000000..5727cbf --- /dev/null +++ b/Backend/app/models/chat.py @@ -0,0 +1,54 @@ +from sqlalchemy import Column, String, ForeignKey, DateTime, Enum, UniqueConstraint +from sqlalchemy.orm import relationship +from datetime import datetime, timezone +from db.db import Base +import uuid +import enum + + +def generate_uuid(): + return str(uuid.uuid4()) + + +class MessageStatus(enum.Enum): + SENT = "sent" + DELIVERED = "delivered" + SEEN = "seen" + + +class ChatList(Base): + __tablename__ = "chat_list" + + id = Column(String, primary_key=True, default=generate_uuid) + user1_id = Column(String, ForeignKey("users.id"), nullable=False) + user2_id = Column(String, ForeignKey("users.id"), nullable=False) + last_message_time = Column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + user1 = relationship("User", foreign_keys=[user1_id], backref="chatlist_user1") + user2 = relationship("User", foreign_keys=[user2_id], backref="chatlist_user2") + + __table_args__ = (UniqueConstraint("user1_id", "user2_id", name="unique_chat"),) + + +class ChatMessage(Base): + __tablename__ = "chat_messages" + + id = Column(String, primary_key=True, default=generate_uuid) + sender_id = Column(String, ForeignKey("users.id"), nullable=False) + receiver_id = Column(String, ForeignKey("users.id"), nullable=False) + message = Column(String, nullable=False) + status = Column( + Enum(MessageStatus), default=MessageStatus.SENT + ) # Using the enum class + created_at = Column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + sender = relationship("User", foreign_keys=[sender_id], backref="sent_messages") + receiver = relationship( + "User", foreign_keys=[receiver_id], backref="received_messages" + ) + chat_list_id = Column(String, ForeignKey("chat_list.id"), nullable=False) + chat = relationship("ChatList", backref="messages") diff --git a/Backend/app/models/models.py b/Backend/app/models/models.py index 6a9c4a0..6a232a2 100644 --- a/Backend/app/models/models.py +++ b/Backend/app/models/models.py @@ -1,12 +1,25 @@ -from sqlalchemy import Column, String, Integer, ForeignKey, Float, Text, JSON, DECIMAL, TIMESTAMP +from sqlalchemy import ( + Column, + String, + Integer, + ForeignKey, + Float, + Text, + JSON, + DECIMAL, + DateTime, + Boolean, +) from sqlalchemy.orm import relationship from datetime import datetime, timezone from db.db import Base import uuid + def generate_uuid(): return str(uuid.uuid4()) + # User Table (Creators & Brands) class User(Base): __tablename__ = "users" @@ -18,13 +31,30 @@ class User(Base): role = Column(String, nullable=False) # 'creator' or 'brand' profile_image = Column(Text, nullable=True) bio = Column(Text, nullable=True) - created_at = Column(TIMESTAMP, default=lambda: datetime.now(timezone.utc)) + created_at = Column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + is_online = Column(Boolean, default=False) # ✅ Track if user is online + last_seen = Column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) audience = relationship("AudienceInsights", back_populates="user", uselist=False) sponsorships = relationship("Sponsorship", back_populates="brand") posts = relationship("UserPost", back_populates="user") applications = relationship("SponsorshipApplication", back_populates="creator") - payments = relationship("SponsorshipPayment", back_populates="creator") + payments = relationship( + "SponsorshipPayment", + foreign_keys="[SponsorshipPayment.creator_id]", + back_populates="creator", + ) + brand_payments = relationship( + "SponsorshipPayment", + foreign_keys="[SponsorshipPayment.brand_id]", + back_populates="brand", + ) + # Audience Insights Table class AudienceInsights(Base): @@ -38,10 +68,13 @@ class AudienceInsights(Base): average_views = Column(Integer) time_of_attention = Column(Integer) # in seconds price_expectation = Column(DECIMAL(10, 2)) - created_at = Column(TIMESTAMP, default=lambda: datetime.now(timezone.utc)) + created_at = Column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) user = relationship("User", back_populates="audience") + # Sponsorship Table (For Brands) class Sponsorship(Base): __tablename__ = "sponsorships" @@ -54,11 +87,14 @@ class Sponsorship(Base): budget = Column(DECIMAL(10, 2)) engagement_minimum = Column(Float) status = Column(String, default="open") - created_at = Column(TIMESTAMP, default=lambda: datetime.now(timezone.utc)) + created_at = Column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) brand = relationship("User", back_populates="sponsorships") applications = relationship("SponsorshipApplication", back_populates="sponsorship") + # User Posts Table class UserPost(Base): __tablename__ = "user_posts" @@ -70,10 +106,13 @@ class UserPost(Base): post_url = Column(Text, nullable=True) category = Column(String, nullable=True) engagement_metrics = Column(JSON) # {"likes": 500, "comments": 100, "shares": 50} - created_at = Column(TIMESTAMP, default=lambda: datetime.now(timezone.utc)) + created_at = Column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) user = relationship("User", back_populates="posts") + # Sponsorship Applications Table class SponsorshipApplication(Base): __tablename__ = "sponsorship_applications" @@ -84,11 +123,14 @@ class SponsorshipApplication(Base): post_id = Column(String, ForeignKey("user_posts.id"), nullable=True) proposal = Column(Text, nullable=False) status = Column(String, default="pending") - applied_at = Column(TIMESTAMP, default=datetime.utcnow) + applied_at = Column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) creator = relationship("User", back_populates="applications") sponsorship = relationship("Sponsorship", back_populates="applications") + # Collaborations Table class Collaboration(Base): __tablename__ = "collaborations" @@ -98,7 +140,10 @@ class Collaboration(Base): creator_2_id = Column(String, ForeignKey("users.id"), nullable=False) collaboration_details = Column(Text, nullable=False) status = Column(String, default="pending") - created_at = Column(TIMESTAMP,default=lambda: datetime.now(timezone.utc)) + created_at = Column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + # Sponsorship Payments Table class SponsorshipPayment(Base): @@ -110,6 +155,11 @@ class SponsorshipPayment(Base): sponsorship_id = Column(String, ForeignKey("sponsorships.id"), nullable=False) amount = Column(DECIMAL(10, 2), nullable=False) status = Column(String, default="pending") - transaction_date = Column(TIMESTAMP, default=datetime.utcnow) - - creator = relationship("User", back_populates="payments") + transaction_date = Column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + creator = relationship("User", foreign_keys=[creator_id], back_populates="payments") + brand = relationship( + "User", foreign_keys=[brand_id], back_populates="brand_payments" + ) diff --git a/Backend/app/routes/chat.py b/Backend/app/routes/chat.py new file mode 100644 index 0000000..1188d32 --- /dev/null +++ b/Backend/app/routes/chat.py @@ -0,0 +1,128 @@ +from fastapi import ( + APIRouter, + WebSocket, + Depends, + WebSocketDisconnect, + Request, + HTTPException, +) +from sqlalchemy.ext.asyncio import AsyncSession +from db.db import get_db +from services.chat_services import chat_service +from redis.asyncio import Redis +from services.redis_client import get_redis +import asyncio +from services.chat_pubsub import listen_to_channel + +router = APIRouter(prefix="/chat", tags=["Chat"]) + + +@router.websocket("/ws/{user_id}") +async def websocket_endpoint( + websocket: WebSocket, + user_id: str, + redis: Redis = Depends(get_redis), + db: AsyncSession = Depends(get_db), +): + await chat_service.connect(user_id, websocket, db) + + listener_task = asyncio.create_task(listen_to_channel(user_id, websocket, redis)) + + try: + while True: + data = await websocket.receive_json() + event_type = data.get("event_type", "") + if event_type == "SEND_MESSAGE": + receiver_id = data.get("receiver_id") + sender_id = user_id + message_text = data.get("message") + await chat_service.send_message( + sender_id, receiver_id, message_text, db, redis + ) + + except WebSocketDisconnect: + listener_task.cancel() + await chat_service.disconnect(user_id, redis, db) + + except Exception as e: + listener_task.cancel() + await chat_service.disconnect(user_id, redis, db) + # Optionally log the error + print(f"Error in websocket for user {user_id}: {e}") + + +@router.get("/user_name/{user_id}") +async def get_user_name(user_id: str, db: AsyncSession = Depends(get_db)): + return await chat_service.get_user_name(user_id, db) + + +@router.get("/chat_list/{user_id}") +async def get_user_chat_list( + user_id: str, + last_message_time: str | None = None, + db: AsyncSession = Depends(get_db), +): + return await chat_service.get_user_chat_list(user_id, last_message_time, db) + + +@router.get("/user_status/{target_user_id}") +async def get_user_status( + target_user_id: str, + redis: Redis = Depends(get_redis), + db: AsyncSession = Depends(get_db), +): + return await chat_service.get_user_status(target_user_id, redis, db) + + +@router.get("/messages/{user_id}/{chat_list_id}") +async def get_chat_history( + user_id: str, + chat_list_id: str, + last_fetched: int = 0, + db: AsyncSession = Depends(get_db), +): + return await chat_service.get_chat_history(user_id, chat_list_id, last_fetched, db) + + +@router.put("/read/{user_id}/{chat_list_id}/{message_id}") +async def mark_message_as_read( + user_id: str, + chat_list_id: str, + message_id: str, + db: AsyncSession = Depends(get_db), + redis: Redis = Depends(get_redis), +): + if not message_id: + raise HTTPException(status_code=400, detail="message_id is required") + + return await chat_service.mark_message_as_read( + user_id, chat_list_id, message_id, db, redis + ) + + +@router.put("/read/{user_id}/{chat_list_id}") +async def mark_chat_as_read( + user_id: str, + chat_list_id: str, + db: AsyncSession = Depends(get_db), + redis: Redis = Depends(get_redis), +): + if not chat_list_id: + raise HTTPException(status_code=400, detail="chat_list_id is required") + + return await chat_service.mark_chat_as_read(user_id, chat_list_id, db, redis) + + +@router.post("/new_chat/{user_id}/{username}") +async def create_new_chat_message( + user_id: str, + username: str, + request: Request, + db: AsyncSession = Depends(get_db), + redis: Redis = Depends(get_redis), +): + body = await request.json() + message = body.get("message") + return await chat_service.create_new_chat_message( + user_id, username, message, db, redis + ) diff --git a/Backend/app/services/chat_pubsub.py b/Backend/app/services/chat_pubsub.py new file mode 100644 index 0000000..1b9e8cd --- /dev/null +++ b/Backend/app/services/chat_pubsub.py @@ -0,0 +1,16 @@ +from fastapi import WebSocket +from redis.asyncio import Redis +import json + + +async def listen_to_channel(user_id: str, websocket: WebSocket, redis_client: Redis): + pubsub = redis_client.pubsub() + await pubsub.subscribe(f"to_user:{user_id}") + + try: + async for message in pubsub.listen(): + if message["type"] == "message": + await websocket.send_json(json.loads(message["data"])) + finally: + await pubsub.unsubscribe(f"to_user:{user_id}") + await pubsub.close() diff --git a/Backend/app/services/chat_services.py b/Backend/app/services/chat_services.py new file mode 100644 index 0000000..7210990 --- /dev/null +++ b/Backend/app/services/chat_services.py @@ -0,0 +1,428 @@ +from fastapi import WebSocket, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql import select +from datetime import datetime, timezone +from models.models import User +from models.chat import ChatList, ChatMessage, MessageStatus +from typing import Dict +from redis.asyncio import Redis +import logging +import json + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class ChatService: + def __init__(self): + self.active_connections: Dict[str, WebSocket] = {} + + async def connect( + self, + user_id: str, + websocket: WebSocket, + db: AsyncSession, + ): + """Accept WebSocket connection and update user status to online.""" + await websocket.accept() + # Mark user as online + user = await db.get(User, user_id) + if user: + self.active_connections[user_id] = websocket + user.is_online = True + await db.commit() + + query = select(ChatMessage).where( + (ChatMessage.receiver_id == user_id) + & (ChatMessage.status == MessageStatus.SENT) + ) + messages = (await db.execute(query)).scalars().all() + # mark as delivered + for message in messages: + message.status = MessageStatus.DELIVERED + await db.commit() + else: + logger.warning(f"User {user_id} not found in the database.") + await websocket.close() + + async def disconnect(self, user_id: str, redis: Redis, db: AsyncSession): + """Remove connection and update last seen.""" + self.active_connections.pop(user_id, None) + + # Mark user as offline and update last seen + user = await db.get(User, user_id) + if user: + user.is_online = False + user.last_seen = datetime.now(timezone.utc) + await db.commit() + await redis.set( + f"user:{user_id}:last_seen", user.last_seen.isoformat(), ex=600 + ) + + async def send_message( + self, + sender_id: str, + receiver_id: str, + message_text: str, + db: AsyncSession, + redis: Redis, + ): + """Send a message to the receiver if they are online.""" + + if not message_text: + raise HTTPException(status_code=400, detail="Message text is required") + if sender_id == receiver_id: + raise HTTPException( + status_code=400, detail="Cannot send message to yourself" + ) + + # Find or create chat list + chat_list = await db.execute( + select(ChatList).where( + ((ChatList.user1_id == sender_id) & (ChatList.user2_id == receiver_id)) + | ( + (ChatList.user1_id == receiver_id) + & (ChatList.user2_id == sender_id) + ) + ) + ) + chat_list = chat_list.scalar_one_or_none() + + is_chat_list_exists = chat_list is not None + + if not chat_list: + chat_list = ChatList(user1_id=sender_id, user2_id=receiver_id) + db.add(chat_list) + await db.commit() + + # Store message in DB + new_message = ChatMessage( + sender_id=sender_id, + receiver_id=receiver_id, + chat_list_id=chat_list.id, + message=message_text, + status=MessageStatus.SENT, + ) + db.add(new_message) + await db.commit() + + # Update last message time + chat_list.last_message_time = datetime.now(timezone.utc) + await db.commit() + + receiver_channel = f"to_user:{receiver_id}" + sender_channel = f"to_user:{sender_id}" + + # Send message to receiver if online + if receiver_id in self.active_connections: + new_message.status = MessageStatus.DELIVERED + await db.commit() + + if sender_id in self.active_connections: + await redis.publish( + sender_channel, + json.dumps( + { + "eventType": "NEW_MESSAGE_DELIVERED", + "chatListId": chat_list.id, + "id": new_message.id, + "message": message_text, + "createdAt": new_message.created_at.isoformat(), + "isSent": True, + "status": "delivered", + "senderId": sender_id, + } + ), + ) + + # Send message to receiver + await redis.publish( + receiver_channel, + json.dumps( + { + "eventType": "NEW_MESSAGE_RECEIVED", + "chatListId": chat_list.id, + "id": new_message.id, + "message": message_text, + "createdAt": new_message.created_at.isoformat(), + "isSent": False, + "senderId": sender_id, + } + ), + ) + + else: + if sender_id in self.active_connections: + # Send delivered message to sender + await redis.publish( + sender_channel, + json.dumps( + { + "eventType": "NEW_MESSAGE_SENT", + "chatListId": chat_list.id, + "id": new_message.id, + "message": message_text, + "createdAt": new_message.created_at.isoformat(), + "isSent": True, + "status": "sent", + "senderId": receiver_id, + } + ), + ) + + # used in create_new_chat_message + return { + "chatListId": chat_list.id, + "isChatListExists": is_chat_list_exists, + } + + async def get_chat_history( + self, user_id: str, chat_list_id: str, last_fetched: int, db: AsyncSession + ): + """Fetch chat history between two users.""" + limit = 20 + last_fetched_date = ( + datetime.fromtimestamp(last_fetched / 1000, tz=timezone.utc) + if last_fetched + else datetime.now(timezone.utc) + ) + chat_list = await db.execute( + select(ChatList).where( + ((ChatList.user1_id == user_id) | (ChatList.user2_id == user_id)) + & (ChatList.id == chat_list_id) + ) + ) + chat_list = chat_list.scalar_one_or_none() + + if not chat_list: + raise HTTPException(status_code=404, detail="Chat not found") + + messages = await db.execute( + select(ChatMessage) + .where( + ChatMessage.chat_list_id == chat_list.id, + ChatMessage.created_at < last_fetched_date, + ) + .order_by(ChatMessage.created_at.desc()) + .limit(limit) + ) + messages = messages.scalars().all() + # Format messages removing user IDs and adding isSent flag + formatted_messages = [] + for message in messages: + formatted_message = { + "id": message.id, + "message": message.message, + "status": message.status.value, + "createdAt": message.created_at.isoformat(), + "isSent": message.sender_id == user_id, + } + formatted_messages.append(formatted_message) + + return formatted_messages + + async def mark_message_as_read( + self, + user_id: str, + chat_list_id: str, + message_id: str, + db: AsyncSession, + redis: Redis, + ): + """Mark a specific message as read and notify sender.""" + # Get the specific message + message = await db.get(ChatMessage, message_id) + + if not message: + raise HTTPException(status_code=404, detail="Message not found") + + # Verify the message belongs to the specified chat list and user is the receiver + if message.chat_list_id != chat_list_id or message.receiver_id != user_id: + raise HTTPException( + status_code=403, detail="Not authorized to mark this message as read" + ) + + # Update message status + if message.status != MessageStatus.SEEN: + message.status = MessageStatus.SEEN + await db.commit() + + # Notify sender if they're online + if message.sender_id in self.active_connections: + # Send message read notification to sender + await redis.publish( + f"to_user:{message.sender_id}", + json.dumps( + { + "eventType": "MESSAGE_READ", + "chatListId": chat_list_id, + "messageId": message_id, + } + ), + ) + + return True + + async def mark_chat_as_read( + self, user_id: str, chat_list_id: str, db: AsyncSession, redis: Redis + ): + """Mark messages as read and notify sender.""" + result = await db.execute( + select(ChatMessage).where( + ( + (ChatMessage.sender_id == user_id) + | (ChatMessage.receiver_id == user_id) + ) + & (ChatMessage.chat_list_id == chat_list_id) + & (ChatMessage.status != MessageStatus.SEEN) + ) + ) + messages = result.scalars().all() + + for message in messages: + if message.sender_id == user_id: + message.status = MessageStatus.SEEN + + await db.commit() + + receiver_id = ( + ( + messages[0].receiver_id + if messages[0].sender_id == user_id + else messages[0].sender_id + ) + if len(messages) + else None + ) + + # Notify receiver + if receiver_id and (receiver_id in self.active_connections): + await redis.publish( + f"to_user:{receiver_id}", + json.dumps( + { + "eventType": "CHAT_MESSAGES_READ", + "chatListId": chat_list_id, + } + ), + ) + + return {"message": "Messages marked as read"} + + async def get_user_status( + self, target_user_id: str, redis: Redis, db: AsyncSession + ): + """Check if user is online. If not, send their last seen time.""" + is_online = target_user_id in self.active_connections + if not is_online: + last_seen = await redis.get(f"user:{target_user_id}:last_seen") + if not last_seen: + user = await db.get(User, target_user_id) + if user: + last_seen = user.last_seen + await redis.set( + f"user:{target_user_id}:last_seen", + last_seen.isoformat(), + ex=600, + ) + return { + "isOnline": False, + "lastSeen": last_seen, + } + + return { + "isOnline": is_online, + } + + async def get_user_chat_list( + self, user_id: str, last_message_time: str | None, db: AsyncSession + ): + """Get all chat lists for a user.""" + limit = 20 + last_message_date = ( + datetime.fromisoformat(last_message_time) + if last_message_time + else datetime.now(timezone.utc) + ) + chat_lists = await db.execute( + select(ChatList) + .where( + ((ChatList.user1_id == user_id) | (ChatList.user2_id == user_id)) + & (ChatList.last_message_time < last_message_date) + ) + .order_by(ChatList.last_message_time.desc()) + .limit(limit) + ) + chat_lists = chat_lists.scalars().all() + + formatted_chat_lists = [] + for chat_list in chat_lists: + receiver_id = ( + chat_list.user1_id + if chat_list.user2_id == user_id + else chat_list.user2_id + ) + + receiver = await db.get(User, receiver_id) + if not receiver: + continue + formatted_chat_list = { + "chatListId": chat_list.id, + "lastMessageTime": chat_list.last_message_time.isoformat(), + "receiver": { + "id": receiver.id, + "username": receiver.username, + "profileImage": receiver.profile_image, + }, + } + formatted_chat_lists.append(formatted_chat_list) + return formatted_chat_lists + + async def get_user_name(self, user_id: str, db: AsyncSession): + """Get the username of a user.""" + user = await db.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return { + "username": user.username, + "profileImage": user.profile_image, + } + + async def create_new_chat_message( + self, + user_id: str, + username: str, + message_text: str, + db: AsyncSession, + redis: Redis, + ): + """Create a new chat message.""" + if not message_text: + raise HTTPException(status_code=400, detail="Message text is required") + + receiver = await db.execute(select(User).where(User.username == username)) + + receiver = receiver.scalar_one_or_none() + if not receiver: + raise HTTPException(status_code=404, detail="Receiver not found") + if receiver.id == user_id: + raise HTTPException( + status_code=400, detail="Cannot send message to yourself" + ) + + chat_list = await db.execute( + select(ChatList).where( + ((ChatList.user1_id == user_id) & (ChatList.user2_id == receiver.id)) + | ((ChatList.user1_id == receiver.id) & (ChatList.user2_id == user_id)) + ) + ) + if chat_list: + return { + "chatListId": chat_list.scalar_one().id, + "isChatListExists": True, + } + + return await self.send_message(user_id, receiver.id, message_text, db, redis) + + +chat_service = ChatService() diff --git a/Backend/app/services/redis_client.py b/Backend/app/services/redis_client.py new file mode 100644 index 0000000..d2fb922 --- /dev/null +++ b/Backend/app/services/redis_client.py @@ -0,0 +1,7 @@ +import redis.asyncio as redis + +redis_client = redis.Redis(host="localhost", port=6379, decode_responses=True) + + +async def get_redis(): + return redis_client diff --git a/Backend/docker-compose.yml b/Backend/docker-compose.yml new file mode 100644 index 0000000..aa1451b --- /dev/null +++ b/Backend/docker-compose.yml @@ -0,0 +1,13 @@ +services: + redis: + image: redis:latest + container_name: redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + restart: unless-stopped + command: redis-server --appendonly yes + +volumes: + redis_data: diff --git a/Backend/requirements.txt b/Backend/requirements.txt index fcd28a2..ea1ab73 100644 Binary files a/Backend/requirements.txt and b/Backend/requirements.txt differ diff --git a/Frontend/.gitignore b/Frontend/.gitignore index a547bf3..6450872 100644 --- a/Frontend/.gitignore +++ b/Frontend/.gitignore @@ -1,3 +1,5 @@ +.env* + # Logs logs *.log diff --git a/Frontend/package-lock.json b/Frontend/package-lock.json index 283ffe4..deae757 100644 --- a/Frontend/package-lock.json +++ b/Frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-popover": "^1.1.6", @@ -19,8 +20,10 @@ "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3", + "@reduxjs/toolkit": "^2.6.1", "@supabase/supabase-js": "^2.49.4", "@tailwindcss/vite": "^4.0.16", + "axios": "^1.8.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -30,6 +33,7 @@ "react": "^19.0.0", "react-day-picker": "^8.10.1", "react-dom": "^19.0.0", + "react-redux": "^9.2.0", "react-router-dom": "^7.2.0", "recharts": "^2.15.1", "tailwind-merge": "^3.0.2", @@ -1247,6 +1251,319 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.13.tgz", + "integrity": "sha512-ARFmqUyhIVS3+riWzwGTe7JLjqwqgnODBUZdqpWar/z1WFs9z76fuOs/2BOWCR+YboRn4/WN9aoaGVwqNRr8VA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.9", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.6", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.8", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.2", + "@radix-ui/react-slot": "1.2.2", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.9.tgz", + "integrity": "sha512-way197PiTvNp+WBP7svMJasHl+vibhWGQDb6Mgf5mhEWJkgb85z7Lfl9TUdkqpWsf8GRNmoopx9ZxCyDzmgRMQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.2", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz", + "integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.6.tgz", + "integrity": "sha512-r9zpYNUQY+2jWHWZGyddQLL9YHkM/XvSFHVcWs7bdVuxMAnCwTAuy6Pf47Z4nw7dYcUou1vg/VgjjrrH03VeBw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.2", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.8.tgz", + "integrity": "sha512-hQsTUIn7p7fxCPvao/q6wpbxmCwgLrlz+nOrJgC+RwfZqWY/WN+UMqkXzrtKbPrF82P43eCTl3ekeKuyAQbFeg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.2.tgz", + "integrity": "sha512-uHa+l/lKfxuDD2zjN/0peM/RhhSmRjr5YWdk/37EnSv1nJ88uvG85DPexSm8HdFQROd2VdERJ6ynXbkCFi+APw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.2.tgz", + "integrity": "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", @@ -1850,6 +2167,39 @@ } } }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-escape-keydown": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", @@ -1963,6 +2313,32 @@ "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.0.tgz", + "integrity": "sha512-7OAPcjqZwxzTV9UQ5l6hKQ9ap9GV1xJi6mh6hzDm+qvEjZ4hRdWMBx9b5oE8k1X9PQY8aE/Zf0WBKAYw0digXg==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.37.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.37.0.tgz", @@ -2243,6 +2619,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@supabase/auth-js": { "version": "2.69.1", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.69.1.tgz", @@ -2704,6 +3092,12 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -3027,6 +3421,23 @@ "node": ">=10" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3091,6 +3502,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3180,6 +3604,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3386,6 +3822,15 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", @@ -3423,6 +3868,20 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.123", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.123.tgz", @@ -3443,6 +3902,51 @@ "node": ">=10.13.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz", @@ -3825,6 +4329,41 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/framer-motion": { "version": "12.6.0", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.6.0.tgz", @@ -3867,6 +4406,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3877,6 +4425,30 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -3886,6 +4458,19 @@ "node": ">=6" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3912,6 +4497,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3935,6 +4532,45 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3945,6 +4581,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4408,6 +5054,15 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4432,6 +5087,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4668,6 +5344,12 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4740,6 +5422,29 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -4922,12 +5627,33 @@ "decimal.js-light": "^2.4.1" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", "license": "MIT" }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -5325,6 +6051,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", diff --git a/Frontend/package.json b/Frontend/package.json index 43c320f..1f4ad6f 100644 --- a/Frontend/package.json +++ b/Frontend/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-popover": "^1.1.6", @@ -21,8 +22,10 @@ "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3", + "@reduxjs/toolkit": "^2.6.1", "@supabase/supabase-js": "^2.49.4", "@tailwindcss/vite": "^4.0.16", + "axios": "^1.8.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -32,6 +35,7 @@ "react": "^19.0.0", "react-day-picker": "^8.10.1", "react-dom": "^19.0.0", + "react-redux": "^9.2.0", "react-router-dom": "^7.2.0", "recharts": "^2.15.1", "tailwind-merge": "^3.0.2", diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index 52a3b5b..15b8bc6 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -29,6 +29,7 @@ function App() { } /> } /> } /> + } /> {/* Protected Routes*/} void; +}) { + const selectedChatId = useSelector( + (state: RootState) => state.chat.selectedChatId + ); + + const lastMessage = useSelector((state: RootState) => + chat.messageIds.length + ? state.chat.messages[chat.messageIds[chat.messageIds.length - 1]].message + : null + ); + + const { fetchUserDetails } = useChat(); + + useEffect(() => { + if (!chat.receiver.username) { + fetchUserDetails(chat.receiver.id, chat.id); + } + }, [chat.receiver.username]); + + if (!chat.receiver.username) return null; + + return ( +
handleChatClick(chat.id)} + > +
+ + + {chat.receiver.username?.[0] || "U"} + +
+
+

+ {chat.receiver.username} +

+ + {formatChatDate(chat.lastMessageTime)} + +
+

{lastMessage}

+
+
+
+ ); +} diff --git a/Frontend/src/components/chat/chat-list.tsx b/Frontend/src/components/chat/chat-list.tsx new file mode 100644 index 0000000..19a1431 --- /dev/null +++ b/Frontend/src/components/chat/chat-list.tsx @@ -0,0 +1,68 @@ +"use client"; +import { useEffect, useState } from "react"; +import { Input } from "../ui/input"; +import { useDispatch, useSelector } from "react-redux"; +import { RootState } from "@/redux/store"; +import { Chat, setSelectedChat } from "@/redux/chatSlice"; + +import { useChat } from "@/lib/useChat"; +import ChatItem from "./chat-item"; +import { CreateNewChat } from "./create-new-chat"; +import ChatSearch from "./chat-search"; + +export default function ChatList() { + const chats = useSelector((state: RootState) => state.chat.chats); + const [sortedChatList, setSortedChatList] = useState([]); + const dispatch = useDispatch(); + const { fetchChatList } = useChat(); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setLoading(true); + fetchChatList().finally(() => { + setLoading(false); + }); + }, []); + + useEffect(() => { + const sortedList = Object.values(chats).sort((a, b) => { + return ( + new Date(b.lastMessageTime).getTime() - + new Date(a.lastMessageTime).getTime() + ); + }); + setSortedChatList(sortedList); + }, [chats]); + + const handleChatClick = (chatId: string) => { + dispatch(setSelectedChat(chatId)); + }; + + return ( +
+
+ + +
+
+ {loading && ( +
+
Loading chats...
+
+ )} + {!loading && sortedChatList.length === 0 && ( +
+
No chats available
+
+ )} + {sortedChatList.map((chat) => ( + + ))} +
+
+ ); +} diff --git a/Frontend/src/components/chat/chat-search.tsx b/Frontend/src/components/chat/chat-search.tsx new file mode 100644 index 0000000..7ef2366 --- /dev/null +++ b/Frontend/src/components/chat/chat-search.tsx @@ -0,0 +1,140 @@ +import React, { useState, useEffect } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { RootState } from "@/redux/store"; +import { setSelectedChat } from "@/redux/chatSlice"; +import { Input } from "@/components/ui/input"; +import { Search as SearchIcon } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; + +const ChatSearch = () => { + const dispatch = useDispatch(); + const chats = useSelector((state: RootState) => state.chat.chats); + const messages = useSelector((state: RootState) => state.chat.messages); + + const [isOpen, setIsOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + + useEffect(() => { + if (searchQuery.trim() === "") { + setSearchResults([]); + return; + } + + const results: any[] = []; + const query = searchQuery.toLowerCase(); + + // Search through chats for username matches + Object.values(chats).forEach((chat) => { + if (chat.receiver.username?.toLowerCase().includes(query)) { + results.push({ + type: "chat", + chatId: chat.id, + username: chat.receiver.username, + profileImage: chat.receiver.profileImage, + }); + } + + // Search through messages in this chat + const chatMessages = chat.messageIds + .map((id) => messages[id]) + .filter((message) => message?.message.toLowerCase().includes(query)); + + chatMessages.forEach((message) => { + results.push({ + type: "message", + chatId: chat.id, + messageId: message.id, + messagePreview: message.message, + username: chat.receiver.username, + profileImage: chat.receiver.profileImage, + }); + }); + }); + + setSearchResults(results); + }, [searchQuery, chats, messages]); + + const handleResultClick = (chatId: string) => { + dispatch(setSelectedChat(chatId)); + setIsOpen(false); + setSearchQuery(""); + }; + + const renderSearchResults = () => { + if (searchResults.length === 0 && searchQuery.trim() !== "") { + return ( +
+ No results found +
+ ); + } + + return searchResults.map((result, index) => ( + + )); + }; + + return ( + + +
+ + { + setSearchQuery(e.target.value); + if (e.target.value.trim() !== "") { + setIsOpen(true); + } + }} + onFocus={() => { + if (searchQuery.trim() !== "") { + setIsOpen(true); + } + }} + /> +
+
+ e.preventDefault()} // This prevents the auto focus + > +
{renderSearchResults()}
+
+
+ ); +}; + +export default ChatSearch; diff --git a/Frontend/src/components/chat/chat.tsx b/Frontend/src/components/chat/chat.tsx new file mode 100644 index 0000000..e177e68 --- /dev/null +++ b/Frontend/src/components/chat/chat.tsx @@ -0,0 +1,42 @@ +import { ChatProvider } from "@/lib/useChat"; +import ChatList from "./chat-list"; +import MessagesView from "./messages-view"; +import { useState } from "react"; +import { Input } from "../ui/input"; +import { Button } from "../ui/button"; + +export default function Chat() { + const [inputUserId, setInputUserId] = useState(null); + const [userId, setUserId] = useState(null); + return ( + <> +
+ setInputUserId(e.target.value)} + placeholder="Enter user ID" + className="mb-4 max-w-xl ml-auto" + disabled={!!userId} + /> + +
+ {userId && ( + +
+ + +
+
+ )} + + ); +} diff --git a/Frontend/src/components/chat/create-new-chat.tsx b/Frontend/src/components/chat/create-new-chat.tsx new file mode 100644 index 0000000..603b059 --- /dev/null +++ b/Frontend/src/components/chat/create-new-chat.tsx @@ -0,0 +1,100 @@ +"use client"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { CirclePlus } from "lucide-react"; +import { useChat } from "@/lib/useChat"; + +export function CreateNewChat() { + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [username, setUsername] = useState(""); + const [message, setMessage] = useState(""); + const { createChatWithMessage } = useChat(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!username.trim() || !message.trim()) { + return; + } + + setLoading(true); + + createChatWithMessage(username, message) + .then((success) => { + if (success) { + setUsername(""); + setMessage(""); + } else { + // Handle error + console.error("Failed to create chat"); + } + }) + .finally(() => { + setOpen(false); + setLoading(false); + }); + }; + + return ( + + + + + +
+ + Create New Chat + + Start a new conversation. Fill in your username and initial + message. + + +
+
+ + setUsername(e.target.value)} + className="col-span-3" + disabled={loading} + /> +
+
+ +