real time messaging

This commit is contained in:
jackiettran
2025-11-08 18:20:02 -05:00
parent de32b68ec4
commit 7a5bff8f2b
19 changed files with 2046 additions and 20 deletions

View File

@@ -32,6 +32,7 @@
"pg": "^8.16.3",
"sequelize": "^6.37.7",
"sequelize-cli": "^6.6.3",
"socket.io": "^4.8.1",
"stripe": "^18.4.0",
"uuid": "^11.1.0",
"winston": "^3.17.0",
@@ -43,6 +44,7 @@
"nodemon": "^3.1.10",
"sequelize-mock": "^0.10.2",
"sinon": "^21.0.0",
"socket.io-client": "^4.8.1",
"supertest": "^7.1.4"
}
},
@@ -3530,6 +3532,12 @@
"node": ">=18.0.0"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -3575,6 +3583,15 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -3969,6 +3986,15 @@
],
"license": "MIT"
},
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"license": "MIT",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz",
@@ -4934,6 +4960,170 @@
"node": ">= 0.8"
}
},
"node_modules/engine.io": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz",
"integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-client": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-client/node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
@@ -8790,6 +8980,196 @@
"node": ">=8"
}
},
"node_modules/socket.io": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
"integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.3.2",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz",
"integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==",
"license": "MIT",
"dependencies": {
"debug": "~4.3.4",
"ws": "~8.17.1"
}
},
"node_modules/socket.io-adapter/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-adapter/node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io/node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -9841,6 +10221,15 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@@ -51,6 +51,7 @@
"pg": "^8.16.3",
"sequelize": "^6.37.7",
"sequelize-cli": "^6.6.3",
"socket.io": "^4.8.1",
"stripe": "^18.4.0",
"uuid": "^11.1.0",
"winston": "^3.17.0",
@@ -62,6 +63,7 @@
"nodemon": "^3.1.10",
"sequelize-mock": "^0.10.2",
"sinon": "^21.0.0",
"socket.io-client": "^4.8.1",
"supertest": "^7.1.4"
}
}

View File

