seen user sidebar...

This commit is contained in:
Gal Podlipnik 2025-06-12 14:50:54 +02:00
parent e67f08bc61
commit 094582aab7
34 changed files with 776 additions and 897 deletions

View File

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

View File

@ -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);

View File

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

View File

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

64
backend/dist/index.js vendored
View File

@ -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();
})();

View File

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

View File

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

View File

@ -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([]),
});

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

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

View File

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

View File

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

View File

@ -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}`);
}
};

View File

@ -1,2 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

View File

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

View File

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

View File

@ -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);

View File

@ -106,4 +106,52 @@ export class MessageService {
};
}
}
static async markMessagesAsSeen(userId: string, messageIds: string[]): Promise<ApiResponse> {
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",
};
}
}
}

View File

@ -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}`);

View File

@ -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<T = any> = {
success: boolean;

View File

@ -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",

View File

@ -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",

View File

@ -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<ProtectedRouteProps> = ({ children }) => {
const { isAuthenticated } = useAuthStore();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};
const AppContent: FC = () => {
const { isAuthenticated } = useAuthStore();
useSocket();
return (
<>
{isAuthenticated ? <ChatApp /> : <LoginForm />}
<BrowserRouter>
<Routes>
<Route
path="/login"
element={
!isAuthenticated ? <LoginForm /> : <Navigate to="/" replace />
}
/>
<Route
path="/"
element={
<ProtectedRoute>
<ChatApp />
</ProtectedRoute>
}
/>
<Route
path="/room/:roomId"
element={
<ProtectedRoute>
<ChatApp />
</ProtectedRoute>
}
/>
</Routes>
<Toaster />
</>
</BrowserRouter>
);
};
function App() {
return (
<QueryClientProvider client={queryClient}>
<AppContent />
</QueryClientProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<QueryClientProvider client={queryClient}>
<AppContent />
</QueryClientProvider>
</ThemeProvider>
);
}
export default App;
export default App;

View File

@ -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 (
<div className="flex h-screen bg-white">
<ChatSidebar
selectedRoomId={selectedRoomId}
selectedRoomId={roomId || selectedRoomId}
onRoomSelect={setSelectedRoom}
/>
<div className="flex-1 flex flex-col">
{selectedRoomId ? (
<>
<div className="p-4 border-b border-gray-200 bg-gray-50">
<h1 className="text-lg font-semibold">Chat Room</h1>
<NavBar />
<div className="flex-1 flex flex-col overflow-hidden">
{roomId ? (
<>
<div className="flex-1 flex overflow-hidden">
<div className="flex-1 flex flex-col overflow-hidden">
<MessageList roomId={roomId} />
<MessageInput roomId={roomId} />
</div>
<UsersSidebar roomId={roomId} />
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center text-gray-500">
<div className="text-center">
<h2 className="text-xl font-semibold mb-2">
Welcome to Chat App
</h2>
<p>Select a chat room to start messaging</p>
</div>
</div>
<MessageList roomId={selectedRoomId} />
<MessageInput roomId={selectedRoomId} />
</>
) : (
<div className="flex-1 flex items-center justify-center text-gray-500">
<div className="text-center">
<h2 className="text-xl font-semibold mb-2">
Welcome to Chat App
</h2>
<p>Select a chat room to start messaging</p>
</div>
</div>
)}
)}
</div>
</div>
</div>
);
};
};

View File

