From 7a5bff8f2bd2bdce43dc88f6652caf47b5125d23 Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Sat, 8 Nov 2025 18:20:02 -0500 Subject: [PATCH] real time messaging --- backend/package-lock.json | 389 ++++++++++++++++++ backend/package.json | 2 + backend/routes/messages.js | 32 +- backend/server.js | 28 +- backend/sockets/messageSocket.js | 343 +++++++++++++++ backend/sockets/socketAuth.js | 111 +++++ .../tests/unit/sockets/messageSocket.test.js | 156 +++++++ frontend/package-lock.json | 137 ++++++ frontend/package.json | 1 + frontend/src/App.tsx | 13 +- frontend/src/components/ChatWindow.tsx | 144 ++++++- frontend/src/components/Navbar.tsx | 54 ++- frontend/src/components/TypingIndicator.css | 48 +++ frontend/src/components/TypingIndicator.tsx | 30 ++ frontend/src/contexts/SocketContext.tsx | 176 ++++++++ frontend/src/pages/MessageDetail.tsx | 47 ++- frontend/src/pages/Messages.tsx | 23 ++ frontend/src/pages/PublicProfile.tsx | 18 +- frontend/src/services/socket.ts | 314 ++++++++++++++ 19 files changed, 2046 insertions(+), 20 deletions(-) create mode 100644 backend/sockets/messageSocket.js create mode 100644 backend/sockets/socketAuth.js create mode 100644 backend/tests/unit/sockets/messageSocket.test.js create mode 100644 frontend/src/components/TypingIndicator.css create mode 100644 frontend/src/components/TypingIndicator.tsx create mode 100644 frontend/src/contexts/SocketContext.tsx create mode 100644 frontend/src/services/socket.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index f0a1a12..a40c08e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 4ccbe9d..02e5365 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } } diff --git a/backend/routes/messages.js b/backend/routes/messages.js index 507b675..b0d04a2 100644 --- a/backend/routes/messages.js +++ b/backend/routes/messages.js @@ -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, diff --git a/backend/server.js b/backend/server.js index 4ae1706..ee5dfd3 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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) => { diff --git a/backend/sockets/messageSocket.js b/backend/sockets/messageSocket.js new file mode 100644 index 0000000..2dc271e --- /dev/null +++ b/backend/sockets/messageSocket.js @@ -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, +}; diff --git a/backend/sockets/socketAuth.js b/backend/sockets/socketAuth.js new file mode 100644 index 0000000..b2fce50 --- /dev/null +++ b/backend/sockets/socketAuth.js @@ -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 }; diff --git a/backend/tests/unit/sockets/messageSocket.test.js b/backend/tests/unit/sockets/messageSocket.test.js new file mode 100644 index 0000000..aa8b697 --- /dev/null +++ b/backend/tests/unit/sockets/messageSocket.test.js @@ -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(); + }); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fb70f50..5d6014d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index e6ed08f..a66ee0e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8a4caf2..7cf77d2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( + + + + ); +}; + function App() { return ( - + ); } diff --git a/frontend/src/components/ChatWindow.tsx b/frontend/src/components/ChatWindow.tsx index b573a76..47f382b 100644 --- a/frontend/src/components/ChatWindow.tsx +++ b/frontend/src/components/ChatWindow.tsx @@ -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 = ({ show, onClose, recipient }) => { const { user: currentUser } = useAuth(); + const { isConnected, joinConversation, leaveConversation, onNewMessage, onUserTyping, emitTypingStart, emitTypingStop } = useSocket(); const [messages, setMessages] = useState([]); const [newMessage, setNewMessage] = useState(''); const [sending, setSending] = useState(false); const [loading, setLoading] = useState(true); + const [isRecipientTyping, setIsRecipientTyping] = useState(false); const messagesEndRef = useRef(null); + const typingTimeoutRef = useRef(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 = ({ 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) => { + 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 = ({ 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 = ({ show, onClose, recipient }) => )} {recipient.firstName} {recipient.lastName} - @{recipient.username} = ({ show, onClose, recipient }) => ); })} + {/* Typing indicator */} + {isRecipientTyping && ( + + )} > )} @@ -229,7 +359,7 @@ const ChatWindow: React.FC = ({ show, onClose, recipient }) => className="form-control" placeholder="Type a message..." value={newMessage} - onChange={(e) => setNewMessage(e.target.value)} + onChange={handleInputChange} disabled={sending} /> { 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" > - {pendingRequestsCount > 0 && ( + {(pendingRequestsCount > 0 || unreadMessagesCount > 0) && ( { zIndex: 1, }} > - {pendingRequestsCount} + {pendingRequestsCount + unreadMessagesCount} )} @@ -224,6 +267,11 @@ const Navbar: React.FC = () => { Messages + {unreadMessagesCount > 0 && ( + + {unreadMessagesCount} + + )} diff --git a/frontend/src/components/TypingIndicator.css b/frontend/src/components/TypingIndicator.css new file mode 100644 index 0000000..7dd3f6e --- /dev/null +++ b/frontend/src/components/TypingIndicator.css @@ -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; + } +} diff --git a/frontend/src/components/TypingIndicator.tsx b/frontend/src/components/TypingIndicator.tsx new file mode 100644 index 0000000..a3e03f2 --- /dev/null +++ b/frontend/src/components/TypingIndicator.tsx @@ -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 = ({ firstName, isVisible }) => { + if (!isVisible) { + return null; + } + + return ( + + {firstName} is typing + + + + + + + ); +}; + +export default TypingIndicator; diff --git a/frontend/src/contexts/SocketContext.tsx b/frontend/src/contexts/SocketContext.tsx new file mode 100644 index 0000000..40e564b --- /dev/null +++ b/frontend/src/contexts/SocketContext.tsx @@ -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(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 = ({ + children, + isAuthenticated = false +}) => { + const [socket, setSocket] = useState(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 ( + + {children} + + ); +}; + +/** + * 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; diff --git a/frontend/src/pages/MessageDetail.tsx b/frontend/src/pages/MessageDetail.tsx index f7636f2..2472b63 100644 --- a/frontend/src/pages/MessageDetail.tsx +++ b/frontend/src/pages/MessageDetail.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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'); diff --git a/frontend/src/pages/Messages.tsx b/frontend/src/pages/Messages.tsx index dadc868..346d870 100644 --- a/frontend/src/pages/Messages.tsx +++ b/frontend/src/pages/Messages.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(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(); diff --git a/frontend/src/pages/PublicProfile.tsx b/frontend/src/pages/PublicProfile.tsx index 4ca3502..5ca333d 100644 --- a/frontend/src/pages/PublicProfile.tsx +++ b/frontend/src/pages/PublicProfile.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [showChat, setShowChat] = useState(false); useEffect(() => { fetchUserProfile(); @@ -85,11 +87,10 @@ const PublicProfile: React.FC = () => { )} {user.firstName} {user.lastName} - @{user.username} {currentUser && currentUser.id !== user.id && ( - navigate('/messages')} + setShowChat(true)} > Message @@ -148,6 +149,15 @@ const PublicProfile: React.FC = () => { + + {/* ChatWindow popup */} + {user && ( + setShowChat(false)} + recipient={user} + /> + )} ); }; diff --git a/frontend/src/services/socket.ts b/frontend/src/services/socket.ts new file mode 100644 index 0000000..514809c --- /dev/null +++ b/frontend/src/services/socket.ts @@ -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;
@{user.username}