seen user sidebar...
This commit is contained in:
parent
e67f08bc61
commit
094582aab7
22
backend/dist/config/database.js
vendored
22
backend/dist/config/database.js
vendored
@ -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;
|
||||
12
backend/dist/config/env.js
vendored
12
backend/dist/config/env.js
vendored
@ -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);
|
||||
17
backend/dist/controllers/authController.js
vendored
17
backend/dist/controllers/authController.js
vendored
@ -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;
|
||||
37
backend/dist/controllers/chatController.js
vendored
37
backend/dist/controllers/chatController.js
vendored
@ -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
64
backend/dist/index.js
vendored
@ -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();
|
||||
})();
|
||||
51
backend/dist/middleware/auth.js
vendored
51
backend/dist/middleware/auth.js
vendored
@ -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;
|
||||
42
backend/dist/middleware/rateLimiter.js
vendored
42
backend/dist/middleware/rateLimiter.js
vendored
@ -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;
|
||||
38
backend/dist/middleware/validation.js
vendored
38
backend/dist/middleware/validation.js
vendored
@ -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([]),
|
||||
});
|
||||
10
backend/dist/routes/authRoutes.js
vendored
10
backend/dist/routes/authRoutes.js
vendored
@ -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);
|
||||
13
backend/dist/routes/chatRoutes.js
vendored
13
backend/dist/routes/chatRoutes.js
vendored
@ -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);
|
||||
10
backend/dist/routes/index.js
vendored
10
backend/dist/routes/index.js
vendored
@ -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);
|
||||
100
backend/dist/services/authService.js
vendored
100
backend/dist/services/authService.js
vendored
@ -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;
|
||||
186
backend/dist/services/chatService.js
vendored
186
backend/dist/services/chatService.js
vendored
@ -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;
|
||||
108
backend/dist/services/messageService.js
vendored
108
backend/dist/services/messageService.js
vendored
@ -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;
|
||||
119
backend/dist/socket/socketHandlers.js
vendored
119
backend/dist/socket/socketHandlers.js
vendored
@ -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}`);
|
||||
}
|
||||
};
|
||||
2
backend/dist/types/index.js
vendored
2
backend/dist/types/index.js
vendored
@ -1,2 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
@ -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;
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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;
|
||||
|
||||
65
frontend/package-lock.json
generated
65
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
50
frontend/src/components/Navbar.tsx
Normal file
50
frontend/src/components/Navbar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
161
frontend/src/components/UsersSidebar.tsx
Normal file
161
frontend/src/components/UsersSidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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}`, {
|
||||
|
||||
@ -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 };
|
||||
});
|
||||
},
|
||||
}))
|
||||
);
|
||||
|
||||
@ -36,6 +36,7 @@ export type Message = {
|
||||
roomId: string;
|
||||
user: User;
|
||||
reactions: MessageReaction[];
|
||||
seenBy?: string[];
|
||||
};
|
||||
|
||||
export type MessageReaction = {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user