@ -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<ChatSidebarProps> = ({
onRoomSelect,
}) => {
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const [newRoomData, setNewRoomData] = useState<CreateChatRoomRequest>({
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<ChatSidebarProps> = ({
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<ChatSidebarProps> = ({
.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 (
<div className="w-80 bg-gray-50 border-r border-gray-200 p-4">
@ -91,11 +126,10 @@ export const ChatSidebar: FC<ChatSidebarProps> = ({
}
return (
<div className="w-80 bg-gray-50 border-r border-gray-200 flex flex-col">
{/* Header */}
<div className="p-4 border-b border-gray-200">
<div className="w-80 bg-gray-50 border-r border-gray-200 flex flex-col dark:bg-gray-800 dark:border-gray-700">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Chat Rooms</h2>
<h2 className="text-lg font-semibold dark:text-white">Chat Rooms</h2>
<Dialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
@ -168,23 +202,23 @@ export const ChatSidebar: FC<ChatSidebarProps> = ({
</DialogContent>
</Dialog>
</div>
<div className="flex items-center space-x-2 text-sm text-gray-600">
<span>Welcome, {user?.username}</span>
<Button size="sm" variant="ghost" onClick={logout}>
<LogOut className="h-4 w-4" />
</Button>
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-2 space-y-2">
{chatRooms.map((room: ChatRoom) => (
<div
key={room.id}
className={`p-3 rounded-lg cursor-pointer transition-colors ${selectedRoomId === room.id ? 'bg-blue-100 border-blue-200 border' : 'bg-white hover:bg-gray-100 border border-gray-200'}`}
onClick={() => 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)}
>
<div className="flex items-center justify-between mb-1">
<h3 className="font-medium text-sm">{room.name}</h3>
<h3 className="font-medium text-sm dark:text-white">
{room.name}
</h3>
<div className="flex items-center space-x-1">
<Badge variant="secondary" className="text-xs">
<Users className="h-3 w-3 mr-1" />
@ -193,11 +227,11 @@ export const ChatSidebar: FC<ChatSidebarProps> = ({
</div>
</div>
{room.description && (
<p className="text-xs text-gray-500 mb-2 truncate">
<p className="text-xs text-gray-500 mb-2 truncate dark:text-gray-300">
{room.description}
</p>
)}
<div className="flex items-center justify-between text-xs text-gray-500">
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<div className="flex items-center">
<MessageCircle className="h-3 w-3 mr-1" />
{room._count.messages} messages
@ -206,14 +240,20 @@ export const ChatSidebar: FC<ChatSidebarProps> = ({
{room.members.slice(0, 3).map((member) => (
<div
key={member.id}
className={`w-4 h-4 rounded-full border border-white flex items-center justify-center text-xs font-medium ${onlineUsers.has(member.userId) ? 'bg-green-500 text-white' : 'bg-gray-300 text-gray-600 '}`}
title={`${member.user.username} - ${onlineUsers.has(member.userId) ? 'Online' : 'Offline'}`}
className={`w-4 h-4 rounded-full border border-white dark:border-gray-800 flex items-center justify-center text-xs font-medium ${
onlineUsers.has(member.userId)
? 'bg-green-500 text-white'
: 'bg-gray-300 text-gray-600 dark:bg-gray-500 dark:text-gray-300'
}`}
title={`${member.user.username} - ${
onlineUsers.has(member.userId) ? 'Online' : 'Offline'
}`}
>
{member.user.username.charAt(0).toUpperCase()}
</div>
))}
{room.members.length > 3 && (
<div className="w-4 h-4 rounded-full border border-white bg-gray-200 flex items-center justify-center text-xs">
<div className="w-4 h-4 rounded-full border border-white dark:border-gray-800 bg-gray-200 dark:bg-gray-600 flex items-center justify-center text-xs">
+{room.members.length - 3}
</div>
)}
@ -223,6 +263,90 @@ export const ChatSidebar: FC<ChatSidebarProps> = ({
))}
</div>
</ScrollArea>
<div className="p-4 border-t border-gray-200 mt-auto dark:border-gray-700">
<Popover open={isUserMenuOpen} onOpenChange={setIsUserMenuOpen}>
<PopoverTrigger asChild>
<div className="flex items-center space-x-3 cursor-pointer p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<div className="w-10 h-10 rounded-full bg-blue-500 text-white flex items-center justify-center">
{user?.avatar ? (
<img
src={user.avatar}
alt={user?.username}
className="w-full h-full rounded-full"
/>
) : (
<User className="h-6 w-6" />
)}
</div>
<div className="flex-1">
<p className="font-medium text-sm dark:text-white">
{user?.username}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Online
</p>
</div>
</div>
</PopoverTrigger>
<PopoverContent
className="w-48 p-0 dark:bg-gray-800 dark:border-gray-700"
align="start"
side="top"
>
<div className="py-1">
<div className="border-b border-gray-200 dark:border-gray-700 pb-1">
<button
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-white flex items-center gap-2"
onClick={() => handleThemeChange('light')}
>
<Sun className="h-4 w-4" />
Light
{theme === 'light' && (
<span className="ml-auto text-blue-500"></span>
)}
</button>
<button
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-white flex items-center gap-2"
onClick={() => handleThemeChange('dark')}
>
<Moon className="h-4 w-4" />
Dark
{theme === 'dark' && (
<span className="ml-auto text-blue-500"></span>
)}
</button>
<button
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-white flex items-center gap-2"
onClick={() => handleThemeChange('system')}
>
<Laptop className="h-4 w-4" />
System
{theme === 'system' && (
<span className="ml-auto text-blue-500"></span>
)}
</button>
</div>
<button
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-white flex items-center gap-2"
onClick={() => {
setIsUserMenuOpen(false);
// settings functionality TODO
}}
>
<Settings className="h-4 w-4" />
Settings
</button>
<button
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 text-red-500 flex items-center gap-2"
onClick={handleLogout}
>
<LogOut className="h-4 w-4" />
Logout
</button>
</div>
</PopoverContent>
</Popover>
</div>
</div>
);
};

View File

@ -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<string, number>
);
const messageStatus = useMemo(() => {
if (!isCurrentUser) return null;
if ((message.seenBy ?? []).length > 0) {
return (
<div className="text-xs text-gray-400 px-2">
Seen{' '}
{(message.seenBy ?? []).length > 1
? `by ${(message.seenBy ?? []).length}`
: ''}
</div>
);
}
return <div className="text-xs text-gray-400 px-2">Delivered</div>;
}, [isCurrentUser, message.seenBy]);
return (
<div
className={cn(
@ -66,7 +83,7 @@ export const MessageCard: FC<{ message: Message }> = ({ message }) => {
)}
>
{!isCurrentUser && (
<div className="text-xs font-medium ml-4 text-gray-600">
<div className="text-xs font-medium ml-4 text-gray-600 dark:text-gray-300">
{message.user.username}
</div>
)}
@ -135,10 +152,10 @@ export const MessageCard: FC<{ message: Message }> = ({ message }) => {
</Popover>
</div>
<div className="text-xs text-gray-500 px-2">
<div className="text-xs text-gray-500 px-2 dark:text-gray-400">
{formatTime(message.createdAt)}
</div>
{messageStatus}
{Object.keys(reactionGroups).length > 0 && (
<div className="flex gap-1 mt-1">
{Object.entries(reactionGroups).map(([type, count]) => (
@ -183,7 +200,7 @@ export const MessageCard: FC<{ message: Message }> = ({ message }) => {
)}
/>
)}
<span>{count}</span>
<span className="text-gray-600 dark:text-black">{count}</span>
</div>
))}
</div>

View File

@ -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<MessageListProps> = ({ roomId }) => {
const { messages, setMessages } = useChatStore();
const { socket } = useSocketStore();
const { user } = useAuthStore();
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const roomMessages = messages[roomId] ?? [];
const { isLoading } = useQuery({
@ -31,20 +36,39 @@ export const MessageList: FC<MessageListProps> = ({ 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 (
<div className="flex-1 flex items-center justify-center">
<div className="h-8 w-8 rounded-full border-4 border-gray-300 border-t-gray-500 animate-spin"></div>
<div className="flex-1 flex items-center justify-center dark:bg-gray-900">
<div className="h-8 w-8 rounded-full border-4 border-gray-300 border-t-gray-500 animate-spin dark:border-gray-600 dark:border-t-gray-300"></div>
</div>
);
}
return (
<div className="flex-1 overflow-hidden">
<ScrollArea className="h-full">
<div className="flex-1 overflow-hidden bg-white dark:bg-gray-900">
<ScrollArea className="h-full" ref={scrollAreaRef}>
<div className="space-y-4 p-4">
{roomMessages.map((message: Message) => (
<MessageCard key={message.id} message={message} />
@ -55,4 +79,4 @@ export const MessageList: FC<MessageListProps> = ({ roomId }) => {
</ScrollArea>
</div>
);
};
};

View File

@ -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 (
<div className="p-4 border-b border-gray-200 bg-gray-50 flex items-center justify-between dark:bg-gray-800 dark:border-gray-700">
<div>
{currentRoom ? (
<div className="flex flex-col">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold dark:text-white">{currentRoom.name}</h1>
<Badge variant="secondary" className="text-xs">
<Users className="h-3 w-3 mr-1" />
{getOnlineMembersCount()} / {currentRoom.members.length} online
</Badge>
</div>
{currentRoom.description && (
<p className="text-sm text-gray-500 dark:text-gray-300">{currentRoom.description}</p>
)}
</div>
) : (
<h1 className="text-lg font-semibold dark:text-white">Chat App</h1>
)}
</div>
<div>
{currentRoom && (
<Button size="sm" variant="ghost">
<Settings className="h-4 w-4" />
</Button>
)}
</div>
</div>
);
};

View File

@ -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<UsersSidebarProps> = ({ 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 (
<div
className={`border-l border-gray-200 bg-gray-50 dark:bg-gray-800 dark:border-gray-700 transition-all duration-300 flex ${
isCollapsed ? 'w-12' : 'w-64'
}`}
>
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="h-12 w-12 flex items-center justify-center text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<ChevronRight
className={`h-5 w-5 transition-transform ${isCollapsed ? 'rotate-180' : ''}`}
/>
</button>
{!isCollapsed && (
<div className="flex-1 flex flex-col">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-medium text-sm dark:text-white">
Users in {currentRoom.name}
</h3>
</div>
<ScrollArea className="flex-1">
<div className="p-3">
{onlineMembers.length > 0 && (
<div className="mb-6">
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-2 uppercase tracking-wider">
Online {onlineMembers.length}
</h4>
<div className="space-y-3">
{onlineMembers.map((member) => (
<div key={member.id} className="flex items-center gap-3">
<div className="relative">
<div className="w-10 h-10 rounded-full bg-blue-500 text-white flex items-center justify-center">
{member.user.avatar ? (
<img
src={member.user.avatar}
alt={member.user.username}
className="w-full h-full rounded-full"
/>
) : (
<User className="h-5 w-5" />
)}
</div>
<div className="absolute bottom-0 right-0 w-3 h-3 bg-green-500 border-2 border-white dark:border-gray-800 rounded-full"></div>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<p className="text-sm font-medium dark:text-white">
{member.user.username}
</p>
{member.role && (
<span className="px-1.5 py-0.5 text-xs rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-medium">
{member.role}
</span>
)}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<div className="w-1.5 h-1.5 rounded-full bg-green-500"></div>
<p className="text-xs text-gray-500 dark:text-gray-400">
Online now
</p>
</div>
</div>
</div>
))}
</div>
</div>
)}
{offlineMembers.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-2 uppercase tracking-wider">
Offline {offlineMembers.length}
</h4>
<div className="space-y-3">
{offlineMembers.map((member) => (
<div key={member.id} className="flex items-center gap-3">
<div className="relative">
<div className="w-10 h-10 rounded-full bg-gray-300 dark:bg-gray-600 text-gray-600 dark:text-gray-300 flex items-center justify-center">
{member.user.avatar ? (
<img
src={member.user.avatar}
alt={member.user.username}
className="w-full h-full rounded-full opacity-70"
/>
) : (
<User className="h-5 w-5" />
)}
</div>
<div className="absolute bottom-0 right-0 w-3 h-3 bg-gray-400 border-2 border-white dark:border-gray-800 rounded-full"></div>
</div>
<div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
{member.user.username}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Last seen {formatLastSeen(member.user.lastSeen)}
</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
</ScrollArea>
</div>
)}
</div>
);
};

View File

@ -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}`, {

View File

@ -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<ChatState>()(
@ -132,5 +133,19 @@ export const useChatStore = create<ChatState>()(
};
});
},
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 };
});
},
}))
);

View File

@ -36,6 +36,7 @@ export type Message = {
roomId: string;
user: User;
reactions: MessageReaction[];
seenBy?: string[];
};
export type MessageReaction = {