From 87a7981f85c9daad5f75603a9bdfa7e6e77e651d Mon Sep 17 00:00:00 2001 From: adhyaansirohi-cyber Date: Thu, 11 Jun 2026 15:18:43 +0530 Subject: [PATCH] feat: add stranger connect matchmaking --- PiperChat01 | 1 + frontend/package-lock.json | 15 +- .../friends/header/TopnavDashboard.jsx | 22 + .../components/friends/main/MainDashboard.jsx | 5 + .../friends/main/StrangerConnect.jsx | 437 ++++++++++++++++++ server/src/socket/index.js | 260 +++++++++++ 6 files changed, 736 insertions(+), 4 deletions(-) create mode 160000 PiperChat01 create mode 100644 frontend/src/components/friends/main/StrangerConnect.jsx diff --git a/PiperChat01 b/PiperChat01 new file mode 160000 index 0000000..9f90159 --- /dev/null +++ b/PiperChat01 @@ -0,0 +1 @@ +Subproject commit 9f901599c61a28a66c45156b4968380e9bf9b42c diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 229d7e1..f161d01 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -70,6 +70,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2192,8 +2193,7 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", @@ -2290,6 +2290,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2611,6 +2612,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2778,8 +2780,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -3280,6 +3281,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5287,6 +5289,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5299,6 +5302,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -5327,6 +5331,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.0.5.tgz", "integrity": "sha512-Q2f6fCKxPFpkXt1qNRZdEDLlScsDWyrgSj0mliK59qU6W5gvBiKkdMEG2lJzhd1rCctf0hb6EtePPLZ2e0m1uw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.1", "@types/hoist-non-react-statics": "^3.3.1", @@ -5477,6 +5482,7 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.9.2" } @@ -6323,6 +6329,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/frontend/src/components/friends/header/TopnavDashboard.jsx b/frontend/src/components/friends/header/TopnavDashboard.jsx index 4eb67d1..130735e 100644 --- a/frontend/src/components/friends/header/TopnavDashboard.jsx +++ b/frontend/src/components/friends/header/TopnavDashboard.jsx @@ -11,6 +11,7 @@ import { Clock, Ban, CircleDot, + Globe2, } from "lucide-react"; import { useDispatch, useSelector } from "react-redux"; import { useTheme } from "../../../context/ThemeContext"; @@ -149,6 +150,27 @@ function TopnavDashboard({button_status, onToggleSidebar}) { Blocked + + + +
+ {statusMessage} +
+ + +
+
+
+
Current match
+
+ {queueState === "matched" + ? "You are connected. Messages are private in this room." + : "No match yet. Join the queue to start connecting."} +
+
+ + {queueState !== "matched" ? ( +
+ {queueState === "waiting" ? ( + + ) : ( + + )} +
+ ) : ( +
+ + + + +
+ )} +
+ + {queueState === "matched" ? ( +
+
+ {displayPartner} +
+
+
{displayPartner}
+
Anonymous stranger
+
+
+ ) : null} +
+ +
+
+
Chat room
+
{queueState === "waiting" ? "Waiting for match..." : "Type when connected."}
+
+ +
+ {messages.length === 0 ? ( +
+ {queueState === "matched" + ? "Start the conversation by sending your first message." + : "Join the queue and your stranger chat history will appear here."} +
+ ) : ( +
+ {messages.map((msg) => ( +
+ {msg.type === "system" ? ( +
+ {msg.text} +
+ ) : ( +
+ {msg.type === "peer" ? ( +
+ {msg.sender} +
+ ) : null} +
{msg.text}
+ {msg.timestamp ? ( +
+ {new Date(msg.timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} +
+ ) : null} +
+ )} +
+ ))} +
+ )} +
+ +
+ setInput(e.target.value)} + placeholder={ + queueState === "matched" + ? "Send a private message to your stranger..." + : "Join the queue to start chatting." + } + disabled={queueState !== "matched"} + className="flex-1" + /> + +
+ + {systemNotice ? ( +
+ {systemNotice} +
+ ) : null} + {reportSent ? ( +
+ Thank you. The report has been sent. +
+ ) : null} +
+ + + +
+
+
How it works
+
+

• Join the queue and wait for a random online user.

+