@@ -2,6 +2,7 @@ const express = require('express');
const { Message, User } = require('../models');
const { authenticateToken } = require('../middleware/auth');
const logger = require('../utils/logger');
const { emitNewMessage, emitMessageRead } = require('../sockets/messageSocket');
const router = express.Router();
// Get all messages for the current user (inbox)
@@ -109,15 +110,26 @@ router.get('/:id', authenticateToken, async (req, res) => {
}
// Mark as read if user is the receiver
if (message.receiverId === req.user.id && !message.isRead) {
const wasUnread = message.receiverId === req.user.id && !message.isRead;
if (wasUnread) {
await message.update({ isRead: true });
// Emit socket event to sender for real-time read receipt
const io = req.app.get('io');
if (io) {
emitMessageRead(io, message.senderId, {
messageId: message.id,
readAt: new Date().toISOString(),
readBy: req.user.id
});
}
}
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Message fetched", {
userId: req.user.id,
messageId: req.params.id,
markedAsRead: message.receiverId === req.user.id && !message.isRead
markedAsRead: wasUnread
});
res.json(message);
@@ -165,6 +177,12 @@ router.post('/', authenticateToken, async (req, res) => {
}]
});
// Emit socket event to receiver for real-time notification
const io = req.app.get('io');
if (io) {
emitNewMessage(io, receiverId, messageWithSender.toJSON());
}
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Message sent", {
senderId: req.user.id,
@@ -202,6 +220,16 @@ router.put('/:id/read', authenticateToken, async (req, res) => {
await message.update({ isRead: true });
// Emit socket event to sender for real-time read receipt
const io = req.app.get('io');
if (io) {
emitMessageRead(io, message.senderId, {
messageId: message.id,
readAt: new Date().toISOString(),
readBy: req.user.id
});
}
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Message marked as read", {
userId: req.user.id,

View File

@@ -6,6 +6,8 @@ require("dotenv").config({
path: envFile,
});
const express = require("express");
const http = require("http");
const { Server } = require("socket.io");
const cors = require("cors");
const bodyParser = require("body-parser");
const path = require("path");
@@ -31,7 +33,30 @@ const PayoutProcessor = require("./jobs/payoutProcessor");
const RentalStatusJob = require("./jobs/rentalStatusJob");
const ConditionCheckReminderJob = require("./jobs/conditionCheckReminder");
// Socket.io setup
const { authenticateSocket } = require("./sockets/socketAuth");
const { initializeMessageSocket } = require("./sockets/messageSocket");
const app = express();
const server = http.createServer(app);
// Initialize Socket.io with CORS
const io = new Server(server, {
cors: {
origin: process.env.FRONTEND_URL || "http://localhost:3000",
credentials: true,
methods: ["GET", "POST"],
},
});
// Apply socket authentication middleware
io.use(authenticateSocket);
// Initialize message socket handlers
initializeMessageSocket(io);
// Store io instance in app for use in routes
app.set("io", io);
// Import security middleware
const {
@@ -152,11 +177,12 @@ sequelize
const conditionCheckJobs = ConditionCheckReminderJob.startScheduledReminders();
logger.info("Condition check reminder job started");
app.listen(PORT, () => {
server.listen(PORT, () => {
logger.info(`Server is running on port ${PORT}`, {
port: PORT,
environment: env,
});
logger.info("Socket.io server initialized");
});
})
.catch((err) => {

View File

@@ -0,0 +1,343 @@
const logger = require("../utils/logger");
/**
* Map to track typing status: { userId_receiverId: timestamp }
* Used to prevent duplicate typing events and auto-clear stale states
*/
const typingStatus = new Map();
/**
* Cleanup interval for stale typing indicators (every 5 seconds)
*/
setInterval(() => {
const now = Date.now();
const TYPING_TIMEOUT = 5000; // 5 seconds
for (const [key, timestamp] of typingStatus.entries()) {
if (now - timestamp > TYPING_TIMEOUT) {
typingStatus.delete(key);
}
}
}, 5000);
/**
* Generate conversation room ID from two user IDs
* Always sorts IDs to ensure consistent room naming regardless of who initiates
*/
const getConversationRoom = (userId1, userId2) => {
const sorted = [userId1, userId2].sort();
return `conv_${sorted[0]}_${sorted[1]}`;
};
/**
* Get personal user room ID
*/
const getUserRoom = (userId) => {
return `user_${userId}`;
};
/**
* Initialize message socket handlers
* @param {SocketIO.Server} io - Socket.io server instance
*/
const initializeMessageSocket = (io) => {
io.on("connection", (socket) => {
const userId = socket.userId;
const userRoom = getUserRoom(userId);
logger.info("User connected to messaging", {
socketId: socket.id,
userId,
userEmail: socket.user.email,
});
// Join user's personal room for receiving direct messages
socket.join(userRoom);
logger.debug("User joined personal room", {
socketId: socket.id,
userId,
room: userRoom,
});
/**
* Join a specific conversation room
* Used when user opens a chat with another user
*/
socket.on("join_conversation", (data) => {
try {
const { otherUserId } = data;
if (!otherUserId) {
logger.warn("join_conversation - missing otherUserId", {
socketId: socket.id,
userId,
});
return;
}
const conversationRoom = getConversationRoom(userId, otherUserId);
socket.join(conversationRoom);
logger.debug("User joined conversation room", {
socketId: socket.id,
userId,
otherUserId,
room: conversationRoom,
});
socket.emit("conversation_joined", {
conversationRoom,
otherUserId,
});
} catch (error) {
logger.error("Error joining conversation", {
socketId: socket.id,
userId,
error: error.message,
});
}
});
/**
* Leave a specific conversation room
* Used when user closes a chat
*/
socket.on("leave_conversation", (data) => {
try {
const { otherUserId } = data;
if (!otherUserId) {
return;
}
const conversationRoom = getConversationRoom(userId, otherUserId);
socket.leave(conversationRoom);
logger.debug("User left conversation room", {
socketId: socket.id,
userId,
otherUserId,
room: conversationRoom,
});
} catch (error) {
logger.error("Error leaving conversation", {
socketId: socket.id,
userId,
error: error.message,
});
}
});
/**
* Typing start indicator
* Notifies the recipient that this user is typing
*/
socket.on("typing_start", (data) => {
try {
const { receiverId } = data;
if (!receiverId) {
return;
}
// Throttle typing events (prevent spam)
const typingKey = `${userId}_${receiverId}`;
const lastTyping = typingStatus.get(typingKey);
const now = Date.now();
if (lastTyping && now - lastTyping < 1000) {
// Ignore if typed within last 1 second
return;
}
typingStatus.set(typingKey, now);
// Emit to recipient's personal room
const receiverRoom = getUserRoom(receiverId);
io.to(receiverRoom).emit("user_typing", {
userId,
firstName: socket.user.firstName,
isTyping: true,
});
logger.debug("Typing indicator sent", {
socketId: socket.id,
senderId: userId,
receiverId,
});
} catch (error) {
logger.error("Error handling typing_start", {
socketId: socket.id,
userId,
error: error.message,
});
}
});
/**
* Typing stop indicator
* Notifies the recipient that this user stopped typing
*/
socket.on("typing_stop", (data) => {
try {
const { receiverId } = data;
if (!receiverId) {
return;
}
// Clear typing status
const typingKey = `${userId}_${receiverId}`;
typingStatus.delete(typingKey);
// Emit to recipient's personal room
const receiverRoom = getUserRoom(receiverId);
io.to(receiverRoom).emit("user_typing", {
userId,
firstName: socket.user.firstName,
isTyping: false,
});
logger.debug("Typing stop sent", {
socketId: socket.id,
senderId: userId,
receiverId,
});
} catch (error) {
logger.error("Error handling typing_stop", {
socketId: socket.id,
userId,
error: error.message,
});
}
});
/**
* Mark message as read (from client)
* This is handled by the REST API route, but we listen here for consistency
*/
socket.on("mark_message_read", (data) => {
try {
const { messageId, senderId } = data;
if (!messageId || !senderId) {
return;
}
// Emit to sender's room to update their UI
const senderRoom = getUserRoom(senderId);
io.to(senderRoom).emit("message_read", {
messageId,
readAt: new Date().toISOString(),
readBy: userId,
});
logger.debug("Message read notification sent", {
socketId: socket.id,
messageId,
readBy: userId,
notifiedUserId: senderId,
});
} catch (error) {
logger.error("Error handling mark_message_read", {
socketId: socket.id,
userId,
error: error.message,
});
}
});
/**
* Disconnect handler
* Clean up rooms and typing status
*/
socket.on("disconnect", (reason) => {
// Clean up all typing statuses for this user
for (const [key] of typingStatus.entries()) {
if (key.startsWith(`${userId}_`)) {
typingStatus.delete(key);
}
}
logger.info("User disconnected from messaging", {
socketId: socket.id,
userId,
reason,
});
});
/**
* Error handler
*/
socket.on("error", (error) => {
logger.error("Socket error", {
socketId: socket.id,
userId,
error: error.message,
stack: error.stack,
});
});
});
logger.info("Message socket handlers initialized");
};
/**
* Emit new message event to a specific user
* Called from message routes when a message is created
* @param {SocketIO.Server} io - Socket.io server instance
* @param {string} receiverId - User ID to send the message to
* @param {Object} messageData - Message object with sender info
*/
const emitNewMessage = (io, receiverId, messageData) => {
try {
const receiverRoom = getUserRoom(receiverId);
io.to(receiverRoom).emit("new_message", messageData);
logger.info("New message emitted", {
receiverId,
receiverRoom,
messageId: messageData.id,
senderId: messageData.senderId,
});
} catch (error) {
logger.error("Error emitting new message", {
receiverId,
messageId: messageData.id,
error: error.message,
});
}
};
/**
* Emit message read event to sender
* Called from message routes when a message is marked as read
* @param {SocketIO.Server} io - Socket.io server instance
* @param {string} senderId - User ID who sent the message
* @param {Object} readData - Read status data
*/
const emitMessageRead = (io, senderId, readData) => {
try {
const senderRoom = getUserRoom(senderId);
io.to(senderRoom).emit("message_read", readData);
logger.debug("Message read status emitted", {
senderId,
messageId: readData.messageId,
});
} catch (error) {
logger.error("Error emitting message read status", {
senderId,
messageId: readData.messageId,
error: error.message,
});
}
};
module.exports = {
initializeMessageSocket,
emitNewMessage,
emitMessageRead,
getConversationRoom,
getUserRoom,
};

View File

@@ -0,0 +1,111 @@
const jwt = require("jsonwebtoken");
const { User } = require("../models");
const logger = require("../utils/logger");
const cookie = require("cookie");
/**
* Socket.io authentication middleware
* Verifies JWT token and attaches user to socket
* Tokens can be provided via:
* 1. Cookie (accessToken) - preferred for browser clients
* 2. Query parameter (token) - fallback for mobile/other clients
*/
const authenticateSocket = async (socket, next) => {
try {
let token = null;
// Try to get token from cookies first (browser clients)
if (socket.handshake.headers.cookie) {
const cookies = cookie.parse(socket.handshake.headers.cookie);
token = cookies.accessToken;
}
// Fallback to query parameter (mobile/other clients)
if (!token && socket.handshake.auth?.token) {
token = socket.handshake.auth.token;
}
// Fallback to legacy query parameter
if (!token && socket.handshake.query?.token) {
token = socket.handshake.query.token;
}
if (!token) {
logger.warn("Socket connection rejected - no token provided", {
socketId: socket.id,
address: socket.handshake.address,
});
return next(new Error("Authentication required"));
}
// Verify JWT
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const userId = decoded.id;
if (!userId) {
logger.warn("Socket connection rejected - invalid token format", {
socketId: socket.id,
});
return next(new Error("Invalid token format"));
}
// Look up user
const user = await User.findByPk(userId);
if (!user) {
logger.warn("Socket connection rejected - user not found", {
socketId: socket.id,
userId,
});
return next(new Error("User not found"));
}
// Validate JWT version (invalidate old tokens after password change)
if (decoded.jwtVersion !== user.jwtVersion) {
logger.warn("Socket connection rejected - JWT version mismatch", {
socketId: socket.id,
userId,
tokenVersion: decoded.jwtVersion,
userVersion: user.jwtVersion,
});
return next(
new Error("Session expired due to password change. Please log in again.")
);
}
// Attach user to socket for use in event handlers
socket.userId = user.id;
socket.user = {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
};
logger.info("Socket authenticated successfully", {
socketId: socket.id,
userId: user.id,
email: user.email,
});
next();
} catch (error) {
// Check if token is expired
if (error.name === "TokenExpiredError") {
logger.warn("Socket connection rejected - token expired", {
socketId: socket.id,
});
return next(new Error("Token expired"));
}
logger.error("Socket authentication error", {
socketId: socket.id,
error: error.message,
stack: error.stack,
});
return next(new Error("Authentication failed"));
}
};
module.exports = { authenticateSocket };

View File

@@ -0,0 +1,156 @@
const { Server } = require('socket.io');
const Client = require('socket.io-client');
const http = require('http');
const { initializeMessageSocket, emitNewMessage, emitMessageRead } = require('../../../sockets/messageSocket');
describe('Message Socket', () => {
let io, serverSocket, clientSocket;
let httpServer;
beforeAll((done) => {
// Create HTTP server
httpServer = http.createServer();
// Create Socket.io server
io = new Server(httpServer);
httpServer.listen(() => {
const port = httpServer.address().port;
// Initialize message socket handlers
initializeMessageSocket(io);
// Create client socket
clientSocket = new Client(`http://localhost:${port}`);
// Mock authentication by setting userId
io.use((socket, next) => {
socket.userId = 'test-user-123';
socket.user = {
id: 'test-user-123',
email: 'test@example.com',
firstName: 'Test',
lastName: 'User'
};
next();
});
// Wait for connection
io.on('connection', (socket) => {
serverSocket = socket;
});
clientSocket.on('connect', done);
});
});
afterAll(() => {
io.close();
clientSocket.close();
httpServer.close();
});
test('should connect successfully', () => {
expect(clientSocket.connected).toBe(true);
});
test('should join conversation room', (done) => {
const otherUserId = 'other-user-456';
clientSocket.on('conversation_joined', (data) => {
expect(data.otherUserId).toBe(otherUserId);
expect(data.conversationRoom).toContain('conv_');
done();
});
clientSocket.emit('join_conversation', { otherUserId });
});
test('should emit typing start event', (done) => {
const receiverId = 'receiver-789';
serverSocket.on('typing_start', (data) => {
expect(data.receiverId).toBe(receiverId);
done();
});
clientSocket.emit('typing_start', { receiverId });
});
test('should emit typing stop event', (done) => {
const receiverId = 'receiver-789';
serverSocket.on('typing_stop', (data) => {
expect(data.receiverId).toBe(receiverId);
done();
});
clientSocket.emit('typing_stop', { receiverId });
});
test('should emit new message to receiver', (done) => {
const receiverId = 'receiver-123';
const messageData = {
id: 'message-456',
senderId: 'sender-789',
receiverId: receiverId,
subject: 'Test Subject',
content: 'Test message content',
createdAt: new Date().toISOString()
};
// Create a second client to receive the message
const port = httpServer.address().port;
const receiverClient = new Client(`http://localhost:${port}`);
receiverClient.on('connect', () => {
receiverClient.on('new_message', (message) => {
expect(message.id).toBe(messageData.id);
expect(message.content).toBe(messageData.content);
receiverClient.close();
done();
});
// Emit the message
emitNewMessage(io, receiverId, messageData);
});
});
test('should emit message read status to sender', (done) => {
const senderId = 'sender-123';
const readData = {
messageId: 'message-789',
readAt: new Date().toISOString(),
readBy: 'reader-456'
};
// Create a sender client to receive the read receipt
const port = httpServer.address().port;
const senderClient = new Client(`http://localhost:${port}`);
senderClient.on('connect', () => {
senderClient.on('message_read', (data) => {
expect(data.messageId).toBe(readData.messageId);
expect(data.readBy).toBe(readData.readBy);
senderClient.close();
done();
});
// Emit the read status
emitMessageRead(io, senderId, readData);
});
});
test('should handle disconnection gracefully', (done) => {
const testClient = new Client(`http://localhost:${httpServer.address().port}`);
testClient.on('connect', () => {
testClient.on('disconnect', (reason) => {
expect(reason).toBeTruthy();
done();
});
testClient.disconnect();
});
});
});

View File

@@ -26,6 +26,7 @@
"react-dom": "^19.1.0",
"react-router-dom": "^6.30.1",
"react-scripts": "5.0.1",
"socket.io-client": "^4.8.1",
"stripe": "^18.4.0",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
@@ -3318,6 +3319,12 @@
"@sinonjs/commons": "^1.7.0"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@stripe/react-stripe-js": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.9.2.tgz",
@@ -7183,6 +7190,66 @@
"node": ">= 0.8"
}
},
"node_modules/engine.io-client": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-client/node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@@ -15583,6 +15650,68 @@
"node": ">=8"
}
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/sockjs": {
"version": "0.3.24",
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
@@ -18103,6 +18232,14 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@@ -21,6 +21,7 @@
"react-dom": "^19.1.0",
"react-router-dom": "^6.30.1",
"react-scripts": "5.0.1",
"socket.io-client": "^4.8.1",
"stripe": "^18.4.0",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { SocketProvider } from './contexts/SocketContext';
import Navbar from './components/Navbar';
import Footer from './components/Footer';
import AuthModal from './components/AuthModal';
@@ -202,10 +203,20 @@ const AppContent: React.FC = () => {
);
};
const AppWithSocket: React.FC = () => {
const { user } = useAuth();
return (
<SocketProvider isAuthenticated={!!user}>
<AppContent />
</SocketProvider>
);
};
function App() {
return (
<AuthProvider>
<AppContent />
<AppWithSocket />
</AuthProvider>
);
}

View File

@@ -1,7 +1,9 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { messageAPI } from '../services/api';
import { User, Message } from '../types';
import { useAuth } from '../contexts/AuthContext';
import { useSocket } from '../contexts/SocketContext';
import TypingIndicator from './TypingIndicator';
interface ChatWindowProps {
show: boolean;
@@ -11,21 +13,108 @@ interface ChatWindowProps {
const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) => {
const { user: currentUser } = useAuth();
const { isConnected, joinConversation, leaveConversation, onNewMessage, onUserTyping, emitTypingStart, emitTypingStop } = useSocket();
const [messages, setMessages] = useState<Message[]>([]);
const [newMessage, setNewMessage] = useState('');
const [sending, setSending] = useState(false);
const [loading, setLoading] = useState(true);
const [isRecipientTyping, setIsRecipientTyping] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (show) {
fetchMessages();
// Join conversation room when chat opens
if (isConnected) {
joinConversation(recipient.id);
}
}
}, [show, recipient.id]);
return () => {
// Leave conversation room when chat closes
if (isConnected) {
leaveConversation(recipient.id);
}
};
}, [show, recipient.id, isConnected]);
// Create a stable callback for handling new messages
const handleNewMessage = useCallback((message: Message) => {
console.log('[ChatWindow] Received new_message event:', message);
// Only add messages that are part of this conversation
if (
(message.senderId === recipient.id && message.receiverId === currentUser?.id) ||
(message.senderId === currentUser?.id && message.receiverId === recipient.id)
) {
console.log('[ChatWindow] Message is for this conversation, adding to chat');
setMessages((prevMessages) => {
// Check if message already exists (avoid duplicates)
if (prevMessages.some(m => m.id === message.id)) {
console.log('[ChatWindow] Message already exists, skipping');
return prevMessages;
}
console.log('[ChatWindow] Adding new message to chat');
return [...prevMessages, message];
});
} else {
console.log('[ChatWindow] Message not for this conversation, ignoring');
}
}, [recipient.id, currentUser?.id]);
// Listen for new messages in real-time
useEffect(() => {
console.log('[ChatWindow] Message listener useEffect running', { isConnected, show, recipientId: recipient.id });
if (!isConnected || !show) {
console.log('[ChatWindow] Skipping listener setup:', { isConnected, show });
return;
}
console.log('[ChatWindow] Setting up message listener for recipient:', recipient.id);
const cleanup = onNewMessage(handleNewMessage);
return () => {
console.log('[ChatWindow] Cleaning up message listener for recipient:', recipient.id);
cleanup();
};
}, [isConnected, show, onNewMessage, handleNewMessage]);
// Listen for typing indicators
useEffect(() => {
if (!isConnected || !show) return;
const cleanup = onUserTyping((data) => {
// Only show typing indicator for the current recipient
if (data.userId === recipient.id) {
setIsRecipientTyping(data.isTyping);
// Auto-hide typing indicator after 3 seconds of no activity
if (data.isTyping) {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(() => {
setIsRecipientTyping(false);
}, 3000);
}
}
});
return () => {
cleanup();
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
}, [isConnected, show, recipient.id, onUserTyping]);
useEffect(() => {
scrollToBottom();
}, [messages]);
}, [messages, isRecipientTyping]);
const fetchMessages = async () => {
try {
@@ -55,10 +144,38 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
// Handle typing indicators with debouncing
const handleTyping = useCallback(() => {
if (!isConnected) return;
// Emit typing start
emitTypingStart(recipient.id);
// Clear existing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// Set timeout to emit typing stop after 2 seconds of inactivity
typingTimeoutRef.current = setTimeout(() => {
emitTypingStop(recipient.id);
}, 2000);
}, [isConnected, recipient.id, emitTypingStart, emitTypingStop]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewMessage(e.target.value);
handleTyping();
};
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim()) return;
// Stop typing indicator
if (isConnected) {
emitTypingStop(recipient.id);
}
setSending(true);
const messageContent = newMessage;
setNewMessage(''); // Clear input immediately for better UX
@@ -70,8 +187,15 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
content: messageContent
});
// Add the new message to the list
setMessages([...messages, response.data]);
// Add message to sender's chat immediately for instant feedback
// Socket will handle updating the receiver's chat
setMessages((prevMessages) => {
// Avoid duplicates
if (prevMessages.some(m => m.id === response.data.id)) {
return prevMessages;
}
return [...prevMessages, response.data];
});
} catch (error) {
console.error('Failed to send message:', error);
setNewMessage(messageContent); // Restore message on error
@@ -145,7 +269,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
)}
<div>
<h6 className="mb-0">{recipient.firstName} {recipient.lastName}</h6>
<small className="opacity-75">@{recipient.username}</small>
</div>
</div>
<button
@@ -216,6 +339,13 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
</div>
);
})}
{/* Typing indicator */}
{isRecipientTyping && (
<TypingIndicator
firstName={recipient.firstName}
isVisible={isRecipientTyping}
/>
)}
<div ref={messagesEndRef} />
</>
)}
@@ -229,7 +359,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({ show, onClose, recipient }) =>
className="form-control"
placeholder="Type a message..."
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
onChange={handleInputChange}
disabled={sending}
/>
<button

