diff --git a/backend/dist/config/database.js b/backend/dist/config/database.js deleted file mode 100644 index ceb2a68..0000000 --- a/backend/dist/config/database.js +++ /dev/null @@ -1,22 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.disconnectDatabase = exports.connectDatabase = exports.prisma = void 0; -const client_1 = require("@prisma/client"); -exports.prisma = new client_1.PrismaClient({ - log: ["query", "info", "warn", "error"], -}); -const connectDatabase = async () => { - try { - await exports.prisma.$connect(); - console.log("✅ Database connected successfully"); - } - catch (error) { - console.error("❌ Database connection failed:", error); - process.exit(1); - } -}; -exports.connectDatabase = connectDatabase; -const disconnectDatabase = async () => { - await exports.prisma.$disconnect(); -}; -exports.disconnectDatabase = disconnectDatabase; diff --git a/backend/dist/config/env.js b/backend/dist/config/env.js deleted file mode 100644 index b8e428d..0000000 --- a/backend/dist/config/env.js +++ /dev/null @@ -1,12 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.env = void 0; -const zod_1 = require("zod"); -const envSchema = zod_1.z.object({ - CORS_ORIGIN: zod_1.z.string().default("*"), - DATABASE_URL: zod_1.z.string(), - JWT_SECRET: zod_1.z.string(), - NODE_ENV: zod_1.z.enum(["development", "production", "test"]).default("development"), - PORT: zod_1.z.string().transform(Number).default("3000"), -}); -exports.env = envSchema.parse(process.env); diff --git a/backend/dist/controllers/authController.js b/backend/dist/controllers/authController.js deleted file mode 100644 index 39f2f35..0000000 --- a/backend/dist/controllers/authController.js +++ /dev/null @@ -1,17 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.AuthController = void 0; -const authService_js_1 = require("../services/authService.js"); -class AuthController { - static async register(req, res) { - const data = req.body; - const result = await authService_js_1.AuthService.register(data); - res.status(result.success ? 201 : 400).json(result); - } - static async login(req, res) { - const data = req.body; - const result = await authService_js_1.AuthService.login(data); - res.status(result.success ? 200 : 400).json(result); - } -} -exports.AuthController = AuthController; diff --git a/backend/dist/controllers/chatController.js b/backend/dist/controllers/chatController.js deleted file mode 100644 index 4fc3386..0000000 --- a/backend/dist/controllers/chatController.js +++ /dev/null @@ -1,37 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.ChatController = void 0; -const chatService_js_1 = require("../services/chatService.js"); -class ChatController { - static async getChatRooms(req, res) { - if (!req.user) { - res.status(401).json({ success: false, error: "Unauthorized" }); - return; - } - const result = await chatService_js_1.ChatService.getChatRooms(req.user.userId); - res.status(result.success ? 200 : 500).json(result); - } - static async createChatRoom(req, res) { - if (!req.user) { - res.status(401).json({ success: false, error: "Unauthorized" }); - return; - } - const data = req.body; - const result = await chatService_js_1.ChatService.createChatRoom(req.user.userId, data); - res.status(result.success ? 201 : 500).json(result); - } - static async getMessages(req, res) { - if (!req.user) { - res.status(401).json({ success: false, error: "Unauthorized" }); - return; - } - const query = { - roomId: req.params.roomId, - page: req.query.page, - limit: req.query.limit, - }; - const result = await chatService_js_1.ChatService.getMessages(query); - res.status(result.success ? 200 : 500).json(result); - } -} -exports.ChatController = ChatController; diff --git a/backend/dist/index.js b/backend/dist/index.js deleted file mode 100644 index 388a991..0000000 --- a/backend/dist/index.js +++ /dev/null @@ -1,64 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -const cors_1 = __importDefault(require("cors")); -const express_1 = __importDefault(require("express")); -const http_1 = __importDefault(require("http")); -const socket_io_1 = require("socket.io"); -const database_js_1 = require("./config/database.js"); -const env_js_1 = require("./config/env.js"); -const auth_js_1 = require("./middleware/auth.js"); -const socketHandlers_js_1 = require("./socket/socketHandlers.js"); -const app = (0, express_1.default)(); -const server = http_1.default.createServer(app); -const io = new socket_io_1.Server(server, { - cors: { - methods: ["GET", "POST"], - origin: "http://localhost:5173", - }, -}); -app.use((0, cors_1.default)({ - methods: ["GET", "POST"], - origin: "http://localhost:5173", -})); -app.use(express_1.default.json()); -app.get("/health", (_, res) => { - res.json({ status: "OK", timestamp: new Date().toISOString() }); -}); -io.use(auth_js_1.authenticateSocket); -io.on("connection", async (socket) => { - await (0, socketHandlers_js_1.handleConnection)(io, socket); -}); -app.use((err, _, res) => { - console.error("Unhandled error:", err); - res.status(500).json({ - success: false, - error: env_js_1.env.NODE_ENV === "production" ? "Internal server error" : err.message, - }); -}); -const startServer = async () => { - try { - await (0, database_js_1.connectDatabase)(); - server.listen(env_js_1.env.PORT, () => { - console.log(`🚀 Server running on port ${env_js_1.env.PORT}`); - console.log(`📊 Environment: ${env_js_1.env.NODE_ENV}`); - console.log(`🔗 CORS Origin: ${env_js_1.env.CORS_ORIGIN}`); - }); - } - catch (error) { - console.error("Failed to start server:", error); - process.exit(1); - } -}; -process.on("SIGTERM", () => { - console.log("SIGTERM received, shutting down gracefully..."); - server.close(() => { - console.log("HTTP server closed"); - process.exit(0); - }); -}); -(async () => { - await startServer(); -})(); diff --git a/backend/dist/middleware/auth.js b/backend/dist/middleware/auth.js deleted file mode 100644 index d9ba31a..0000000 --- a/backend/dist/middleware/auth.js +++ /dev/null @@ -1,51 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.authenticateSocket = exports.authenticateToken = void 0; -const jsonwebtoken_1 = __importDefault(require("jsonwebtoken")); -const database_js_1 = require("../config/database.js"); -const env_js_1 = require("../config/env.js"); -const authenticateToken = (req, res, next) => { - try { - const authHeader = req.headers.authorization; - const token = authHeader?.split(" ")[1]; - if (!token) { - res.status(401).json({ success: false, error: "Access token required" }); - return; - } - const decoded = jsonwebtoken_1.default.verify(token, env_js_1.env.JWT_SECRET); - req.user = decoded; - next(); - } - catch (error) { - console.error("Authentication error:", error); - res.status(403).json({ success: false, error: "Invalid or expired token" }); - } -}; -exports.authenticateToken = authenticateToken; -const authenticateSocket = async (socket, next) => { - try { - const token = socket.handshake.auth.token; - if (!token) { - return next(new Error("Authentication token required")); - } - const decoded = jsonwebtoken_1.default.verify(token, env_js_1.env.JWT_SECRET); - const user = await database_js_1.prisma.user.findUnique({ - where: { - id: decoded.userId, - }, - }); - if (!user) - return next(new Error("User not found")); - const authenticateSocket = socket; - authenticateSocket.userId = user.id; - authenticateSocket.user = user; - next(); - } - catch (error) { - console.error("Socket authentication error:", error); - } -}; -exports.authenticateSocket = authenticateSocket; diff --git a/backend/dist/middleware/rateLimiter.js b/backend/dist/middleware/rateLimiter.js deleted file mode 100644 index b58637a..0000000 --- a/backend/dist/middleware/rateLimiter.js +++ /dev/null @@ -1,42 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.checkMessageRateLimit = exports.messageRateLimiter = void 0; -class RateLimiter { - limits = new Map(); - maxMessages; - windowMs; - constructor(maxMessages = 10, windowMs = 60000) { - this.maxMessages = maxMessages; - this.windowMs = windowMs; - } - checkLimit(userId) { - const now = Date.now(); - const userLimit = this.limits.get(userId) ?? { count: 0, resetTime: now + this.windowMs }; - if (now > userLimit.resetTime) { - userLimit.count = 0; - userLimit.resetTime = now + this.windowMs; - } - userLimit.count++; - this.limits.set(userId, userLimit); - return userLimit.count <= this.maxMessages; - } - getRemainingTime(userId) { - const userLimit = this.limits.get(userId); - if (!userLimit) - return 0; - return Math.max(0, userLimit.resetTime - Date.now()); - } -} -exports.messageRateLimiter = new RateLimiter(10, 60000); // 10 messages per minute -const checkMessageRateLimit = (socket) => { - const canSend = exports.messageRateLimiter.checkLimit(socket.userId); - if (!canSend) { - const remainingTime = exports.messageRateLimiter.getRemainingTime(socket.userId); - socket.emit("rate_limit_exceeded", { - message: `Too many messages. Please wait ${Math.ceil(remainingTime / 1000)} seconds before sending another message.`, - remainingTime, - }); - } - return canSend; -}; -exports.checkMessageRateLimit = checkMessageRateLimit; diff --git a/backend/dist/middleware/validation.js b/backend/dist/middleware/validation.js deleted file mode 100644 index c548d68..0000000 --- a/backend/dist/middleware/validation.js +++ /dev/null @@ -1,38 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.createChatRoomSchema = exports.registerSchema = exports.loginSchema = exports.validate = void 0; -const zod_1 = require("zod"); -const validate = (schema) => { - return (req, res, next) => { - try { - schema.parse(req.body); - next(); - } - catch (error) { - if (error instanceof zod_1.z.ZodError) { - res.status(400).json({ - success: false, - error: "Validation failed", - details: error.errors, - }); - return; - } - next(error); - } - }; -}; -exports.validate = validate; -exports.loginSchema = zod_1.z.object({ - email: zod_1.z.string().email("Invalid email format"), - password: zod_1.z.string().min(6, "Password must be at least 6 characters long"), -}); -exports.registerSchema = zod_1.z.object({ - username: zod_1.z.string().min(3, "Username must be at least 3 characters long"), - email: zod_1.z.string().email("Invalid email format"), - password: zod_1.z.string().min(6, "Password must be at least 6 characters long"), -}); -exports.createChatRoomSchema = zod_1.z.object({ - name: zod_1.z.string().min(1, "Room name is required"), - description: zod_1.z.string().optional(), - memberUsernames: zod_1.z.array(zod_1.z.string()).default([]), -}); diff --git a/backend/dist/routes/authRoutes.js b/backend/dist/routes/authRoutes.js deleted file mode 100644 index 78ba4d3..0000000 --- a/backend/dist/routes/authRoutes.js +++ /dev/null @@ -1,10 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.authRoutes = void 0; -const authController_js_1 = require("../controllers/authController.js"); -const validation_js_1 = require("../middleware/validation.js"); -const express_1 = require("express"); -const router = (0, express_1.Router)(); -exports.authRoutes = router; -router.post("/register", (0, validation_js_1.validate)(validation_js_1.registerSchema), authController_js_1.AuthController.register); -router.post("/login", (0, validation_js_1.validate)(validation_js_1.loginSchema), authController_js_1.AuthController.login); diff --git a/backend/dist/routes/chatRoutes.js b/backend/dist/routes/chatRoutes.js deleted file mode 100644 index e5d933c..0000000 --- a/backend/dist/routes/chatRoutes.js +++ /dev/null @@ -1,13 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.chatRoutes = void 0; -const chatController_js_1 = require("../controllers/chatController.js"); -const auth_js_1 = require("../middleware/auth.js"); -const validation_js_1 = require("../middleware/validation.js"); -const express_1 = require("express"); -const router = (0, express_1.Router)(); -exports.chatRoutes = router; -router.use(auth_js_1.authenticateToken); -router.get("/chat-rooms", chatController_js_1.ChatController.getChatRooms); -router.post("/chat-rooms", (0, validation_js_1.validate)(validation_js_1.createChatRoomSchema), chatController_js_1.ChatController.createChatRoom); -router.get("/messages/:roomId", chatController_js_1.ChatController.getMessages); diff --git a/backend/dist/routes/index.js b/backend/dist/routes/index.js deleted file mode 100644 index cb62b97..0000000 --- a/backend/dist/routes/index.js +++ /dev/null @@ -1,10 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.apiRoutes = void 0; -const express_1 = require("express"); -const authRoutes_js_1 = require("./authRoutes.js"); -const chatRoutes_js_1 = require("./chatRoutes.js"); -const router = (0, express_1.Router)(); -exports.apiRoutes = router; -router.use("/auth", authRoutes_js_1.authRoutes); -router.use("/", chatRoutes_js_1.chatRoutes); diff --git a/backend/dist/services/authService.js b/backend/dist/services/authService.js deleted file mode 100644 index 2594aa6..0000000 --- a/backend/dist/services/authService.js +++ /dev/null @@ -1,100 +0,0 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.AuthService = void 0; -const database_js_1 = require("../config/database.js"); -const env_js_1 = require("../config/env.js"); -const bcryptjs_1 = __importDefault(require("bcryptjs")); -const jsonwebtoken_1 = __importDefault(require("jsonwebtoken")); -class AuthService { - static async register(data) { - try { - const existingUser = await database_js_1.prisma.user.findFirst({ - where: { - OR: [{ email: data.email }, { username: data.username }], - }, - }); - if (existingUser) { - return { - success: false, - error: existingUser.email === data.email ? "Email already exists" : "Username already exists", - }; - } - const hashedPassword = await bcryptjs_1.default.hash(data.password, 12); - const user = await database_js_1.prisma.user.create({ - data: { - username: data.username, - email: data.email, - password: hashedPassword, - }, - }); - const token = this.generateToken({ - userId: user.id, - username: user.username, - email: user.email, - }); - return { - success: true, - data: { - token, - user: { - id: user.id, - username: user.username, - email: user.email, - }, - }, - }; - } - catch (error) { - console.error("Registration error:", error); - return { - success: false, - error: "Registration failed", - }; - } - } - static async login(data) { - try { - const user = await database_js_1.prisma.user.findUnique({ - where: { - email: data.email, - }, - }); - if (!user || !(await bcryptjs_1.default.compare(data.password, user.password))) { - return { - success: false, - error: "Invalid email or password", - }; - } - const token = this.generateToken({ - userId: user.id, - username: user.username, - email: user.email, - }); - return { - success: true, - data: { - token, - user: { - id: user.id, - username: user.username, - email: user.email, - }, - }, - }; - } - catch (error) { - console.error("Login error:", error); - return { - success: false, - error: "Login failed", - }; - } - } - static generateToken(payload) { - return jsonwebtoken_1.default.sign(payload, env_js_1.env.JWT_SECRET, { expiresIn: "7d" }); - } -} -exports.AuthService = AuthService; diff --git a/backend/dist/services/chatService.js b/backend/dist/services/chatService.js deleted file mode 100644 index 80aab4c..0000000 --- a/backend/dist/services/chatService.js +++ /dev/null @@ -1,186 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.ChatService = void 0; -const database_js_1 = require("../config/database.js"); -class ChatService { - static async getChatRooms(userId) { - try { - const chatRooms = await database_js_1.prisma.chatRoom.findMany({ - where: { - members: { - some: { - userId, - }, - }, - }, - include: { - members: { - include: { - user: { - select: { - id: true, - username: true, - isOnline: true, - avatar: true, - }, - }, - }, - }, - _count: { - select: { - messages: true, - }, - }, - }, - orderBy: { - updatedAt: "desc", - }, - }); - return { - success: true, - data: chatRooms, - }; - } - catch (error) { - console.error("Get chat rooms error:", error); - return { - success: false, - error: "Failed to retrieve chat rooms", - }; - } - } - static async createChatRoom(userId, data) { - try { - const chatRoom = await database_js_1.prisma.chatRoom.create({ - data: { - name: data.name, - description: data.description, - createdBy: userId, - members: { - create: [ - { - userId, - role: "admin", - }, - ], - }, - }, - }); - if (data.memberUsernames.length > 0) { - const users = await database_js_1.prisma.user.findMany({ - where: { - username: { - in: data.memberUsernames, - }, - }, - }); - const memberData = users.map((user) => ({ - userId: user.id, - roomId: chatRoom.id, - })); - await database_js_1.prisma.chatRoomMember.createMany({ - data: memberData, - skipDuplicates: true, - }); - } - const fullChatRoom = await database_js_1.prisma.chatRoom.findUnique({ - where: { id: chatRoom.id }, - include: { - members: { - include: { - user: { - select: { - id: true, - username: true, - isOnline: true, - avatar: true, - }, - }, - }, - }, - _count: { - select: { - messages: true, - }, - }, - }, - }); - return { - success: true, - data: fullChatRoom, - }; - } - catch (error) { - console.error("Create chat room error:", error); - return { - success: false, - error: "Failed to create chat room", - }; - } - } - static async getMessages(query) { - try { - const page = Number.parseInt(query.page ?? "1"); - const limit = Number.parseInt(query.limit ?? "50"); - const skip = (page - 1) * limit; - const messages = await database_js_1.prisma.message.findMany({ - where: { - roomId: query.roomId, - }, - include: { - user: { - select: { - id: true, - username: true, - avatar: true, - }, - }, - reactions: { - include: { - user: { - select: { - id: true, - username: true, - }, - }, - }, - }, - }, - orderBy: { - createdAt: "desc", - }, - take: limit, - skip, - }); - return { - success: true, - data: messages.reverse(), - }; - } - catch (error) { - console.error("Get messages error:", error); - return { - success: false, - error: "Failed to retrieve messages", - }; - } - } - static async checkRoomMembership(userId, roomId) { - try { - const membership = await database_js_1.prisma.chatRoomMember.findUnique({ - where: { - userId_roomId: { - userId, - roomId, - }, - }, - }); - return !!membership; - } - catch (error) { - console.error("Check room membership error:", error); - return false; - } - } -} -exports.ChatService = ChatService; diff --git a/backend/dist/services/messageService.js b/backend/dist/services/messageService.js deleted file mode 100644 index b40cb7b..0000000 --- a/backend/dist/services/messageService.js +++ /dev/null @@ -1,108 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.MessageService = void 0; -const database_js_1 = require("../config/database.js"); -class MessageService { - static async sendMessage(userId, data) { - try { - const message = await database_js_1.prisma.message.create({ - data: { - content: data.constent, - userId, - roomId: data.roomId, - }, - include: { - user: { - select: { - id: true, - username: true, - avatar: true, - }, - }, - reactions: { - include: { - user: { - select: { - id: true, - username: true, - }, - }, - }, - }, - }, - }); - return { - success: true, - data: message, - }; - } - catch (error) { - console.error("Send message error:", error); - return { - success: false, - error: "Failed to send message", - }; - } - } - static async reactToMessage(userId, data) { - try { - const existingReaction = await database_js_1.prisma.messageReaction.findUnique({ - where: { - userId_messageId_type: { - userId, - messageId: data.messageId, - type: data.type, - }, - }, - }); - if (existingReaction) { - await database_js_1.prisma.messageReaction.delete({ - where: { - id: existingReaction.id, - }, - }); - } - else { - await database_js_1.prisma.messageReaction.create({ - data: { - userId, - messageId: data.messageId, - type: data.type, - }, - }); - } - const message = await database_js_1.prisma.message.findUnique({ - where: { - id: data.messageId, - }, - include: { - reactions: { - include: { - user: { - select: { - id: true, - username: true, - }, - }, - }, - }, - }, - }); - return { - success: true, - data: { - messageId: data.messageId, - reactions: message?.reactions ?? [], - }, - }; - } - catch (error) { - console.error("React to message error:", error); - return { - success: false, - error: "Failed to react to message", - }; - } - } -} -exports.MessageService = MessageService; diff --git a/backend/dist/socket/socketHandlers.js b/backend/dist/socket/socketHandlers.js deleted file mode 100644 index e71ee99..0000000 --- a/backend/dist/socket/socketHandlers.js +++ /dev/null @@ -1,119 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.handleConnection = void 0; -const database_js_1 = require("../config/database.js"); -const rateLimiter_js_1 = require("../middleware/rateLimiter.js"); -const chatService_js_1 = require("../services/chatService.js"); -const messageService_js_1 = require("../services/messageService.js"); -const handleConnection = async (io, socket) => { - console.log(`User connected: ${socket.user.username} (${socket.user.id})`); - await updateUserOnlineStatus(socket.userId, true); - await joinUserRooms(socket); - socket.on("send_message", (data) => handleSendMessage(io, socket, data)); - socket.on("react_to_message", (data) => handleReactToMessage(io, socket, data)); - socket.on("join_room", (roomId) => handleJoinRoom(socket, roomId)); - socket.on("leave_room", (roomId) => handleLeaveRoom(socket, roomId)); - socket.on("disconnect", () => handleDisconnect(socket)); -}; -exports.handleConnection = handleConnection; -const updateUserOnlineStatus = async (userId, isOnline) => { - try { - await database_js_1.prisma.user.update({ - where: { id: userId }, - data: { - isOnline, - lastSeen: new Date(), - }, - }); - } - catch (error) { - console.error(`Error updating user online status: ${error}`); - } -}; -const joinUserRooms = async (socket) => { - try { - const userRooms = await database_js_1.prisma.chatRoomMember.findMany({ - where: { userId: socket.userId }, - include: { room: true }, - }); - userRooms.forEach(async (member) => { - await socket.join(member.roomId); - socket.to(member.roomId).emit("user_online", { - userId: socket.userId, - username: socket.user.username, - }); - }); - } - catch (error) { - console.error(`Error joining user rooms: ${error}`); - } -}; -const handleSendMessage = async (io, socket, data) => { - try { - if (!(0, rateLimiter_js_1.checkMessageRateLimit)(socket)) - return; - const isMember = await chatService_js_1.ChatService.checkRoomMembership(socket.userId, data.roomId); - if (!isMember) { - socket.emit("error", { message: "Not authorized to send messages to this room" }); - return; - } - const result = await messageService_js_1.MessageService.sendMessage(socket.userId, data); - if (result.success) { - io.to(data.roomId).emit("new_message", result.data); - } - else { - socket.emit("error", { message: "Failed to send message" }); - } - } - catch (error) { - console.error(`Error handling send message: ${error}`); - socket.emit("error", { message: "Failed to send message" }); - } -}; -const handleReactToMessage = async (io, socket, data) => { - try { - const result = await messageService_js_1.MessageService.reactToMessage(socket.userId, data); - if (result.success) { - const message = await database_js_1.prisma.message.findUnique({ - where: { id: data.messageId }, - select: { roomId: true }, - }); - if (message) { - io.to(message.roomId).emit("message_reaction_updated", result.data); - } - } - else { - socket.emit("error", { message: result.error }); - } - } - catch (error) { - console.error(`Error handling react to message: ${error}`); - socket.emit("error", { message: "Failed to react to message" }); - } -}; -const handleJoinRoom = async (socket, roomId) => { - await socket.join(roomId); -}; -const handleLeaveRoom = async (socket, roomId) => { - await socket.leave(roomId); -}; -const handleDisconnect = async (socket) => { - console.log(`User disconnected: ${socket.user.username}`); - await updateUserOnlineStatus(socket.userId, false); - try { - const userRooms = await database_js_1.prisma.chatRoomMember.findMany({ - where: { - userId: socket.userId, - }, - }); - userRooms.forEach((member) => { - socket.to(member.roomId).emit("user_offline", { - userId: socket.userId, - username: socket.user.username, - }); - }); - } - catch (error) { - console.error(`Error handling user disconnect: ${error}`); - } -}; diff --git a/backend/dist/types/index.js b/backend/dist/types/index.js deleted file mode 100644 index c8ad2e5..0000000 --- a/backend/dist/types/index.js +++ /dev/null @@ -1,2 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/backend/prisma/migrations/20250612120245_add_message_seen/migration.sql b/backend/prisma/migrations/20250612120245_add_message_seen/migration.sql new file mode 100644 index 0000000..f2078c1 --- /dev/null +++ b/backend/prisma/migrations/20250612120245_add_message_seen/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "message_seen" ( + "id" TEXT NOT NULL, + "seenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" TEXT NOT NULL, + "messageId" TEXT NOT NULL, + + CONSTRAINT "message_seen_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "message_seen_userId_messageId_key" ON "message_seen"("userId", "messageId"); + +-- AddForeignKey +ALTER TABLE "message_seen" ADD CONSTRAINT "message_seen_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "message_seen" ADD CONSTRAINT "message_seen_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "messages"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 15ef8dd..97cc71d 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -22,6 +22,7 @@ model User { reactions MessageReaction[] chatRooms ChatRoomMember[] createdRooms ChatRoom[] + seenMessages MessageSeen[] @@map("users") } @@ -44,7 +45,7 @@ model ChatRoom { } model ChatRoomMember { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String roomId String joinedAt DateTime @default(now()) @@ -70,13 +71,28 @@ model Message { room ChatRoom @relation(fields: [roomId], references: [id], onDelete: Cascade) reactions MessageReaction[] + seenBy MessageSeen[] @@map("messages") } +model MessageSeen { + id String @id @default(cuid()) + seenAt DateTime @default(now()) + + userId String + messageId String + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + message Message @relation(fields: [messageId], references: [id], onDelete: Cascade) + + @@unique([userId, messageId]) + @@map("message_seen") +} + model MessageReaction { id String @id @default(cuid()) - type String // like, love, laugh, etc. + type String // like, love, laugh, etc. createdAt DateTime @default(now()) userId String diff --git a/backend/src/services/chatService.ts b/backend/src/services/chatService.ts index bfed3b2..f7102d7 100644 --- a/backend/src/services/chatService.ts +++ b/backend/src/services/chatService.ts @@ -20,6 +20,7 @@ export class ChatService { id: true, username: true, isOnline: true, + lastSeen: true, avatar: true, }, }, @@ -151,6 +152,11 @@ export class ChatService { }, }, }, + seenBy: { + select: { + userId: true, + }, + }, }, orderBy: { createdAt: "desc", @@ -159,9 +165,14 @@ export class ChatService { skip, }); + const formattedMessages = messages.map((message) => ({ + ...message, + seenBy: message.seenBy.map((seen) => seen.userId), + })); + return { success: true, - data: messages.reverse(), + data: formattedMessages.reverse(), }; } catch (error) { console.error("Get messages error:", error); diff --git a/backend/src/services/messageService.ts b/backend/src/services/messageService.ts index ed18750..7521b88 100644 --- a/backend/src/services/messageService.ts +++ b/backend/src/services/messageService.ts @@ -106,4 +106,52 @@ export class MessageService { }; } } + + static async markMessagesAsSeen(userId: string, messageIds: string[]): Promise { + try { + const seenEntries = messageIds.map(messageId => ({ + userId, + messageId + })); + + await prisma.messageSeen.createMany({ + data: seenEntries, + skipDuplicates: true, + }) + + const messages = await prisma.message.findMany({ + where: { + id: { in: messageIds } + }, + include: { + seenBy: { + include: { + user: { + select: { + id: true, + username: true, + } + } + } + } + } + }) + + const seenData = messages.map(message => ({ + messageId: message.id, + seenBy: message.seenBy.map(seen => seen.userId) + })); + + return { + success: true, + data: seenData, + } + } catch (error) { + console.error("Mark messages as seen error:", error); + return { + success: false, + error: "Failed to mark messages as seen", + }; + } + } } diff --git a/backend/src/socket/socketHandlers.ts b/backend/src/socket/socketHandlers.ts index b10ae52..4a95933 100644 --- a/backend/src/socket/socketHandlers.ts +++ b/backend/src/socket/socketHandlers.ts @@ -23,12 +23,45 @@ export const handleConnection = async (io: Server, socket: AuthenticatedSocket) socket.on("react_to_message", (data: ReactToMessageRequest) => handleReactToMessage(io, socket, data)); + socket.on("typing_start", (data: { roomId: string; username: string }) => handleTypingStart(io, socket, data)); + socket.on("typing_stop", (data: { roomId: string; username: string }) => handleTypingStop(io, socket, data)); + socket.on("join_room", (roomId: string) => handleJoinRoom(socket, roomId)); socket.on("leave_room", (roomId: string) => handleLeaveRoom(socket, roomId)); + socket.on("messages_seen", (data: { roomId: string; messageIds: string[] }) => handleMessagesSeen(io, socket, data)); + socket.on("disconnect", () => handleDisconnect(socket)); }; +const handleTypingStart = async (io: Server, socket: AuthenticatedSocket, data: { roomId: string; username: string }) => { + try { + const isMember = await ChatService.checkRoomMembership(socket.userId, data.roomId); + if (!isMember) return; + + io.to(data.roomId).emit("user_typing", { + roomId: data.roomId, + username: data.username, + }); + } catch (error) { + console.error(`Error handling typing start: ${error}`); + } +}; + +const handleTypingStop = async (io: Server, socket: AuthenticatedSocket, data: { roomId: string; username: string }) => { + try { + const isMember = await ChatService.checkRoomMembership(socket.userId, data.roomId); + if (!isMember) return; + + io.to(data.roomId).emit("user_stopped_typing", { + roomId: data.roomId, + username: data.username, + }); + } catch (error) { + console.error(`Error handling typing stop: ${error}`); + } +}; + const updateUserOnlineStatus = async (userId: string, isOnline: boolean) => { try { await prisma.user.update({ @@ -77,6 +110,11 @@ const handleSendMessage = async (io: Server, socket: AuthenticatedSocket, data: if (result.success) { io.to(data.roomId).emit("new_message", result.data); + + io.to(data.roomId).emit("user_stopped_typing", { + roomId: data.roomId, + username: socket.user.username, + }); } else { socket.emit("error", { message: "Failed to send message" }); } @@ -108,6 +146,41 @@ const handleReactToMessage = async (io: Server, socket: AuthenticatedSocket, dat } }; +const handleMessagesSeen = async (io: Server, socket: AuthenticatedSocket, data: { roomId: string; messageIds: string[] }) => { + try { + const isMember = await ChatService.checkRoomMembership(socket.userId, data.roomId); + if (!isMember) { + socket.emit("error", { message: "Not authorized to access this room" }); + return; + } + + const messagesInRoom = await prisma.message.findMany({ + where: { + id: { in: data.messageIds }, + roomId: data.roomId, + }, + }); + + const validMessageIds = messagesInRoom.map((msg) => msg.id); + + if (validMessageIds.length === 0) return; + + const result = await MessageService.markMessagesAsSeen(socket.userId, validMessageIds); + + if (result.success && result.data) { + for (const item of result.data) { + io.to(data.roomId).emit("message_seen_update", { + messageId: item.messageId, + seenBy: item.seenBy, + }); + } + } + } catch (error) { + console.error(`Error handling messages seen: ${error}`); + socket.emit("error", { message: "Failed to mark messages as seen" }); + } +}; + const handleJoinRoom = async (socket: AuthenticatedSocket, roomId: string) => { await socket.join(roomId); }; @@ -133,6 +206,11 @@ const handleDisconnect = async (socket: AuthenticatedSocket) => { userId: socket.userId, username: socket.user.username, }); + + socket.to(member.roomId).emit("user_stopped_typing", { + roomId: member.roomId, + username: socket.user.username, + }); }); } catch (error) { console.error(`Error handling user disconnect: ${error}`); diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index c712905..7f95273 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -31,6 +31,11 @@ export type ReactToMessageRequest = { type: string; }; +export type MessageWithSeenBy = { + messageId: string; + seenBy: string[]; +}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any export type ApiResponse = { success: boolean; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0334e3d..f663e31 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,10 +19,12 @@ "axios": "^1.9.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.514.0", "next-themes": "^0.4.6", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-router-dom": "^7.6.2", "recharts": "^2.15.3", "socket.io-client": "^4.8.1", "sonner": "^2.0.5", @@ -3050,6 +3052,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3246,6 +3257,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -5759,6 +5780,44 @@ } } }, + "node_modules/react-router": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.2.tgz", + "integrity": "sha512-U7Nv3y+bMimgWjhlT5CRdzHPu2/KVmqPwKUCChW8en5P3znxUqwlYFlbmyj8Rgp1SF6zs5X4+77kBVknkg6a0w==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.2.tgz", + "integrity": "sha512-Q8zb6VlTbdYKK5JJBLQEN06oTUa/RAbG/oQS1auK1I0TbJOXktqm+QENEVJU6QvWynlXPRBXI3fiOQcSEA78rA==", + "license": "MIT", + "dependencies": { + "react-router": "7.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/react-smooth": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", @@ -6076,6 +6135,12 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4b4b634..4fadb13 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,10 +23,12 @@ "axios": "^1.9.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.514.0", "next-themes": "^0.4.6", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-router-dom": "^7.6.2", "recharts": "^2.15.3", "socket.io-client": "^4.8.1", "sonner": "^2.0.5", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6932648..0bf0ed1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,9 @@ import { Toaster } from '@/components/ui/sonner'; import { useSocket } from '@/hooks/useSocket'; import { useAuthStore } from '@/stores/authStore'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import type { FC } from 'react'; +import { ThemeProvider } from 'next-themes'; +import type { FC, ReactNode } from 'react'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; const queryClient = new QueryClient({ defaultOptions: { @@ -15,25 +17,64 @@ const queryClient = new QueryClient({ }, }); +type ProtectedRouteProps = { + children: ReactNode; +}; + +const ProtectedRoute: FC = ({ children }) => { + const { isAuthenticated } = useAuthStore(); + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +}; + const AppContent: FC = () => { const { isAuthenticated } = useAuthStore(); useSocket(); return ( - <> - {isAuthenticated ? : } + + + : + } + /> + + + + } + /> + + + + } + /> + - + ); }; function App() { return ( - - - + + + + + ); } -export default App; +export default App; \ No newline at end of file diff --git a/frontend/src/components/ChatApp.tsx b/frontend/src/components/ChatApp.tsx index 0b07ea7..6181270 100644 --- a/frontend/src/components/ChatApp.tsx +++ b/frontend/src/components/ChatApp.tsx @@ -1,39 +1,60 @@ import { useChatStore } from '@/stores/chatStore'; -import type { FC } from 'react'; +import { useEffect, type FC } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; import { ChatSidebar } from './ChatSidebar'; import { MessageInput } from './MessageInput'; import { MessageList } from './MessageList'; +import { NavBar } from './Navbar'; +import { UsersSidebar } from './UsersSidebar'; export const ChatApp: FC = () => { - const { selectedRoomId, setSelectedRoom } = useChatStore(); + const { roomId } = useParams<{ roomId: string }>(); + const { setSelectedRoom, selectedRoomId } = useChatStore(); + const navigate = useNavigate(); + + useEffect(() => { + if (roomId) { + setSelectedRoom(roomId); + } + }, [roomId, setSelectedRoom]); + + useEffect(() => { + if (selectedRoomId && selectedRoomId !== roomId) { + navigate(`/room/${selectedRoomId}`); + } + }, [selectedRoomId, navigate, roomId]); return (
-
- {selectedRoomId ? ( - <> -
-

Chat Room

+ +
+ {roomId ? ( + <> +
+
+ + +
+ +
+ + ) : ( +
+
+

+ Welcome to Chat App +

+

Select a chat room to start messaging

+
- - - - ) : ( -
-
-

- Welcome to Chat App -

-

Select a chat room to start messaging

-
-
- )} + )} +
); -}; +}; \ No newline at end of file diff --git a/frontend/src/components/ChatSidebar.tsx b/frontend/src/components/ChatSidebar.tsx index a303f51..33b9105 100644 --- a/frontend/src/components/ChatSidebar.tsx +++ b/frontend/src/components/ChatSidebar.tsx @@ -3,8 +3,20 @@ import { useAuthStore } from '@/stores/authStore'; import { useChatStore } from '@/stores/chatStore'; import type { ChatRoom, CreateChatRoomRequest } from '@/types'; import { useMutation, useQuery } from '@tanstack/react-query'; -import { LogOut, MessageCircle, Plus, Users } from 'lucide-react'; +import { + Laptop, + LogOut, + MessageCircle, + Moon, + Plus, + Settings, + Sun, + User, + Users, +} from 'lucide-react'; +import { useTheme } from 'next-themes'; import { useState, type FC, type FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; import { Badge } from './ui/badge'; import { Button } from './ui/button'; @@ -17,6 +29,7 @@ import { } from './ui/dialog'; import { Input } from './ui/input'; import { Label } from './ui/label'; +import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; import { ScrollArea } from './ui/scroll-area'; import { Textarea } from './ui/textarea'; @@ -30,14 +43,17 @@ export const ChatSidebar: FC = ({ onRoomSelect, }) => { const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); const [newRoomData, setNewRoomData] = useState({ name: '', description: '', memberUsernames: [], }); - const { user, logout } = useAuthStore(); const { chatRooms, onlineUsers, setChatRooms, addChatRoom } = useChatStore(); + const { user, logout } = useAuthStore(); + const navigate = useNavigate(); + const { theme, setTheme } = useTheme(); const { isLoading } = useQuery({ queryKey: ['chatRooms'], @@ -56,10 +72,13 @@ export const ChatSidebar: FC = ({ mutationFn: chatAPI.createChatRoom, onSuccess: (response) => { if (response.data.success) { - addChatRoom(response.data.data); + const newRoom = response.data.data; + addChatRoom(newRoom); setIsCreateDialogOpen(false); setNewRoomData({ name: '', description: '', memberUsernames: [] }); toast.success('Chat room created successfully'); + + navigate(`/room/${newRoom.id}`); } }, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -78,6 +97,22 @@ export const ChatSidebar: FC = ({ .length; }; + const handleRoomSelect = (roomId: string) => { + onRoomSelect(roomId); + navigate(`/room/${roomId}`); + }; + + const handleLogout = () => { + logout(); + navigate('/login'); + setIsUserMenuOpen(false); + }; + + const handleThemeChange = (newTheme: string) => { + setTheme(newTheme); + setIsUserMenuOpen(false); + }; + if (isLoading) { return (
@@ -91,11 +126,10 @@ export const ChatSidebar: FC = ({ } return ( -
- {/* Header */} -
+
+
-

Chat Rooms

+

Chat Rooms

= ({
-
- Welcome, {user?.username} - -
{chatRooms.map((room: ChatRoom) => (
onRoomSelect(room.id)} + className={`p-3 rounded-lg cursor-pointer transition-colors ${ + selectedRoomId === room.id + ? 'bg-blue-100 border-blue-200 border dark:bg-blue-900/30 dark:border-blue-800' + : 'bg-white hover:bg-gray-100 border border-gray-200 dark:bg-gray-700 dark:border-gray-600 dark:hover:bg-gray-600' + }`} + onClick={() => handleRoomSelect(room.id)} >
-

{room.name}

+

+ {room.name} +

@@ -193,11 +227,11 @@ export const ChatSidebar: FC = ({
{room.description && ( -

+

{room.description}

)} -
+
{room._count.messages} messages @@ -206,14 +240,20 @@ export const ChatSidebar: FC = ({ {room.members.slice(0, 3).map((member) => (
{member.user.username.charAt(0).toUpperCase()}
))} {room.members.length > 3 && ( -
+
+{room.members.length - 3}
)} @@ -223,6 +263,90 @@ export const ChatSidebar: FC = ({ ))}
+
+ + +
+
+ {user?.avatar ? ( + {user?.username} + ) : ( + + )} +
+
+

+ {user?.username} +

+

+ Online +

+
+
+
+ +
+
+ + + +
+ + +
+
+
+
); }; diff --git a/frontend/src/components/MessageCard.tsx b/frontend/src/components/MessageCard.tsx index 081819a..a8aa8bc 100644 --- a/frontend/src/components/MessageCard.tsx +++ b/frontend/src/components/MessageCard.tsx @@ -3,7 +3,7 @@ import { useAuthStore } from '@/stores/authStore'; import { useSocketStore } from '@/stores/socketStore'; import type { Message, MessageReaction } from '@/types'; import { Heart, Smile, ThumbsUp } from 'lucide-react'; -import { useRef, useState, type FC } from 'react'; +import { useMemo, useRef, useState, type FC } from 'react'; import { Button } from './ui/button'; import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; @@ -58,6 +58,23 @@ export const MessageCard: FC<{ message: Message }> = ({ message }) => { {} as Record ); + const messageStatus = useMemo(() => { + if (!isCurrentUser) return null; + + if ((message.seenBy ?? []).length > 0) { + return ( +
+ Seen{' '} + {(message.seenBy ?? []).length > 1 + ? `by ${(message.seenBy ?? []).length}` + : ''} +
+ ); + } + + return
Delivered
; + }, [isCurrentUser, message.seenBy]); + return (
= ({ message }) => { )} > {!isCurrentUser && ( -
+
{message.user.username}
)} @@ -135,10 +152,10 @@ export const MessageCard: FC<{ message: Message }> = ({ message }) => {
-
+
{formatTime(message.createdAt)}
- + {messageStatus} {Object.keys(reactionGroups).length > 0 && (
{Object.entries(reactionGroups).map(([type, count]) => ( @@ -183,7 +200,7 @@ export const MessageCard: FC<{ message: Message }> = ({ message }) => { )} /> )} - {count} + {count}
))}
diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 8e3a43d..8507249 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -1,5 +1,7 @@ import { chatAPI } from '@/lib/api'; +import { useAuthStore } from '@/stores/authStore'; import { useChatStore } from '@/stores/chatStore'; +import { useSocketStore } from '@/stores/socketStore'; import type { Message } from '@/types'; import { useQuery } from '@tanstack/react-query'; import { useEffect, useRef, type FC } from 'react'; @@ -13,8 +15,11 @@ type MessageListProps = { export const MessageList: FC = ({ roomId }) => { const { messages, setMessages } = useChatStore(); + const { socket } = useSocketStore(); + const { user } = useAuthStore(); const messagesEndRef = useRef(null); + const scrollAreaRef = useRef(null); const roomMessages = messages[roomId] ?? []; const { isLoading } = useQuery({ @@ -31,20 +36,39 @@ export const MessageList: FC = ({ roomId }) => { }); useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [roomMessages]); + if (!isLoading && roomMessages.length > 0) { + setTimeout(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'auto' }); + }, 100); + } + }, [isLoading, roomId]); + + useEffect(() => { + if (!socket || !user || !roomId || roomMessages.length === 0) return; + + const unseenMessages = roomMessages.filter( + (msg) => msg.userId !== user.id && !msg.seenBy?.includes(user.id) + ); + + if (unseenMessages.length > 0) { + socket.emit('messages_seen', { + roomId, + messageIds: unseenMessages.map((msg) => msg.id), + }); + } + }, [socket, user, roomId, roomMessages]); if (isLoading) { return ( -
-
+
+
); } return ( -
- +
+
{roomMessages.map((message: Message) => ( @@ -55,4 +79,4 @@ export const MessageList: FC = ({ roomId }) => {
); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx new file mode 100644 index 0000000..241f0eb --- /dev/null +++ b/frontend/src/components/Navbar.tsx @@ -0,0 +1,50 @@ +import { useChatStore } from '@/stores/chatStore'; +import { Settings, Users } from 'lucide-react'; +import { type FC } from 'react'; +import { useParams } from 'react-router-dom'; +import { Badge } from './ui/badge'; +import { Button } from './ui/button'; + +export const NavBar: FC = () => { + const { chatRooms, onlineUsers } = useChatStore(); + const { roomId } = useParams<{ roomId: string }>(); + + const currentRoom = chatRooms.find((room) => room.id === roomId); + + const getOnlineMembersCount = () => { + if (!currentRoom) return 0; + return currentRoom.members.filter((member) => + onlineUsers.has(member.userId) + ).length; + }; + + return ( +
+
+ {currentRoom ? ( +
+
+

{currentRoom.name}

+ + + {getOnlineMembersCount()} / {currentRoom.members.length} online + +
+ {currentRoom.description && ( +

{currentRoom.description}

+ )} +
+ ) : ( +

Chat App

+ )} +
+
+ {currentRoom && ( + + )} +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/UsersSidebar.tsx b/frontend/src/components/UsersSidebar.tsx new file mode 100644 index 0000000..eaf900c --- /dev/null +++ b/frontend/src/components/UsersSidebar.tsx @@ -0,0 +1,161 @@ +import { useChatStore } from '@/stores/chatStore'; +import { + differenceInHours, + differenceInMinutes, + format, + isYesterday, +} from 'date-fns'; +import { ChevronRight, User } from 'lucide-react'; +import { useState, type FC } from 'react'; +import { ScrollArea } from './ui/scroll-area'; + +type UsersSidebarProps = { + roomId: string; +}; + +export const UsersSidebar: FC = ({ roomId }) => { + const [isCollapsed, setIsCollapsed] = useState(false); + const { chatRooms, onlineUsers } = useChatStore(); + + const currentRoom = chatRooms.find((room) => room.id === roomId); + + const formatLastSeen = (lastSeen: string) => { + if (!lastSeen) return 'Unknown'; + + const date = new Date(lastSeen); + const now = new Date(); + + const mins = differenceInMinutes(now, date); + const hours = differenceInHours(now, date); + + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + if (hours < 24) return `${hours}h ago`; + if (isYesterday(date)) return 'yesterday'; + + return format(date, 'MMM d, yyyy'); + }; + + const onlineMembers = + currentRoom?.members.filter((member) => onlineUsers.has(member.userId)) ?? + []; + + const offlineMembers = + currentRoom?.members.filter((member) => !onlineUsers.has(member.userId)) ?? + []; + + if (!currentRoom) return null; + + return ( +
+ + + {!isCollapsed && ( +
+
+

+ Users in {currentRoom.name} +

+
+ + +
+ {onlineMembers.length > 0 && ( +
+

+ Online • {onlineMembers.length} +

+
+ {onlineMembers.map((member) => ( +
+
+
+ {member.user.avatar ? ( + {member.user.username} + ) : ( + + )} +
+
+
+
+
+

+ {member.user.username} +

+ {member.role && ( + + {member.role} + + )} +
+
+
+

+ Online now +

+
+
+
+ ))} +
+
+ )} + + {offlineMembers.length > 0 && ( +
+

+ Offline • {offlineMembers.length} +

+
+ {offlineMembers.map((member) => ( +
+
+
+ {member.user.avatar ? ( + {member.user.username} + ) : ( + + )} +
+
+
+
+

+ {member.user.username} +

+

+ Last seen {formatLastSeen(member.user.lastSeen)} +

+
+
+ ))} +
+
+ )} +
+
+
+ )} +
+ ); +}; diff --git a/frontend/src/lib/socket.ts b/frontend/src/lib/socket.ts index 4c3f185..d9f88c6 100644 --- a/frontend/src/lib/socket.ts +++ b/frontend/src/lib/socket.ts @@ -144,6 +144,19 @@ class SocketService { } ); + this.socket.on( + 'message_seen_update', + (data: { messageId: string; seenBy: string[] }) => { + Object.keys(useChatStore.getState().messages).forEach((roomId) => { + useChatStore.getState().messages[roomId].forEach((msg) => { + if (msg.id === data.messageId) { + msg.seenBy = data.seenBy; + } + }); + }); + } + ); + this.socket.on('error', (data: { message: string }) => { console.error('Socket error:', data.message); toast.error(`Socket error: ${data.message}`, { diff --git a/frontend/src/stores/chatStore.ts b/frontend/src/stores/chatStore.ts index 30bdb7b..03bfb5d 100644 --- a/frontend/src/stores/chatStore.ts +++ b/frontend/src/stores/chatStore.ts @@ -23,6 +23,7 @@ type ChatState = { removeOnlineUser: (userId: string) => void; addTypingUser: (roomId: string, username: string) => void; removeTypingUser: (roomId: string, username: string) => void; + updateMessageSeen: (messageId: string, seenBy: string[]) => void; }; export const useChatStore = create()( @@ -132,5 +133,19 @@ export const useChatStore = create()( }; }); }, + + updateMessageSeen: (messageId: string, seenBy: string[]) => { + set((state) => { + const newMessages = { ...state.messages }; + + Object.keys(newMessages).forEach((roomId) => { + newMessages[roomId] = newMessages[roomId].map((message) => + message.id === messageId ? { ...message, seenBy } : message + ); + }); + + return { messages: newMessages }; + }); + }, })) ); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 9ccdd8b..689c741 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -36,6 +36,7 @@ export type Message = { roomId: string; user: User; reactions: MessageReaction[]; + seenBy?: string[]; }; export type MessageReaction = {