• Your username stays hidden if anonymous mode is on.

+

• Use Next Person to rematch instantly.

+

• Report or block any user for safety.

+
+
+ +
+
Safety guidelines
+
+

• Keep personal details private until you trust the person.

+

• Use report or block if someone behaves badly.

+

• You can leave the chat at any time.

+
+
+
+ + ); +} + +export default StrangerConnect; diff --git a/server/src/socket/index.js b/server/src/socket/index.js index 8561f10..f08c453 100644 --- a/server/src/socket/index.js +++ b/server/src/socket/index.js @@ -2,6 +2,8 @@ import User from "../models/User.js"; import { buildServerTypingEvent } from "../lib/typingEvents.js"; const onlineUsers = new Map(); +const strangerQueue = []; +const activeStrangerMatches = new Map(); async function shouldSendNotification(userId, preferenceKey) { try { @@ -14,6 +16,26 @@ async function shouldSendNotification(userId, preferenceKey) { } } +async function isBlockedBetween(userId, partnerId) { + if (!userId || !partnerId || userId === partnerId) { + return true; + } + + try { + const user = await User.findById(userId).lean(); + const partner = await User.findById(partnerId).lean(); + if (!user || !partner) { + return true; + } + + const userBlocked = user.blocked?.some((entry) => String(entry.id) === String(partnerId)); + const partnerBlocked = partner.blocked?.some((entry) => String(entry.id) === String(userId)); + return Boolean(userBlocked || partnerBlocked); + } catch { + return true; + } +} + function emitPresenceSnapshot(socket) { socket.emit("presence_snapshot", { online_user_ids: Array.from(onlineUsers.keys()), @@ -62,6 +84,100 @@ function setUserOffline(io, userId, socketId) { onlineUsers.set(normalizedUserId, activeSockets); } +function removeFromStrangerQueue(userId) { + const normalizedUserId = String(userId); + const index = strangerQueue.findIndex((entry) => entry.userId === normalizedUserId); + if (index !== -1) { + strangerQueue.splice(index, 1); + } +} + +function getSocketById(io, socketId) { + return io.sockets.sockets.get(socketId); +} + +function endStrangerMatch(io, normalizedUserId, reason = "The stranger conversation has ended.") { + const match = activeStrangerMatches.get(normalizedUserId); + if (!match) { + return; + } + + const partnerId = String(match.partnerId); + const roomId = match.roomId; + + activeStrangerMatches.delete(normalizedUserId); + activeStrangerMatches.delete(partnerId); + + io.to(roomId).emit("stranger_match_ended", { reason }); +} + +async function matchStrangerPair(io, first, second) { + const firstSocket = getSocketById(io, first.socketId); + const secondSocket = getSocketById(io, second.socketId); + if (!firstSocket || !secondSocket) { + removeFromStrangerQueue(first.userId); + removeFromStrangerQueue(second.userId); + return; + } + + const roomId = `stranger:${Date.now()}:${first.userId}:${second.userId}`; + firstSocket.join(roomId); + secondSocket.join(roomId); + + activeStrangerMatches.set(first.userId, { + partnerId: second.userId, + roomId, + }); + activeStrangerMatches.set(second.userId, { + partnerId: first.userId, + roomId, + }); + + firstSocket.emit("stranger_matched", { + room_id: roomId, + partner: { + id: second.userId, + display_name: second.anonymous ? "Anonymous" : second.username || "Anonymous", + profile_pic: second.anonymous ? "" : second.profile_pic || "", + }, + }); + + secondSocket.emit("stranger_matched", { + room_id: roomId, + partner: { + id: first.userId, + display_name: first.anonymous ? "Anonymous" : first.username || "Anonymous", + profile_pic: first.anonymous ? "" : first.profile_pic || "", + }, + }); +} + +async function attemptStrangerMatch(io) { + while (strangerQueue.length >= 2) { + const first = strangerQueue.shift(); + if (!first) { + continue; + } + + const partnerIndex = strangerQueue.findIndex( + (entry) => entry.userId !== first.userId + ); + if (partnerIndex === -1) { + strangerQueue.unshift(first); + break; + } + + const second = strangerQueue.splice(partnerIndex, 1)[0]; + const blocked = await isBlockedBetween(first.userId, second.userId); + if (blocked) { + strangerQueue.push(first); + continue; + } + + await matchStrangerPair(io, first, second); + } +} + function attachSocketHandlers(io) { io.on("connection", (socket) => { socket.on("channelCreated", (data) => { @@ -118,6 +234,146 @@ function attachSocketHandlers(io) { socket.to(receiver_id).emit("request_updated"); }); + socket.on("join_stranger_queue", async ({ anonymous = true } = {}) => { + const userId = socket.data.user_id; + if (!userId) { + return; + } + + removeFromStrangerQueue(userId); + const normalizedUserId = String(userId); + let userRecord; + try { + userRecord = await User.findById(userId).lean(); + } catch { + userRecord = null; + } + + strangerQueue.push({ + userId: normalizedUserId, + socketId: socket.id, + anonymous: Boolean(anonymous), + username: userRecord?.username || "Anonymous", + profile_pic: userRecord?.profile_pic || "", + }); + socket.emit("stranger_queue_status", { status: "waiting" }); + await attemptStrangerMatch(io); + }); + + socket.on("leave_stranger_queue", () => { + removeFromStrangerQueue(socket.data.user_id); + socket.emit("stranger_queue_status", { status: "idle" }); + }); + + socket.on("leave_stranger_chat", () => { + if (!socket.data.user_id) { + return; + } + endStrangerMatch(io, String(socket.data.user_id), "The conversation has ended."); + }); + + socket.on("request_next_stranger", async ({ anonymous = true } = {}) => { + const userId = socket.data.user_id; + if (!userId) { + return; + } + + endStrangerMatch(io, String(userId), "You have left the conversation to find a new match."); + removeFromStrangerQueue(userId); + const normalizedUserId = String(userId); + let userRecord; + try { + userRecord = await User.findById(userId).lean(); + } catch { + userRecord = null; + } + + strangerQueue.push({ + userId: normalizedUserId, + socketId: socket.id, + anonymous: Boolean(anonymous), + username: userRecord?.username || "Anonymous", + profile_pic: userRecord?.profile_pic || "", + }); + socket.emit("stranger_queue_status", { status: "waiting" }); + await attemptStrangerMatch(io); + }); + + socket.on("report_stranger", async ({ partner_id }) => { + const userId = socket.data.user_id; + if (!userId || !partner_id) { + return; + } + + try { + const partner = await User.findById(partner_id).lean(); + if (!partner) { + return; + } + + await User.updateOne( + { _id: userId, "blocked.id": { $ne: partner_id } }, + { + $push: { + blocked: { + id: partner_id, + username: partner.username || "Anonymous", + profile_pic: partner.profile_pic || "", + tag: partner.tag || "0000", + }, + }, + }, + ); + } catch { + // ignore failures for reporting + } + + endStrangerMatch(io, String(userId), "The conversation ended after reporting the user."); + }); + + socket.on("block_stranger", async ({ partner_id }) => { + const userId = socket.data.user_id; + if (!userId || !partner_id) { + return; + } + + try { + const partner = await User.findById(partner_id).lean(); + if (!partner) { + return; + } + + await User.updateOne( + { _id: userId, "blocked.id": { $ne: partner_id } }, + { + $push: { + blocked: { + id: partner_id, + username: partner.username || "Anonymous", + profile_pic: partner.profile_pic || "", + tag: partner.tag || "0000", + }, + }, + }, + ); + } catch { + // ignore failures for blocking + } + + endStrangerMatch(io, String(userId), "The user has been blocked and removed from the chat."); + }); + + socket.on("stranger_message", (room_id, message_data) => { + if (!room_id || !message_data) { + return; + } + + socket.to(room_id).emit("stranger_message", { + room_id, + message_data, + }); + }); + socket.on("join_chat", (data) => { const channel_id = typeof data === "object" ? data.channel_id : data; const normalizedChannelId = String(channel_id || ""); @@ -233,6 +489,10 @@ function attachSocketHandlers(io) { socket.on("disconnect", () => { setUserOffline(io, socket.data.user_id, socket.id); + removeFromStrangerQueue(socket.data.user_id); + if (socket.data?.user_id) { + endStrangerMatch(io, String(socket.data.user_id), "The stranger has disconnected."); + } }); }); }