View File

@@ -1,16 +1,19 @@
import React, { useState, useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { rentalAPI } from "../services/api";
import { useSocket } from "../contexts/SocketContext";
import { rentalAPI, messageAPI } from "../services/api";
const Navbar: React.FC = () => {
const { user, logout, openAuthModal } = useAuth();
const { onNewMessage, onMessageRead } = useSocket();
const navigate = useNavigate();
const [searchFilters, setSearchFilters] = useState({
search: "",
location: "",
});
const [pendingRequestsCount, setPendingRequestsCount] = useState(0);
const [unreadMessagesCount, setUnreadMessagesCount] = useState(0);
// Fetch pending rental requests count when user logs in
useEffect(() => {
@@ -41,6 +44,46 @@ const Navbar: React.FC = () => {
};
}, [user]);
// Fetch unread messages count when user logs in
useEffect(() => {
const fetchUnreadCount = async () => {
if (user) {
try {
const response = await messageAPI.getUnreadCount();
setUnreadMessagesCount(response.data.count);
} catch (error) {
console.error("Failed to fetch unread message count:", error);
}
} else {
setUnreadMessagesCount(0);
}
};
fetchUnreadCount();
}, [user]);
// Listen for real-time message updates via socket
useEffect(() => {
if (!user) return;
// Listen for new messages
const cleanupNewMessage = onNewMessage((message: any) => {
if (message.receiverId === user.id) {
setUnreadMessagesCount((prev) => prev + 1);
}
});
// Listen for messages being read
const cleanupMessageRead = onMessageRead(() => {
setUnreadMessagesCount((prev) => Math.max(0, prev - 1));
});
return () => {
cleanupNewMessage();
cleanupMessageRead();
};
}, [user, onNewMessage, onMessageRead]);
const handleLogout = () => {
logout();
navigate("/");
@@ -155,7 +198,7 @@ const Navbar: React.FC = () => {
aria-expanded="false"
>
<span style={{ display: "flex", alignItems: "center", position: "relative" }}>
{pendingRequestsCount > 0 && (
{(pendingRequestsCount > 0 || unreadMessagesCount > 0) && (
<span
style={{
position: "absolute",
@@ -177,7 +220,7 @@ const Navbar: React.FC = () => {
zIndex: 1,
}}
>
{pendingRequestsCount}
{pendingRequestsCount + unreadMessagesCount}
</span>
)}
<i className="bi bi-person-circle me-1"></i>
@@ -224,6 +267,11 @@ const Navbar: React.FC = () => {
<li>
<Link className="dropdown-item" to="/messages">
<i className="bi bi-envelope me-2"></i>Messages
{unreadMessagesCount > 0 && (
<span className="badge bg-danger rounded-pill ms-2">
{unreadMessagesCount}
</span>
)}
</Link>
</li>
<li>

View File

@@ -0,0 +1,48 @@
.typing-indicator {
display: flex;
align-items: center;
padding: 8px 12px;
font-size: 0.875rem;
color: #6c757d;
font-style: italic;
gap: 6px;
}
.typing-text {
margin-right: 2px;
}
.typing-dots {
display: flex;
align-items: center;
gap: 3px;
height: 16px;
}
.typing-dots .dot {
width: 6px;
height: 6px;
background-color: #6c757d;
border-radius: 50%;
animation: typing-bounce 1.4s infinite ease-in-out;
animation-fill-mode: both;
}
.typing-dots .dot:nth-child(1) {
animation-delay: -0.32s;
}
.typing-dots .dot:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes typing-bounce {
0%, 80%, 100% {
transform: translateY(0);
opacity: 0.7;
}
40% {
transform: translateY(-6px);
opacity: 1;
}
}

View File

@@ -0,0 +1,30 @@
import React from 'react';
import './TypingIndicator.css';
interface TypingIndicatorProps {
firstName: string;
isVisible: boolean;
}
/**
* Typing Indicator Component
* Shows an animated "User is typing..." message
*/
const TypingIndicator: React.FC<TypingIndicatorProps> = ({ firstName, isVisible }) => {
if (!isVisible) {
return null;
}
return (
<div className="typing-indicator">
<span className="typing-text">{firstName} is typing</span>
<div className="typing-dots">
<span className="dot"></span>
<span className="dot"></span>
<span className="dot"></span>
</div>
</div>
);
};
export default TypingIndicator;

View File

@@ -0,0 +1,176 @@
import React, { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react';
import { Socket } from 'socket.io-client';
import socketService from '../services/socket';
/**
* Socket Context Type
*/
interface SocketContextType {
socket: Socket | null;
isConnected: boolean;
joinConversation: (otherUserId: string) => void;
leaveConversation: (otherUserId: string) => void;
emitTypingStart: (receiverId: string) => void;
emitTypingStop: (receiverId: string) => void;
emitMarkMessageRead: (messageId: string, senderId: string) => void;
onNewMessage: (callback: (message: any) => void) => () => void;
onMessageRead: (callback: (data: { messageId: string; readAt: string; readBy: string }) => void) => () => void;
onUserTyping: (callback: (data: { userId: string; firstName: string; isTyping: boolean }) => void) => () => void;
}
/**
* Create Socket Context
*/
const SocketContext = createContext<SocketContextType | undefined>(undefined);
/**
* Socket Provider Props
*/
interface SocketProviderProps {
children: ReactNode;
isAuthenticated?: boolean;
}
/**
* Socket Provider Component
* Manages socket connection lifecycle and provides socket functionality to children
*/
export const SocketProvider: React.FC<SocketProviderProps> = ({
children,
isAuthenticated = false
}) => {
const [socket, setSocket] = useState<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
/**
* Initialize socket connection when user is authenticated
*/
useEffect(() => {
console.log('[SocketProvider] useEffect running', { isAuthenticated });
if (!isAuthenticated) {
console.log('[SocketProvider] Not authenticated, skipping socket setup');
return;
}
console.log('[SocketProvider] Initializing socket connection');
const newSocket = socketService.connect();
setSocket(newSocket);
// Listen for connection status changes
console.log('[SocketProvider] Setting up connection listener');
const removeListener = socketService.addConnectionListener((connected) => {
console.log('[SocketProvider] Connection status changed:', connected);
setIsConnected(connected);
});
// Cleanup on unmount
return () => {
console.log('[SocketProvider] Cleaning up connection listener');
removeListener();
};
}, [isAuthenticated]);
/**
* Disconnect socket when user logs out
*/
useEffect(() => {
if (!isAuthenticated && socket) {
console.log('[SocketProvider] User logged out, disconnecting socket');
socketService.disconnect();
setSocket(null);
setIsConnected(false);
}
}, [isAuthenticated, socket]);
/**
* Join a conversation room
*/
const joinConversation = useCallback((otherUserId: string) => {
socketService.joinConversation(otherUserId);
}, []);
/**
* Leave a conversation room
*/
const leaveConversation = useCallback((otherUserId: string) => {
socketService.leaveConversation(otherUserId);
}, []);
/**
* Emit typing start event
*/
const emitTypingStart = useCallback((receiverId: string) => {
socketService.emitTypingStart(receiverId);
}, []);
/**
* Emit typing stop event
*/
const emitTypingStop = useCallback((receiverId: string) => {
socketService.emitTypingStop(receiverId);
}, []);
/**
* Emit mark message as read event
*/
const emitMarkMessageRead = useCallback((messageId: string, senderId: string) => {
socketService.emitMarkMessageRead(messageId, senderId);
}, []);
/**
* Listen for new messages
*/
const onNewMessage = useCallback((callback: (message: any) => void) => {
return socketService.onNewMessage(callback);
}, []);
/**
* Listen for message read events
*/
const onMessageRead = useCallback((callback: (data: { messageId: string; readAt: string; readBy: string }) => void) => {
return socketService.onMessageRead(callback);
}, []);
/**
* Listen for typing indicators
*/
const onUserTyping = useCallback((callback: (data: { userId: string; firstName: string; isTyping: boolean }) => void) => {
return socketService.onUserTyping(callback);
}, []);
const value: SocketContextType = {
socket,
isConnected,
joinConversation,
leaveConversation,
emitTypingStart,
emitTypingStop,
emitMarkMessageRead,
onNewMessage,
onMessageRead,
onUserTyping,
};
return (
<SocketContext.Provider value={value}>
{children}
</SocketContext.Provider>
);
};
/**
* Custom hook to use Socket Context
* @throws Error if used outside of SocketProvider
*/
export const useSocket = (): SocketContextType => {
const context = useContext(SocketContext);
if (context === undefined) {
throw new Error('useSocket must be used within a SocketProvider');
}
return context;
};
export default SocketContext;

View File

@@ -3,11 +3,13 @@ import { useParams, useNavigate } from 'react-router-dom';
import { Message } from '../types';
import { messageAPI } from '../services/api';
import { useAuth } from '../contexts/AuthContext';
import { useSocket } from '../contexts/SocketContext';
const MessageDetail: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const { isConnected, onNewMessage } = useSocket();
const [message, setMessage] = useState<Message | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -18,6 +20,34 @@ const MessageDetail: React.FC = () => {
fetchMessage();
}, [id]);
// Listen for new replies in real-time
useEffect(() => {
if (!isConnected || !message) return;
const cleanup = onNewMessage((newMessage: Message) => {
// Check if this is a reply to the current thread
if (newMessage.parentMessageId === message.id) {
setMessage((prevMessage) => {
if (!prevMessage) return prevMessage;
// Check if reply already exists (avoid duplicates)
const replies = prevMessage.replies || [];
if (replies.some(r => r.id === newMessage.id)) {
return prevMessage;
}
// Add new reply to the thread
return {
...prevMessage,
replies: [...replies, newMessage]
};
});
}
});
return cleanup;
}, [isConnected, message?.id, onNewMessage]);
const fetchMessage = async () => {
try {
const response = await messageAPI.getMessage(id!);
@@ -38,7 +68,7 @@ const MessageDetail: React.FC = () => {
try {
const recipientId = message.senderId === user?.id ? message.receiverId : message.senderId;
await messageAPI.sendMessage({
const response = await messageAPI.sendMessage({
receiverId: recipientId,
subject: `Re: ${message.subject}`,
content: replyContent,
@@ -46,7 +76,20 @@ const MessageDetail: React.FC = () => {
});
setReplyContent('');
fetchMessage(); // Refresh to show the new reply
// Note: Socket will automatically add the reply to the thread
// But we add it manually for immediate feedback if socket is disconnected
if (!isConnected) {
setMessage((prevMessage) => {
if (!prevMessage) return prevMessage;
const replies = prevMessage.replies || [];
return {
...prevMessage,
replies: [...replies, response.data]
};
});
}
alert('Reply sent successfully!');
} catch (err: any) {
setError(err.response?.data?.error || 'Failed to send reply');

View File

@@ -3,11 +3,13 @@ import { useNavigate } from 'react-router-dom';
import { Message, User } from '../types';
import { messageAPI } from '../services/api';
import { useAuth } from '../contexts/AuthContext';
import { useSocket } from '../contexts/SocketContext';
import ChatWindow from '../components/ChatWindow';
const Messages: React.FC = () => {
const navigate = useNavigate();
const { user } = useAuth();
const { isConnected, onNewMessage } = useSocket();
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -18,6 +20,27 @@ const Messages: React.FC = () => {
fetchMessages();
}, []);
// Listen for new messages in real-time
useEffect(() => {
if (!isConnected) return;
const cleanup = onNewMessage((newMessage: Message) => {
// Only add if this is a received message (user is the receiver)
if (newMessage.receiverId === user?.id) {
setMessages((prevMessages) => {
// Check if message already exists (avoid duplicates)
if (prevMessages.some(m => m.id === newMessage.id)) {
return prevMessages;
}
// Add new message to the top of the inbox
return [newMessage, ...prevMessages];
});
}
});
return cleanup;
}, [isConnected, user?.id, onNewMessage]);
const fetchMessages = async () => {
try {
const response = await messageAPI.getMessages();

View File

@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import { User, Item } from '../types';
import { userAPI, itemAPI } from '../services/api';
import { useAuth } from '../contexts/AuthContext';
import ChatWindow from '../components/ChatWindow';
const PublicProfile: React.FC = () => {
const { id } = useParams<{ id: string }>();
@@ -12,6 +13,7 @@ const PublicProfile: React.FC = () => {
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showChat, setShowChat] = useState(false);
useEffect(() => {
fetchUserProfile();
@@ -85,11 +87,10 @@ const PublicProfile: React.FC = () => {
</div>
)}
<h3>{user.firstName} {user.lastName}</h3>
<p className="text-muted">@{user.username}</p>
{currentUser && currentUser.id !== user.id && (
<button
className="btn btn-primary mt-3"
onClick={() => navigate('/messages')}
onClick={() => setShowChat(true)}
>
<i className="bi bi-chat-dots-fill me-2"></i>Message
</button>
@@ -148,6 +149,15 @@ const PublicProfile: React.FC = () => {
</div>
</div>
</div>
{/* ChatWindow popup */}
{user && (
<ChatWindow
show={showChat}
onClose={() => setShowChat(false)}
recipient={user}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,314 @@
import { io, Socket } from "socket.io-client";
/**
* Socket event types for type safety
*/
export interface SocketEvents {
// Incoming events (from server)
new_message: (message: any) => void;
message_read: (data: {
messageId: string;
readAt: string;
readBy: string;
}) => void;
user_typing: (data: {
userId: string;
firstName: string;
isTyping: boolean;
}) => void;
conversation_joined: (data: {
conversationRoom: string;
otherUserId: string;
}) => void;
// Connection events
connect: () => void;
disconnect: (reason: string) => void;
connect_error: (error: Error) => void;
error: (error: Error) => void;
}
/**
* Socket service for managing WebSocket connection
* Implements singleton pattern to ensure only one socket instance
*/
class SocketService {
private socket: Socket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private connectionListeners: Array<(connected: boolean) => void> = [];
/**
* Get the socket instance URL based on environment
*/
private getSocketUrl(): string {
// Use environment variable or default to localhost:5001 (matches backend)
return process.env.REACT_APP_BASE_URL || "http://localhost:5001";
}
/**
* Initialize and connect to the socket server
* Authentication happens via cookies (sent automatically)
*/
connect(): Socket {
if (this.socket?.connected) {
console.log("[Socket] Already connected");
return this.socket;
}
console.log("[Socket] Connecting to server...");
this.socket = io(this.getSocketUrl(), {
withCredentials: true, // Send cookies for authentication
transports: ["websocket", "polling"], // Try WebSocket first, fallback to polling
path: "/socket.io", // Explicit Socket.io path
reconnection: true,
reconnectionAttempts: this.maxReconnectAttempts,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
timeout: 20000,
});
// Connection event handlers
this.socket.on("connect", () => {
console.log("[Socket] Connected successfully", {
socketId: this.socket?.id,
});
this.reconnectAttempts = 0;
this.notifyConnectionListeners(true);
});
this.socket.on("disconnect", (reason) => {
console.log("[Socket] Disconnected", { reason });
this.notifyConnectionListeners(false);
});
this.socket.on("connect_error", (error) => {
console.error("[Socket] Connection error", {
error: error.message,
attempt: this.reconnectAttempts + 1,
});
this.reconnectAttempts++;
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error("[Socket] Max reconnection attempts reached");
this.disconnect();
}
});
this.socket.on("error", (error) => {
console.error("[Socket] Socket error", error);
});
return this.socket;
}
/**
* Disconnect from the socket server
*/
disconnect(): void {
if (this.socket) {
console.log("[Socket] Disconnecting...");
this.socket.disconnect();
this.socket = null;
this.notifyConnectionListeners(false);
}
}
/**
* Get the current socket instance
*/
getSocket(): Socket | null {
return this.socket;
}
/**
* Check if socket is connected
*/
isConnected(): boolean {
return this.socket?.connected ?? false;
}
/**
* Join a conversation room
*/
joinConversation(otherUserId: string): void {
if (!this.socket?.connected) {
console.warn("[Socket] Not connected, cannot join conversation");
return;
}
console.log("[Socket] Joining conversation", { otherUserId });
this.socket.emit("join_conversation", { otherUserId });
}
/**
* Leave a conversation room
*/
leaveConversation(otherUserId: string): void {
if (!this.socket?.connected) {
return;
}
console.log("[Socket] Leaving conversation", { otherUserId });
this.socket.emit("leave_conversation", { otherUserId });
}
/**
* Emit typing start event
*/
emitTypingStart(receiverId: string): void {
if (!this.socket?.connected) {
return;
}
this.socket.emit("typing_start", { receiverId });
}
/**
* Emit typing stop event
*/
emitTypingStop(receiverId: string): void {
if (!this.socket?.connected) {
return;
}
this.socket.emit("typing_stop", { receiverId });
}
/**
* Emit mark message as read event
*/
emitMarkMessageRead(messageId: string, senderId: string): void {
if (!this.socket?.connected) {
return;
}
this.socket.emit("mark_message_read", { messageId, senderId });
}
/**
* Listen for new messages
*/
onNewMessage(callback: (message: any) => void): () => void {
if (!this.socket) {
console.warn("[Socket] Socket not initialized");
return () => {};
}
this.socket.on("new_message", callback);
// Return cleanup function
return () => {
this.socket?.off("new_message", callback);
};
}
/**
* Listen for message read events
*/
onMessageRead(
callback: (data: {
messageId: string;
readAt: string;
readBy: string;
}) => void
): () => void {
if (!this.socket) {
console.warn("[Socket] Socket not initialized");
return () => {};
}
this.socket.on("message_read", callback);
// Return cleanup function
return () => {
this.socket?.off("message_read", callback);
};
}
/**
* Listen for typing indicators
*/
onUserTyping(
callback: (data: {
userId: string;
firstName: string;
isTyping: boolean;
}) => void
): () => void {
if (!this.socket) {
console.warn("[Socket] Socket not initialized");
return () => {};
}
this.socket.on("user_typing", callback);
// Return cleanup function
return () => {
this.socket?.off("user_typing", callback);
};
}
/**
* Listen for conversation joined event
*/
onConversationJoined(
callback: (data: { conversationRoom: string; otherUserId: string }) => void
): () => void {
if (!this.socket) {
console.warn("[Socket] Socket not initialized");
return () => {};
}
this.socket.on("conversation_joined", callback);
// Return cleanup function
return () => {
this.socket?.off("conversation_joined", callback);
};
}
/**
* Add connection status listener
*/
addConnectionListener(callback: (connected: boolean) => void): () => void {
this.connectionListeners.push(callback);
// Immediately notify of current status
callback(this.isConnected());
// Return cleanup function
return () => {
this.connectionListeners = this.connectionListeners.filter(
(cb) => cb !== callback
);
};
}
/**
* Notify all connection listeners of status change
*/
private notifyConnectionListeners(connected: boolean): void {
this.connectionListeners.forEach((callback) => {
try {
callback(connected);
} catch (error) {
console.error("[Socket] Error in connection listener", error);
}
});
}
/**
* Remove all event listeners
*/
removeAllListeners(): void {
if (this.socket) {
this.socket.removeAllListeners();
}
}
}
// Export singleton instance
export const socketService = new SocketService();
export default socketService;