├── .github ├── FUNDING.yml ├── pull_request_template.md └── ISSUE_TEMPLATE │ ├── feature_request.md │ ├── code_enhancement.md │ └── bug_report.md ├── backend ├── .gitignore ├── bun.lockb ├── .env.example ├── src │ ├── cache │ │ ├── redis.ts │ │ └── presence.ts │ ├── type.ts │ ├── managers │ │ ├── RoomManager.ts │ │ └── UserManger.ts │ ├── match │ │ └── Matchmaker.ts │ ├── chat │ │ └── chat.ts │ └── index.ts ├── tsconfig.json └── package.json ├── .DS_Store ├── assets ├── header.png ├── code_of_conduct.png └── contributing_guidelines.png ├── frontend ├── bun.lockb ├── app │ ├── favicon.ico │ ├── match │ │ └── page.tsx │ ├── page.tsx │ ├── create-room │ │ └── page.tsx │ ├── room │ │ └── [roomId] │ │ │ └── page.tsx │ ├── test-toasts │ │ └── page.tsx │ ├── layout.tsx │ └── globals.css ├── postcss.config.mjs ├── public │ ├── vercel.svg │ ├── window.svg │ ├── file.svg │ ├── globe.svg │ └── next.svg ├── next.config.ts ├── lib │ └── utils.ts ├── .gitignore ├── components │ ├── ui │ │ ├── toaster.tsx │ │ ├── tooltip.tsx │ │ ├── shimmer-button.tsx │ │ ├── magic-card.tsx │ │ └── animated-beam.tsx │ └── RTC │ │ ├── hooks.tsx │ │ ├── TimeoutAlert.tsx │ │ ├── ControlBar.tsx │ │ ├── VideoGrid.tsx │ │ ├── DeviceCheck.tsx │ │ ├── webrtc-utils.tsx │ │ ├── Chat │ │ └── chat.tsx │ │ └── Room.tsx ├── tsconfig.json ├── package.json ├── README.md ├── tailwind.config.js └── tailwind.config.ts ├── .gitignore ├── SECURITY.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [javatcoding1] 2 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HXQLabs/Helixque/HEAD/.DS_Store -------------------------------------------------------------------------------- /assets/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HXQLabs/Helixque/HEAD/assets/header.png -------------------------------------------------------------------------------- /backend/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HXQLabs/Helixque/HEAD/backend/bun.lockb -------------------------------------------------------------------------------- /frontend/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HXQLabs/Helixque/HEAD/frontend/bun.lockb -------------------------------------------------------------------------------- /frontend/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HXQLabs/Helixque/HEAD/frontend/app/favicon.ico -------------------------------------------------------------------------------- /assets/code_of_conduct.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HXQLabs/Helixque/HEAD/assets/code_of_conduct.png -------------------------------------------------------------------------------- /assets/contributing_guidelines.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HXQLabs/Helixque/HEAD/assets/contributing_guidelines.png -------------------------------------------------------------------------------- /frontend/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | export default config; -------------------------------------------------------------------------------- /frontend/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/app/match/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import DeviceCheck from "@/components/RTC/DeviceCheck"; 3 | 4 | export default function MatchPage() { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /frontend/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /frontend/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import MatchPage from "./match/page"; 3 | 4 | export default function Home() { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /frontend/public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | # Backend Environment Variables 2 | # Copy this to .env and configure as needed 3 | 4 | # Server Configuration 5 | PORT=5001 6 | NODE_ENV=development 7 | 8 | # Optional: Redis Configuration for scaling (uncomment if using Redis) 9 | # REDIS_URL=redis://localhost:6379 10 | 11 | # Optional: CORS Configuration (default allows all origins in dev) 12 | # CORS_ORIGIN=http://localhost:3001 -------------------------------------------------------------------------------- /frontend/public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/app/create-room/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export default function CreateRoomPage() { 4 | return ( 5 |
6 |
7 |

Create Room

8 |

Room creation page coming soon...

9 |
10 |
11 | ); 12 | } -------------------------------------------------------------------------------- /backend/src/cache/redis.ts: -------------------------------------------------------------------------------- 1 | import Redis from "ioredis"; 2 | 3 | const REDIS_URL = process.env.REDIS_URL || "redis://127.0.0.1:6379"; 4 | 5 | export const appRedis = new Redis(REDIS_URL); 6 | export const pubClient = new Redis(REDIS_URL); 7 | export const subClient = pubClient.duplicate(); 8 | 9 | // Helpful logs (won’t crash app) 10 | for (const [name, c] of [["app", appRedis], ["pub", pubClient], ["sub", subClient]] as const) { 11 | // c.on("connect", () => console.log(`[redis:${name}] connected`)); 12 | // c.on("error", (e) => console.warn(`[redis:${name}] error`, e.message)); 13 | } 14 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | 7 | "rootDir": "./src", 8 | "outDir": "./dist", 9 | 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "resolveJsonModule": true, 13 | 14 | "strict": true, 15 | "skipLibCheck": true, 16 | "forceConsistentCasingInFileNames": true, 17 | 18 | "types": ["node"], 19 | "typeRoots": ["./node_modules/@types"] 20 | }, 21 | "include": ["src/**/*.ts"], 22 | "exclude": ["dist", "node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | */node_modules/ 4 | 5 | # Environment variables 6 | .env 7 | .env.local 8 | .env.production 9 | .env.staging 10 | 11 | # Build outputs 12 | dist/ 13 | .next/ 14 | build/ 15 | 16 | # Logs 17 | *.log 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | # Runtime data 23 | pids 24 | *.pid 25 | *.seed 26 | *.pid.lock 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage/ 30 | 31 | # IDE files 32 | .vscode/ 33 | .idea/ 34 | *.swp 35 | *.swo 36 | 37 | # OS generated files 38 | .DS_Store 39 | .DS_Store? 40 | ._* 41 | .Spotlight-V100 42 | .Trashes 43 | ehthumbs.db 44 | Thumbs.db 45 | 46 | # Temporary folders 47 | tmp/ 48 | temp/ -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | 14 | # testing 15 | /coverage 16 | 17 | # next.js 18 | /.next/ 19 | /out/ 20 | 21 | # production 22 | /build 23 | 24 | # misc 25 | .DS_Store 26 | *.pem 27 | 28 | # debug 29 | npm-debug.log* 30 | yarn-debug.log* 31 | yarn-error.log* 32 | .pnpm-debug.log* 33 | 34 | # env files (can opt-in for committing if needed) 35 | .env* 36 | 37 | # vercel 38 | .vercel 39 | 40 | # typescript 41 | *.tsbuildinfo 42 | next-env.d.ts 43 | 44 | 45 | package-lock.json 46 | -------------------------------------------------------------------------------- /frontend/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster as SonnerToaster } from "sonner"; 2 | 3 | export function Toaster() { 4 | return ( 5 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | If you discover a security vulnerability, **please do not open a public issue**. 5 | 6 | Instead, use GitHub’s **private vulnerability reporting feature** to notify the maintainers securely: 7 | 👉 [Report a vulnerability](../../security/advisories/new) 8 | 9 | If this option is unavailable, you can alternatively describe the issue privately to the maintainer team via GitHub (e.g., through a private message or organization contact). 10 | 11 | We appreciate responsible disclosure and will respond as soon as possible. 12 | 13 | ## Supported Versions 14 | Only the latest stable release of this project is supported for security updates. 15 | 16 | ## Responsible Disclosure 17 | When reporting a vulnerability, please include: 18 | - Steps to reproduce the issue 19 | - The potential impact 20 | - Any suggested fixes or mitigations 21 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "1.0.0", 4 | "main": "dist/index.js", 5 | "engines": { "node": "20.x" }, 6 | "scripts": { 7 | "build": "tsc -p tsconfig.json", 8 | "start": "node dist/index.js", 9 | "dev": "tsx src/index.ts" 10 | }, 11 | "dependencies": { 12 | "@clerk/express": "^1.7.22", 13 | "@socket.io/redis-adapter": "^8.3.0", 14 | "cors": "^2.8.5", 15 | "dotenv": "^17.2.1", 16 | "express": "^4.21.2", 17 | "ioredis": "^5.8.0", 18 | "mongoose": "^8.17.2", 19 | "peer": "^1.0.2", 20 | "socket.io": "^4.8.1", 21 | "socket.io-adapter": "^2.5.5", 22 | "zod": "^4.0.17" 23 | }, 24 | "devDependencies": { 25 | "@types/cors": "^2.8.17", 26 | "@types/express": "^4.17.23", 27 | "@types/node": "^20.19.19", 28 | "@types/socket.io": "^3.0.1", 29 | "tsx": "^4.7.0", 30 | "typescript": "^5.4.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /frontend/app/room/[roomId]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { use } from "react"; 3 | import { useEffect } from "react"; 4 | import { useRouter } from "next/navigation"; 5 | 6 | interface RoomPageProps { 7 | params: Promise<{ 8 | roomId: string; 9 | }>; 10 | } 11 | 12 | export default function RoomPage({ params }: RoomPageProps) { 13 | const router = useRouter(); 14 | const resolvedParams = use(params); 15 | 16 | useEffect(() => { 17 | // Redirect to match page with roomId 18 | router.push(`/match?roomId=${resolvedParams.roomId}`); 19 | }, [resolvedParams.roomId, router]); 20 | 21 | return ( 22 |
23 |
24 |

Joining room...

25 |

Redirecting to device setup...

26 |
27 |
28 | ); 29 | } -------------------------------------------------------------------------------- /frontend/public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@tabler/icons-react": "^3.34.1", 13 | "@tailwindcss/postcss": "^4.1.12", 14 | "autoprefixer": "^10.4.21", 15 | "canvas-confetti": "^1.9.3", 16 | "class-variance-authority": "^0.7.1", 17 | "clsx": "^2.1.1", 18 | "emoji-picker-react": "^4.14.0", 19 | "framer-motion": "^12.23.12", 20 | "lucide-react": "^0.539.0", 21 | "motion": "^12.23.12", 22 | "next": "15.4.6", 23 | "peerjs": "^1.5.5", 24 | "postcss": "^8.5.6", 25 | "react": "19.1.0", 26 | "react-dom": "19.1.0", 27 | "react-icons": "^5.5.0", 28 | "socket.io-client": "^4.8.1", 29 | "sonner": "^2.0.7", 30 | "tailwind-merge": "^3.3.1" 31 | }, 32 | "devDependencies": { 33 | "@types/node": "^20", 34 | "@types/react": "^19", 35 | "@types/react-dom": "^19", 36 | "tailwindcss": "^4.1.11", 37 | "typescript": "^5" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useState } from "react"; 3 | 4 | export default function Tooltip({ children, content, position = "top" }: { children: React.ReactNode, content: string, position?: "top" | "bottom" | "left" | "right" }) { 5 | const [visible, setVisible] = useState(false); 6 | 7 | return ( 8 |
setVisible(true)} 11 | onMouseLeave={() => setVisible(false)} 12 | > 13 | {children} 14 | {visible && ( 15 |
24 | {content} 25 |
26 | )} 27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /frontend/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/src/cache/presence.ts: -------------------------------------------------------------------------------- 1 | import { appRedis } from "./redis"; 2 | 3 | const TTL_SEC = Number(process.env.SOCKET_PRESENCE_TTL || 60); 4 | 5 | // Key helpers 6 | const K = { 7 | socketMeta: (sid: string) => `socket:${sid}`, // string => JSON meta (name, ts, ip, etc) 8 | onlineSet: "sockets:online", // Set of SIDs 9 | }; 10 | 11 | // Add (on connection) 12 | export async function presenceUp(socketId: string, meta: Record) { 13 | const now = Date.now(); 14 | const payload = JSON.stringify({ ...meta, ts: now }); 15 | await appRedis 16 | .multi() 17 | .set(K.socketMeta(socketId), payload, "EX", TTL_SEC) 18 | .sadd(K.onlineSet, socketId) 19 | .exec(); 20 | } 21 | 22 | // Heartbeat (refresh TTL) 23 | export async function presenceHeartbeat(socketId: string) { 24 | // Only need to refresh expiry; keep value as-is 25 | // A portable way: get + set with EX if you need; cheaper: just expire 26 | await appRedis.expire(K.socketMeta(socketId), TTL_SEC); 27 | } 28 | 29 | // Remove (on disconnect) 30 | export async function presenceDown(socketId: string) { 31 | await appRedis 32 | .multi() 33 | .del(K.socketMeta(socketId)) 34 | .srem(K.onlineSet, socketId) 35 | .exec(); 36 | } 37 | 38 | // Introspection (optional) 39 | export async function getOnlineSockets(): Promise { 40 | return appRedis.smembers(K.onlineSet); 41 | } 42 | 43 | export async function countOnline(): Promise { 44 | return appRedis.scard(K.onlineSet); 45 | } 46 | -------------------------------------------------------------------------------- /frontend/app/test-toasts/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { toast } from "sonner"; 3 | 4 | export default function TestToastsPage() { 5 | return ( 6 |
7 |
8 |

Toast Test Page

9 | 10 | 16 | 17 | 23 | 24 | 30 | 31 | 37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /frontend/components/RTC/hooks.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | 5 | // ===== CUSTOM HOOKS ===== 6 | export function useMediaState(audioOn?: boolean, videoOn?: boolean) { 7 | const [micOn, setMicOn] = useState(typeof audioOn === "boolean" ? audioOn : true); 8 | const [camOn, setCamOn] = useState(typeof videoOn === "boolean" ? videoOn : true); 9 | const [screenShareOn, setScreenShareOn] = useState(false); 10 | 11 | return { 12 | micOn, setMicOn, 13 | camOn, setCamOn, 14 | screenShareOn, setScreenShareOn 15 | }; 16 | } 17 | 18 | export function usePeerState() { 19 | const [peerMicOn, setPeerMicOn] = useState(true); 20 | const [peerCamOn, setPeerCamOn] = useState(true); 21 | const [peerScreenShareOn, setPeerScreenShareOn] = useState(false); 22 | 23 | return { 24 | peerMicOn, setPeerMicOn, 25 | peerCamOn, setPeerCamOn, 26 | peerScreenShareOn, setPeerScreenShareOn 27 | }; 28 | } 29 | 30 | export function useRoomState() { 31 | const [showChat, setShowChat] = useState(false); 32 | const [roomId, setRoomId] = useState(null); 33 | const [mySocketId, setMySocketId] = useState(null); 34 | const [lobby, setLobby] = useState(true); 35 | const [status, setStatus] = useState("Waiting to connect you to someone…"); 36 | const [showTimeoutAlert, setShowTimeoutAlert] = useState(false); 37 | const [timeoutMessage, setTimeoutMessage] = useState(""); 38 | 39 | return { 40 | showChat, setShowChat, 41 | roomId, setRoomId, 42 | mySocketId, setMySocketId, 43 | lobby, setLobby, 44 | status, setStatus, 45 | showTimeoutAlert, setShowTimeoutAlert, 46 | timeoutMessage, setTimeoutMessage 47 | }; 48 | } -------------------------------------------------------------------------------- /frontend/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import type { ReactNode } from "react"; 4 | import "./globals.css"; 5 | import { Toaster } from "@/components/ui/toaster"; 6 | 7 | 8 | const geistSans = Geist({ 9 | variable: "--font-geist-sans", 10 | subsets: ["latin"], 11 | }); 12 | 13 | const geistMono = Geist_Mono({ 14 | variable: "--font-geist-mono", 15 | subsets: ["latin"], 16 | }); 17 | 18 | export const metadata: Metadata = { 19 | title: "Helixque", 20 | description: "Connect with professionals worldwide through Helixque's real-time video chat platform. Match based on preferences, network effectively, and build meaningful professional relationships.", 21 | keywords: ["professional networking", "video chat", "business meetings", "professional connections", "WebRTC", "real-time communication"], 22 | authors: [{ name: "Helixque Team" }], 23 | openGraph: { 24 | title: "Helixque", 25 | description: "Connect with professionals worldwide through real-time video chat. Match based on preferences and build meaningful business relationships.", 26 | type: "website", 27 | siteName: "Helixque", 28 | }, 29 | twitter: { 30 | card: "summary_large_image", 31 | title: "Helixque", 32 | description: "Connect with professionals worldwide through real-time video chat platform.", 33 | }, 34 | viewport: "width=device-width, initial-scale=1", 35 | robots: "index, follow", 36 | }; 37 | 38 | export default function RootLayout({ 39 | children, 40 | }: Readonly<{ 41 | children: ReactNode; 42 | }>) { 43 | return ( 44 | 45 | 46 | 50 | {children} 51 | 52 | 53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Helixque Frontend 2 | 3 | This is the frontend for Helixque, a professional real-time video chat application that connects people based on preferences, built with [Next.js](https://nextjs.org). 4 | 5 | ## Getting Started 6 | 7 | Make sure the backend server is running first (see main README for backend setup). 8 | 9 | Then, run the frontend development server: 10 | 11 | ```bash 12 | npm run dev 13 | ``` 14 | 15 | Open [http://localhost:3000](http://localhost:3000) (or the next available port) with your browser. 16 | 17 | **Important**: You'll need to allow camera and microphone permissions for the application to work properly. 18 | 19 | ## Key Components 20 | 21 | - `app/page.tsx` - Landing page 22 | - `app/match/page.tsx` - Device setup and preference-based user matching 23 | - `components/RTC/DeviceCheck.tsx` - Camera/microphone setup interface 24 | - `components/RTC/Room.tsx` - Main professional video chat room component 25 | 26 | This project uses WebRTC for peer-to-peer video communication and Socket.IO for real-time signaling, enabling professional networking through video conversations. 27 | 28 | ## Learn More 29 | 30 | To learn more about Next.js, take a look at the following resources: 31 | 32 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 33 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 34 | 35 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 36 | 37 | ## Deploy on Vercel 38 | 39 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 40 | 41 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 42 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## 🌟 Pre-submission Checklist 2 | - [ ] ⭐ **I have starred the repository** (mandatory before contributing) 3 | - [ ] 💬 **I am a member of the Discord server**: [Join Here](https://discord.gg/UJfWXRYe) 4 | - [ ] 📝 **I have signed up at [helixque.netlify.app](https://helixque.netlify.app/)** 5 | - [ ] 📢 **I have checked the `#pull-request` channel** to ensure no one else is working on this issue 6 | - [ ] 📝 **I have mentioned this PR in the Discord `#pull-request` channel** 7 | 8 | 9 | ## Summary 10 | Brief description of what this PR accomplishes. 11 | 12 | ## Type of Changes 13 | - [ ] 🚀 Feature addition 14 | - [ ] 🐛 Bug fix 15 | - [ ] 📚 Documentation update 16 | - [ ] 🔧 Refactoring 17 | - [ ] 🎨 UI/UX improvements 18 | - [ ] ⚡ Performance optimizations 19 | - [ ] 📱 Mobile responsiveness 20 | - [ ] ♿ Accessibility improvements 21 | - [ ] Other: _____ 22 | 23 | ## Testing Completed 24 | - [ ] ✅ I have tested these changes locally 25 | - [ ] 🔧 Backend functionality works properly (if applicable) 26 | - [ ] 🎨 Frontend functionality works properly (if applicable) 27 | - [ ] 🌐 WebRTC connections work properly (if applicable) 28 | - [ ] 📱 Tested on different screen sizes/devices 29 | - [ ] 🔄 Tested edge cases (disconnections, reconnections, etc.) 30 | - [ ] 🧪 All existing functionality remains unaffected 31 | 32 | ## Development Setup Verification 33 | - [ ] 📦 Dependencies installed for both frontend and backend 34 | - [ ] 🚀 Development servers start without errors 35 | - [ ] 🏗️ Code builds successfully 36 | 37 | ## Code Quality 38 | - [ ] 📏 Follows existing TypeScript and React patterns 39 | - [ ] 📝 Uses meaningful variable and function names 40 | - [ ] 💡 Added comments for complex logic 41 | - [ ] 🎯 Code is properly formatted 42 | - [ ] 🔍 Self-review of the code has been performed 43 | 44 | ## Related Issues 45 | Closes # 46 | 47 | ## Screenshots/Videos 48 | 49 | 50 | ## Additional Notes 51 | Any additional information or context about the changes. 52 | 53 | --- 54 | **Note**: For faster PR review and approval, ensure you're active in our Discord server! 55 | -------------------------------------------------------------------------------- /frontend/components/RTC/TimeoutAlert.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { IconFlag } from "@tabler/icons-react"; 4 | 5 | interface TimeoutAlertProps { 6 | show: boolean; 7 | message: string; 8 | onRetry: () => void; 9 | onCancel: () => void; 10 | onKeyDown: (e: React.KeyboardEvent) => void; 11 | } 12 | 13 | export default function TimeoutAlert({ 14 | show, 15 | message, 16 | onRetry, 17 | onCancel, 18 | onKeyDown 19 | }: TimeoutAlertProps) { 20 | if (!show) return null; 21 | 22 | return ( 23 |
30 |
31 |
32 |
33 | 34 |
35 | 36 |

37 | No Match Found 38 |

39 | 40 |

41 | {message || "We couldn't find a match right now. Please try again later."} 42 |

43 | 44 |
45 | 52 | 58 |
59 |
60 |
61 |
62 | ); 63 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request (Hacktoberfest) 3 | about: Suggest a new feature after completing prerequisites 4 | title: "[Feature]: " 5 | labels: ["enhancement", "hacktoberfest", "pending verification"] 6 | --- 7 | 8 | --- 9 | ## Note 10 | You can now preview the latest updates and improvements every 2–3 days at the following link: 11 | 👉 [Helixque-Changes](https://helixque-changes.netlify.app/) 12 | --- 13 | 14 | **Describe the feature you'd like** 15 | A clear and concise description of the feature you want added. 16 | 17 | **Describe alternatives you've considered** 18 | Any alternative solutions or features you've considered. 19 | 20 | **Additional context** 21 | Add any other context or screenshots about the feature request here. 22 | 23 | --- 24 | 25 | ### Prerequisite Checklist 26 | 27 | - [ ] ⭐ Starred [Helixque Repo](https://github.com/HXQLabs/Helixque/) 28 | - [ ] ⭐ Starred [Helixque Landing Repo](https://github.com/HXQLabs/helixque-landing/) 29 | - [ ] 📘 Read and understood [Contributing Guidelines](https://github.com/HXQLabs/Helixque/blob/main/CONTRIBUTING.md) 30 | - [ ] 💬 Joined [Discord Channel](https://discord.com/invite/dQUh6SY9Uk) 31 | - [ ] 🌐 Explored and signed up on [helixque.netlify.app](https://helixque.netlify.app) 32 | 33 | --- 34 | 35 | 36 | ✅ Issues will be assigned once all checklist prerequisites are verified. 37 | 38 | > \[!IMPORTANT] 39 | > 40 | > 🛑 **Contribution Guidelines — Please Read Before Proceeding** 41 | > 42 | > - Any **PR raised for an unassigned issue** (even if the issue number is mentioned in the PR) **will not be merged and will be closed automatically**. 43 | > - If your issue is valid but **not yet assigned**, please **ping the Project Maintainers** in the [Discord Channel](https://discord.com/invite/dQUh6SY9Uk) to get it assigned **before** starting work or submitting a PR. 44 | > - Only **Code Owners** or **Project Maintainers** have the authority to verify, approve, and assign issues. 45 | > - Do **not self-assign issues** — any self-assigned issue will be reviewed, and the final decision will rest with the **Code Owners**. 46 | > 47 | > Thank you for helping shape **HelixQue’s future** this **Hacktoberfest 🙌** 48 | 49 | 50 | ## Note 51 | You can now preview the latest updates and improvements every 2–3 days at the following link: 52 | 👉 [Helixque-Changes](https://helixque-changes.netlify.app/) 53 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/code_enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Code/Performance Enhancement (Hacktoberfest) 3 | about: Suggest improvements to existing code or performance after completing prerequisites 4 | title: "[Enhancement]: " 5 | labels: ["enhancement", "hacktoberfest", "pending verification"] 6 | --- 7 | 8 | --- 9 | ## Note 10 | You can now preview the latest updates and improvements every 2–3 days at the following link: 11 | 👉 [Helixque-Changes](https://helixque-changes.netlify.app/) 12 | --- 13 | 14 | **Describe the enhancement** 15 | What improvement are you proposing? Share architectural, code, or performance details. 16 | 17 | **Current vs. improved behavior** 18 | What currently happens and how does your proposal improve things? 19 | 20 | **Additional context** 21 | Other details, links, or references. 22 | 23 | --- 24 | 25 | ### Prerequisite Checklist 26 | 27 | - [ ] ⭐ Starred [Helixque Repo](https://github.com/HXQLabs/Helixque/) 28 | - [ ] ⭐ Starred [Helixque Landing Repo](https://github.com/HXQLabs/helixque-landing/) 29 | - [ ] 📘 Read and understood [Contributing Guidelines](https://github.com/HXQLabs/Helixque/blob/main/CONTRIBUTING.md) 30 | - [ ] 💬 Joined [Discord Channel](https://discord.com/invite/dQUh6SY9Uk) 31 | - [ ] 🌐 Explored and signed up on [helixque.netlify.app](https://helixque.netlify.app) 32 | 33 | --- 34 | 35 | ✅ Issues will be assigned once all checklist prerequisites are verified. 36 | 37 | > \[!IMPORTANT] 38 | > 39 | > 🛑 **Contribution Guidelines — Please Read Before Proceeding** 40 | > 41 | > - Any **PR raised for an unassigned issue** (even if the issue number is mentioned in the PR) **will not be merged and will be closed automatically**. 42 | > - If your issue is valid but **not yet assigned**, please **ping the Project Maintainers** in the [Discord Channel](https://discord.com/invite/dQUh6SY9Uk) to get it assigned **before** starting work or submitting a PR. 43 | > - Only **Code Owners** or **Project Maintainers** have the authority to verify, approve, and assign issues. 44 | > - Do **not self-assign issues** — any self-assigned issue will be reviewed, and the final decision will rest with the **Code Owners**. 45 | > 46 | > Thank you for helping shape **HelixQue’s future** this **Hacktoberfest 🙌** 47 | 48 | ## Note 49 | You can now preview the latest updates and improvements every 2–3 days at the following link: 50 | 👉 [Helixque-Changes](https://helixque-changes.netlify.app/) 51 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report (Hacktoberfest) 3 | about: File a bug report after completing prerequisites 4 | title: "[Bug]: " 5 | labels: ["bug", "hacktoberfest", "pending verification"] 6 | --- 7 | 8 | --- 9 | ## Note 10 | You can now preview the latest updates and improvements every 2–3 days at the following link: 11 | 👉 [Helixque-Changes](https://helixque-changes.netlify.app/) 12 | --- 13 | 14 | **Describe the bug** 15 | A clear and concise description of what the bug is. 16 | 17 | **To Reproduce** 18 | Steps to reproduce the behavior: 19 | 1. Go to '...' 20 | 2. Click on '....' 21 | 3. Scroll down to '....' 22 | 4. See error 23 | 24 | **Expected behavior** 25 | A clear and concise description of what you expected to happen. 26 | 27 | **Screenshots (if applicable)** 28 | If applicable, add screenshots to help explain your problem. 29 | 30 | **Environment (please complete the following information):** 31 | - OS: [e.g. Windows 11] 32 | - Browser [e.g. Chrome 115] 33 | - Node version [e.g. v16.3.0] 34 | 35 | **Additional context** 36 | Add any other context about the problem here. 37 | 38 | --- 39 | 40 | ### Prerequisite Checklist 41 | 42 | - [ ] ⭐ Starred [Helixque Repo](https://github.com/HXQLabs/Helixque/) 43 | - [ ] ⭐ Starred [Helixque Landing Repo](https://github.com/HXQLabs/helixque-landing/) 44 | - [ ] 📘 Read and understood [Contributing Guidelines](https://github.com/HXQLabs/Helixque/blob/main/CONTRIBUTING.md) 45 | - [ ] 💬 Joined [Discord Channel](https://discord.com/invite/dQUh6SY9Uk) 46 | - [ ] 🌐 Explored and signed up on [helixque.netlify.app](https://helixque.netlify.app) 47 | 48 | --- 49 | 50 | ✅ Issues will be assigned once all checklist prerequisites are verified. 51 | 52 | > \[!IMPORTANT] 53 | > 54 | > 🛑 **Contribution Guidelines — Please Read Before Proceeding** 55 | > 56 | > - Any **PR raised for an unassigned issue** (even if the issue number is mentioned in the PR) **will not be merged and will be closed automatically**. 57 | > - If your issue is valid but **not yet assigned**, please **ping the Project Maintainers** in the [Discord Channel](https://discord.com/invite/dQUh6SY9Uk) to get it assigned **before** starting work or submitting a PR. 58 | > - Only **Code Owners** or **Project Maintainers** have the authority to verify, approve, and assign issues. 59 | > - Do **not self-assign issues** — any self-assigned issue will be reviewed, and the final decision will rest with the **Code Owners**. 60 | > 61 | > Thank you for helping shape **HelixQue’s future** this **Hacktoberfest 🙌** 62 | 63 | 64 | -------------------------------------------------------------------------------- /backend/src/type.ts: -------------------------------------------------------------------------------- 1 | import type { Socket } from "socket.io"; 2 | 3 | export interface User { 4 | socket: Socket; 5 | name: string; 6 | joinedAt?: number; 7 | meta?: { 8 | language?: string; 9 | industry?: string; 10 | skillBucket?: string; 11 | ip?: string | null; 12 | ua?: string | null; 13 | [key: string]: unknown; 14 | }; 15 | } 16 | 17 | // Socket.IO handshake auth parameters 18 | export interface HandshakeAuth { 19 | name?: string; 20 | roomId?: string; 21 | } 22 | 23 | // Socket.IO handshake query parameters 24 | export interface HandshakeQuery { 25 | roomId?: string; 26 | } 27 | 28 | // Socket data structure for storing chat-related information 29 | export interface SocketData { 30 | chatNames?: Record; 31 | } 32 | 33 | // Chat event payload types 34 | export interface ChatJoinPayload { 35 | roomId: string; 36 | name?: string; 37 | } 38 | 39 | export interface ChatMessagePayload { 40 | roomId: string; 41 | text: string; 42 | from: string; 43 | clientId: string; 44 | ts?: number; 45 | } 46 | 47 | export interface ChatTypingPayload { 48 | roomId: string; 49 | from: string; 50 | typing: boolean; 51 | } 52 | 53 | export interface ChatLeavePayload { 54 | roomId: string; 55 | name: string; 56 | } 57 | 58 | export interface ScreenStatePayload { 59 | roomId: string; 60 | on: boolean; 61 | } 62 | 63 | export interface ScreenshareOfferPayload { 64 | roomId: string; 65 | sdp: string; 66 | } 67 | 68 | export interface ScreenshareAnswerPayload { 69 | roomId: string; 70 | sdp: string; 71 | } 72 | 73 | export interface ScreenshareIceCandidatePayload { 74 | roomId: string; 75 | candidate: string; 76 | } 77 | 78 | export interface ScreenshareTrackStartPayload { 79 | roomId: string; 80 | } 81 | 82 | export interface ScreenshareTrackStopPayload { 83 | roomId: string; 84 | } 85 | 86 | export interface MediaStatePayload { 87 | roomId: string; 88 | state: { 89 | micOn?: boolean; 90 | camOn?: boolean; 91 | }; 92 | } 93 | 94 | export interface MediaCamPayload { 95 | roomId: string; 96 | on: boolean; 97 | } 98 | 99 | export interface MediaMicPayload { 100 | roomId: string; 101 | on: boolean; 102 | } 103 | 104 | export interface StateUpdatePayload { 105 | roomId: string; 106 | micOn?: boolean; 107 | camOn?: boolean; 108 | } 109 | 110 | export interface RenegotiateOfferPayload { 111 | roomId: string; 112 | sdp: string; 113 | role: string; 114 | } 115 | 116 | export interface RenegotiateAnswerPayload { 117 | roomId: string; 118 | sdp: string; 119 | role: string; 120 | } 121 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ![Helixque Code of Conduct](assets/code_of_conduct.png) 2 | 3 | ## Our Pledge 4 | We as contributors, maintainers, and members of the Helixque community pledge to make participation in our project and community a safe, respectful, and welcoming experience for everyone. 5 | We are committed to creating an environment free from harassment, discrimination, and hostility—regardless of age, body size, visible or invisible disability, ethnicity, gender identity, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | Examples of behavior that contributes to a positive environment include: 9 | - Being respectful and considerate in all interactions 10 | - Offering constructive feedback and being open to others’ perspectives 11 | - Using inclusive and welcoming language 12 | - Helping others learn and grow 13 | 14 | Examples of unacceptable behavior include: 15 | - Harassment, intimidation, or personal attacks 16 | - Discriminatory or offensive comments (verbal, written, or visual) 17 | - Sharing sexually explicit or violent material 18 | - Trolling, insulting remarks, or deliberate disruption of conversations 19 | - Publishing private information (such as physical or email addresses) without permission 20 | 21 | ## Our Responsibilities 22 | Project maintainers are responsible for: 23 | - Clarifying and enforcing standards of acceptable behavior 24 | - Taking appropriate corrective action in response to unacceptable behavior 25 | - Temporarily or permanently banning contributors who violate the Code of Conduct 26 | 27 | ## Scope 28 | This Code of Conduct applies within all project spaces—GitHub issues, discussions, pull requests, and external channels directly related to the project. 29 | It also applies when an individual represents the project or its community publicly. 30 | 31 | ## Enforcement 32 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by: 33 | - Opening an issue in the repository 34 | - Contacting the maintainers directly (via GitHub profiles or project email if provided) 35 | 36 | All complaints will be reviewed and investigated promptly and fairly. 37 | Maintainers are obligated to maintain confidentiality regarding the reporter of an incident. 38 | 39 | ## Enforcement Guidelines 40 | 1. **Warning** – Maintainers will issue a private, written warning. 41 | 2. **Temporary Ban** – Contributor may be suspended from interaction for a period. 42 | 3. **Permanent Ban** – For severe or repeated violations, a permanent ban may be enforced. 43 | 44 | ## Attribution 45 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1. 46 | -------------------------------------------------------------------------------- /frontend/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | :root { 4 | --background: 0 0% 100%; 5 | --foreground: 0 0% 3.9%; 6 | --card: 0 0% 100%; 7 | --card-foreground: 0 0% 3.9%; 8 | --popover: 0 0% 100%; 9 | --popover-foreground: 0 0% 3.9%; 10 | --primary: 0 0% 9%; 11 | --primary-foreground: 0 0% 98%; 12 | --secondary: 0 0% 96.1%; 13 | --secondary-foreground: 0 0% 9%; 14 | --muted: 0 0% 96.1%; 15 | --muted-foreground: 0 0% 45.1%; 16 | --accent: 0 0% 96.1%; 17 | --accent-foreground: 0 0% 9%; 18 | --destructive: 0 84.2% 60.2%; 19 | --destructive-foreground: 0 0% 98%; 20 | --border: 0 0% 89.8%; 21 | --input: 0 0% 89.8%; 22 | --ring: 0 0% 3.9%; 23 | --chart-1: 12 76% 61%; 24 | --chart-2: 173 58% 39%; 25 | --chart-3: 197 37% 24%; 26 | --chart-4: 43 74% 66%; 27 | --chart-5: 27 87% 67%; 28 | --radius: 0.5rem; 29 | } 30 | 31 | .dark { 32 | --background: 0 0% 3.9%; 33 | --foreground: 0 0% 98%; 34 | --card: 0 0% 3.9%; 35 | --card-foreground: 0 0% 98%; 36 | --popover: 0 0% 3.9%; 37 | --popover-foreground: 0 0% 98%; 38 | --primary: 0 0% 98%; 39 | --primary-foreground: 0 0% 9%; 40 | --secondary: 0 0% 14.9%; 41 | --secondary-foreground: 0 0% 98%; 42 | --muted: 0 0% 14.9%; 43 | --muted-foreground: 0 0% 63.9%; 44 | --accent: 0 0% 14.9%; 45 | --accent-foreground: 0 0% 98%; 46 | --destructive: 0 62.8% 30.6%; 47 | --destructive-foreground: 0 0% 98%; 48 | --border: 0 0% 14.9%; 49 | --input: 0 0% 14.9%; 50 | --ring: 0 0% 83.1%; 51 | --chart-1: 220 70% 50%; 52 | --chart-2: 160 60% 45%; 53 | --chart-3: 30 80% 55%; 54 | --chart-4: 280 65% 60%; 55 | --chart-5: 340 75% 55%; 56 | } 57 | 58 | body { 59 | background-color: hsl(var(--background)); 60 | color: hsl(var(--foreground)); 61 | } 62 | 63 | /* Custom Sonner Toast Styles */ 64 | [data-sonner-toaster] { 65 | font-family: inherit; 66 | } 67 | 68 | [data-sonner-toast] { 69 | background: rgb(38 38 38) !important; 70 | border: 1px solid rgb(64 64 64) !important; 71 | color: rgb(255 255 255) !important; 72 | border-radius: 12px !important; 73 | box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5) !important; 74 | } 75 | 76 | [data-sonner-toast][data-type="success"] { 77 | border-color: rgb(34 197 94) !important; 78 | background: rgb(20 83 45) !important; 79 | } 80 | 81 | [data-sonner-toast][data-type="error"] { 82 | border-color: rgb(239 68 68) !important; 83 | background: rgb(127 29 29) !important; 84 | } 85 | 86 | [data-sonner-toast][data-type="warning"] { 87 | border-color: rgb(245 158 11) !important; 88 | background: rgb(146 64 14) !important; 89 | } 90 | 91 | [data-sonner-toast] [data-title] { 92 | color: rgb(255 255 255) !important; 93 | font-weight: 600 !important; 94 | } 95 | 96 | [data-sonner-toast] [data-description] { 97 | color: rgb(212 212 212) !important; 98 | } -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 5 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 7 | ], 8 | theme: { 9 | extend: { 10 | colors: { 11 | background: "hsl(var(--background))", 12 | foreground: "hsl(var(--foreground))", 13 | card: { 14 | DEFAULT: "hsl(var(--card))", 15 | foreground: "hsl(var(--card-foreground))", 16 | }, 17 | popover: { 18 | DEFAULT: "hsl(var(--popover))", 19 | foreground: "hsl(var(--popover-foreground))", 20 | }, 21 | primary: { 22 | DEFAULT: "hsl(var(--primary))", 23 | foreground: "hsl(var(--primary-foreground))", 24 | }, 25 | secondary: { 26 | DEFAULT: "hsl(var(--secondary))", 27 | foreground: "hsl(var(--secondary-foreground))", 28 | }, 29 | muted: { 30 | DEFAULT: "hsl(var(--muted))", 31 | foreground: "hsl(var(--muted-foreground))", 32 | }, 33 | accent: { 34 | DEFAULT: "hsl(var(--accent))", 35 | foreground: "hsl(var(--accent-foreground))", 36 | }, 37 | destructive: { 38 | DEFAULT: "hsl(var(--destructive))", 39 | foreground: "hsl(var(--destructive-foreground))", 40 | }, 41 | border: "hsl(var(--border))", 42 | input: "hsl(var(--input))", 43 | ring: "hsl(var(--ring))", 44 | chart: { 45 | "1": "hsl(var(--chart-1))", 46 | "2": "hsl(var(--chart-2))", 47 | "3": "hsl(var(--chart-3))", 48 | "4": "hsl(var(--chart-4))", 49 | "5": "hsl(var(--chart-5))", 50 | }, 51 | }, 52 | borderRadius: { 53 | lg: "var(--radius)", 54 | md: "calc(var(--radius) - 2px)", 55 | sm: "calc(var(--radius) - 4px)", 56 | }, 57 | keyframes: { 58 | "shimmer-slide": { 59 | to: { 60 | transform: "translate(calc(100cqw - 100%), 0)", 61 | }, 62 | }, 63 | "spin-around": { 64 | "0%": { 65 | transform: "translateZ(0) rotate(0)", 66 | }, 67 | "15%, 35%": { 68 | transform: "translateZ(0) rotate(90deg)", 69 | }, 70 | "65%, 85%": { 71 | transform: "translateZ(0) rotate(270deg)", 72 | }, 73 | "100%": { 74 | transform: "translateZ(0) rotate(360deg)", 75 | }, 76 | }, 77 | }, 78 | animation: { 79 | "shimmer-slide": 80 | "shimmer-slide var(--speed) ease-in-out infinite alternate", 81 | "spin-around": "spin-around calc(var(--speed) * 2) infinite linear", 82 | }, 83 | }, 84 | }, 85 | plugins: [], 86 | } 87 | -------------------------------------------------------------------------------- /frontend/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | content: [ 5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | background: "hsl(var(--background))", 13 | foreground: "hsl(var(--foreground))", 14 | card: { 15 | DEFAULT: "hsl(var(--card))", 16 | foreground: "hsl(var(--card-foreground))", 17 | }, 18 | popover: { 19 | DEFAULT: "hsl(var(--popover))", 20 | foreground: "hsl(var(--popover-foreground))", 21 | }, 22 | primary: { 23 | DEFAULT: "hsl(var(--primary))", 24 | foreground: "hsl(var(--primary-foreground))", 25 | }, 26 | secondary: { 27 | DEFAULT: "hsl(var(--secondary))", 28 | foreground: "hsl(var(--secondary-foreground))", 29 | }, 30 | muted: { 31 | DEFAULT: "hsl(var(--muted))", 32 | foreground: "hsl(var(--muted-foreground))", 33 | }, 34 | accent: { 35 | DEFAULT: "hsl(var(--accent))", 36 | foreground: "hsl(var(--accent-foreground))", 37 | }, 38 | destructive: { 39 | DEFAULT: "hsl(var(--destructive))", 40 | foreground: "hsl(var(--destructive-foreground))", 41 | }, 42 | border: "hsl(var(--border))", 43 | input: "hsl(var(--input))", 44 | ring: "hsl(var(--ring))", 45 | chart: { 46 | "1": "hsl(var(--chart-1))", 47 | "2": "hsl(var(--chart-2))", 48 | "3": "hsl(var(--chart-3))", 49 | "4": "hsl(var(--chart-4))", 50 | "5": "hsl(var(--chart-5))", 51 | }, 52 | }, 53 | borderRadius: { 54 | lg: "var(--radius)", 55 | md: "calc(var(--radius) - 2px)", 56 | sm: "calc(var(--radius) - 4px)", 57 | }, 58 | keyframes: { 59 | "shimmer-slide": { 60 | to: { 61 | transform: "translate(calc(100cqw - 100%), 0)", 62 | }, 63 | }, 64 | "spin-around": { 65 | "0%": { 66 | transform: "translateZ(0) rotate(0)", 67 | }, 68 | "15%, 35%": { 69 | transform: "translateZ(0) rotate(90deg)", 70 | }, 71 | "65%, 85%": { 72 | transform: "translateZ(0) rotate(270deg)", 73 | }, 74 | "100%": { 75 | transform: "translateZ(0) rotate(360deg)", 76 | }, 77 | }, 78 | }, 79 | animation: { 80 | "shimmer-slide": 81 | "shimmer-slide var(--speed) ease-in-out infinite alternate", 82 | "spin-around": "spin-around calc(var(--speed) * 2) infinite linear", 83 | }, 84 | }, 85 | }, 86 | plugins: [], 87 | } satisfies Config; 88 | -------------------------------------------------------------------------------- /backend/src/managers/RoomManager.ts: -------------------------------------------------------------------------------- 1 | import { User } from "../type"; 2 | 3 | let GLOBAL_ROOM_ID = 1; 4 | 5 | interface Room { 6 | user1: User, 7 | user2: User, 8 | } 9 | 10 | export class RoomManager { 11 | private rooms: Map; 12 | 13 | constructor() { 14 | this.rooms = new Map(); 15 | } 16 | 17 | // Return roomId so caller can store mappings 18 | createRoom(user1: User, user2: User) { 19 | const roomId = this.generate().toString(); 20 | this.rooms.set(roomId, { user1, user2 }); 21 | 22 | // Your original behavior: ask both to start offer (you may choose only one in future) 23 | user1.socket.emit("send-offer", { roomId }); 24 | user2.socket.emit("send-offer", { roomId }); 25 | 26 | return roomId; 27 | } 28 | 29 | onOffer(roomId: string, sdp: string, senderSocketid: string) { 30 | const room = this.rooms.get(roomId); 31 | if (!room) return; 32 | 33 | const receivingUser = room.user1.socket.id === senderSocketid ? room.user2 : room.user1; 34 | receivingUser?.socket.emit("offer", { sdp, roomId }); 35 | } 36 | 37 | onAnswer(roomId: string, sdp: string, senderSocketid: string) { 38 | const room = this.rooms.get(roomId); 39 | if (!room) return; 40 | 41 | const receivingUser = room.user1.socket.id === senderSocketid ? room.user2 : room.user1; 42 | receivingUser?.socket.emit("answer", { sdp, roomId }); 43 | } 44 | 45 | onIceCandidates(roomId: string, senderSocketid: string, candidate: any, type: "sender" | "receiver") { 46 | const room = this.rooms.get(roomId); 47 | if (!room) return; 48 | 49 | const receivingUser = room.user1.socket.id === senderSocketid ? room.user2 : room.user1; 50 | receivingUser.socket.emit("add-ice-candidate", ({candidate, type})); 51 | } 52 | 53 | // NEW: teardown helpers for robust leave/next flows 54 | teardownUser(roomId: string, userId: string) { 55 | const room = this.rooms.get(roomId); 56 | if (!room) { 57 | return; 58 | } 59 | 60 | const other = room.user1.socket.id === userId ? room.user2 : room.user1; 61 | // Notify other side that this room is done (front-end can stop peer connection) 62 | // Removed duplicate notification - handled in UserManager.handleLeave 63 | this.rooms.delete(roomId); 64 | } 65 | 66 | teardownRoom(roomId: string) { 67 | const room = this.rooms.get(roomId); 68 | if (!room) { 69 | return; 70 | } 71 | 72 | // Optionally notify both sides (guard if sockets are still connected) 73 | // Removed duplicate notifications - handled in UserManager.handleLeave 74 | this.rooms.delete(roomId); 75 | } 76 | 77 | generate() { 78 | return GLOBAL_ROOM_ID++; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /frontend/components/ui/shimmer-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { CSSProperties, ComponentPropsWithoutRef } from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | export interface ShimmerButtonProps extends ComponentPropsWithoutRef<"button"> { 8 | shimmerColor?: string; 9 | shimmerSize?: string; 10 | borderRadius?: string; 11 | shimmerDuration?: string; 12 | background?: string; 13 | className?: string; 14 | children?: React.ReactNode; 15 | } 16 | 17 | export const ShimmerButton = React.forwardRef< 18 | HTMLButtonElement, 19 | ShimmerButtonProps 20 | >( 21 | ( 22 | { 23 | shimmerColor = "#ffffff", 24 | shimmerSize = "0.05em", 25 | shimmerDuration = "3s", 26 | borderRadius = "100px", 27 | background = "rgba(0, 0, 0, 1)", 28 | className, 29 | children, 30 | ...props 31 | }, 32 | ref, 33 | ) => { 34 | return ( 35 | 94 | ); 95 | }, 96 | ); 97 | 98 | ShimmerButton.displayName = "ShimmerButton"; 99 | -------------------------------------------------------------------------------- /frontend/components/ui/magic-card.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { motion, useMotionTemplate, useMotionValue } from "motion/react"; 4 | import React, { useCallback, useEffect, useRef } from "react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | interface MagicCardProps { 9 | children?: React.ReactNode; 10 | className?: string; 11 | gradientSize?: number; 12 | gradientColor?: string; 13 | gradientOpacity?: number; 14 | gradientFrom?: string; 15 | gradientTo?: string; 16 | } 17 | 18 | export function MagicCard({ 19 | children, 20 | className, 21 | gradientSize = 200, 22 | gradientColor = "#262626", 23 | gradientOpacity = 0.8, 24 | gradientFrom = "#9E7AFF", 25 | gradientTo = "#FE8BBB", 26 | }: MagicCardProps) { 27 | const cardRef = useRef(null); 28 | const mouseX = useMotionValue(-gradientSize); 29 | const mouseY = useMotionValue(-gradientSize); 30 | 31 | const handleMouseMove = useCallback( 32 | (e: MouseEvent) => { 33 | if (cardRef.current) { 34 | const { left, top } = cardRef.current.getBoundingClientRect(); 35 | const clientX = e.clientX; 36 | const clientY = e.clientY; 37 | mouseX.set(clientX - left); 38 | mouseY.set(clientY - top); 39 | } 40 | }, 41 | [mouseX, mouseY], 42 | ); 43 | 44 | const handleMouseOut = useCallback( 45 | (e: MouseEvent) => { 46 | if (!e.relatedTarget) { 47 | document.removeEventListener("mousemove", handleMouseMove); 48 | mouseX.set(-gradientSize); 49 | mouseY.set(-gradientSize); 50 | } 51 | }, 52 | [handleMouseMove, mouseX, gradientSize, mouseY], 53 | ); 54 | 55 | const handleMouseEnter = useCallback(() => { 56 | document.addEventListener("mousemove", handleMouseMove); 57 | mouseX.set(-gradientSize); 58 | mouseY.set(-gradientSize); 59 | }, [handleMouseMove, mouseX, gradientSize, mouseY]); 60 | 61 | useEffect(() => { 62 | document.addEventListener("mousemove", handleMouseMove); 63 | document.addEventListener("mouseout", handleMouseOut); 64 | document.addEventListener("mouseenter", handleMouseEnter); 65 | 66 | return () => { 67 | document.removeEventListener("mousemove", handleMouseMove); 68 | document.removeEventListener("mouseout", handleMouseOut); 69 | document.removeEventListener("mouseenter", handleMouseEnter); 70 | }; 71 | }, [handleMouseEnter, handleMouseMove, handleMouseOut]); 72 | 73 | useEffect(() => { 74 | mouseX.set(-gradientSize); 75 | mouseY.set(-gradientSize); 76 | }, [gradientSize, mouseX, mouseY]); 77 | 78 | return ( 79 |
83 | 95 |
96 | 105 |
{children}
106 |
107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ![Helixque Contributing](assets/contributing_guidelines.png) 2 | 3 | Thank you for your interest in contributing to Helixque! This document provides guidelines for contributing to the project. 4 | 5 | --- 6 | 7 | > \[!IMPORTANT] 8 | > - If an issue is assigned to you, please complete it **before requesting another one**. 9 | > - A person can be assigned a **maximum of two issues** at a time to ensure fair distribution among all participants. 10 | 11 | 12 | ## ⭐ Before You Start 13 | 14 | 1. **Starring the repository is mandatory** before contributing. 15 | This helps the project grow and shows your support. 16 | 17 | 2. **Join our Discord server** for discussions, questions, and faster PR approvals: 18 | 👉 [Join the Discord Server](https://discord.gg/dQUh6SY9Uk) 19 | 20 | ⚠️ Please note: To quickly get your PRs reviewed and merged, you must be a member of our Discord server. 21 | 22 | 3. **Pull Request Policy on Discord** 23 | - Before raising a PR, **check the `#pull-request` channel** to ensure there isn’t already an open PR for the same issue. 24 | - If no one is working on it, go ahead and raise your PR and mention it in the channel. 25 | 26 | --- 27 | 28 | ### Keeping Your Branch Up-to-Date 29 | 30 | Please ensure your local branch is **synced with the latest `develop` branch** before making changes or opening a Pull Request (PR). 31 | This helps avoid unnecessary merge conflicts and ensures a smooth review process. 32 | 33 | ⚠️**Note**: 34 | - 🚀 Please raise PRs **only to the `develop` branch**. 35 | - 📷 For faster reviews and merges, include a **recording or screenshot** (depending on the issue) that demonstrates the fix. 36 | - 🏗️ Before opening a PR, run `npm run build` and ensure the project builds successfully **without errors**. 37 | - ✅ Only raise a PR once the build passes. 38 | - 🔗 When raising the PR, please **reference the issue number** it addresses. 39 | 40 | 41 | ### Merge Conflicts 42 | 43 | - Project maintainers are **not responsible** if your PR is blocked due to merge conflicts. 44 | - It is **your responsibility** to update your branch and resolve conflicts before requesting a merge. 45 | 46 | --- 47 | 48 | ## Getting Started 49 | 50 | 1. Fork the repository 51 | 2. Clone your fork locally 52 | 3. Follow the setup instructions in the main README 53 | 4. Create a new branch for your feature/fix 54 | 55 | ## Development Setup 56 | 57 | 1. **Install dependencies for both frontend and backend**: 58 | ```bash 59 | # Backend 60 | cd backend && npm install 61 | 62 | # Frontend 63 | cd ../frontend && npm install 64 | ``` 65 | 66 | 2. **Start development servers**: 67 | ```bash 68 | # Terminal 1 - Backend 69 | cd backend && npm run dev 70 | 71 | # Terminal 2 - Frontend 72 | cd frontend && npm run dev 73 | ``` 74 | 75 | ## Code Style 76 | 77 | - Follow existing TypeScript and React patterns 78 | - Use meaningful variable and function names 79 | - Add comments for complex logic 80 | - Ensure code is properly formatted 81 | 82 | ## Testing Your Changes 83 | 84 | 1. Test both frontend and backend functionality 85 | 2. Verify WebRTC connections work properly 86 | 3. Test edge cases like disconnections and reconnections 87 | 4. Ensure responsive design works on different screen sizes 88 | 89 | ## Submitting Changes 90 | 91 | 1. Commit your changes with clear, descriptive messages 92 | 2. Push to your fork 93 | 3. Create a Pull Request with: 94 | - Clear description of changes 95 | - Screenshots/videos for UI changes 96 | - Testing notes 97 | 98 | ## Areas for Contribution 99 | 100 | - UI/UX improvements 101 | - Performance optimizations 102 | - Additional WebRTC features 103 | - Mobile responsiveness 104 | - Accessibility improvements 105 | - Documentation updates 106 | - Bug fixes 107 | 108 | ## Questions? 109 | 110 | Feel free to open an issue for questions or discussion about potential contributions. 111 | -------------------------------------------------------------------------------- /backend/src/match/Matchmaker.ts: -------------------------------------------------------------------------------- 1 | import type { Redis } from "ioredis"; 2 | 3 | type UserMeta = { 4 | id: string; // socket.id 5 | language?: string; 6 | industry?: string; 7 | skillBucket?: string; 8 | }; 9 | 10 | export class Matchmaker { 11 | constructor(private redis: Redis) {} 12 | 13 | private shardKey(meta: UserMeta) { 14 | const language = meta.language || 'any'; 15 | const industry = meta.industry || 'any'; 16 | const skillBucket = meta.skillBucket || 'any'; 17 | return `Q:${language}:${industry}:${skillBucket}`; 18 | } 19 | private langKey(lang: string) { return `QL:${lang}`; } 20 | private indKey(ind: string) { return `QI:${ind}`; } 21 | private globalKey() { return `QG`; } 22 | 23 | // Presence, partner, bans 24 | private onlineKey() { return `online`; } // HASH socketId -> "1" 25 | private partnerOfKey() { return `partnerOf`; } // HASH socketId -> partnerId 26 | private roomOfKey() { return `roomOf`; } // HASH socketId -> roomId 27 | private banKey(id: string) { return `ban:${id}`; } // SET of banned partner ids 28 | 29 | async setOnline(id: string) { 30 | await this.redis.hset(this.onlineKey(), id, "1"); 31 | } 32 | async setOffline(id: string) { 33 | await this.redis.hdel(this.onlineKey(), id); 34 | } 35 | async isOnline(id: string) { 36 | return (await this.redis.hexists(this.onlineKey(), id)) === 1; 37 | } 38 | 39 | async setPartners(a: string, b: string) { 40 | await this.redis.hset(this.partnerOfKey(), a, b); 41 | await this.redis.hset(this.partnerOfKey(), b, a); 42 | } 43 | async getPartner(id: string) { 44 | return this.redis.hget(this.partnerOfKey(), id); 45 | } 46 | async clearPartners(a: string, b?: string) { 47 | await this.redis.hdel(this.partnerOfKey(), a); 48 | if (b) await this.redis.hdel(this.partnerOfKey(), b); 49 | } 50 | 51 | async setRoom(id: string, roomId: string) { 52 | await this.redis.hset(this.roomOfKey(), id, roomId); 53 | } 54 | async getRoom(id: string) { 55 | return this.redis.hget(this.roomOfKey(), id); 56 | } 57 | async clearRoom(a: string, b?: string) { 58 | await this.redis.hdel(this.roomOfKey(), a); 59 | if (b) await this.redis.hdel(this.roomOfKey(), b); 60 | } 61 | 62 | async banEachOther(a: string, b: string) { 63 | await this.redis.sadd(this.banKey(a), b); 64 | await this.redis.sadd(this.banKey(b), a); 65 | } 66 | private async isBanned(a: string, b: string) { 67 | const result = await this.redis 68 | .multi() 69 | .sismember(this.banKey(a), b) 70 | .sismember(this.banKey(b), a) 71 | .exec(); 72 | const [ab, ba] = result ?? [[null, 0], [null, 0]]; 73 | const abv = Number(ab?.[1] ?? 0), bav = Number(ba?.[1] ?? 0); 74 | return abv === 1 || bav === 1; 75 | } 76 | 77 | // Enqueue user; attempt fast match with bounded fallbacks 78 | async enqueue(meta: UserMeta): Promise { 79 | const primary = this.shardKey(meta); 80 | const fallbacks: string[] = [ 81 | primary, 82 | ...(meta.language ? [this.langKey(meta.language)] : []), 83 | ...(meta.industry ? [this.indKey(meta.industry)] : []), 84 | this.globalKey(), 85 | ]; 86 | 87 | // Try to find a partner: bounded probes, lazy-skip offline/banned 88 | for (const key of fallbacks) { 89 | // Pop until we either match or the queue yields nothing viable 90 | while (true) { 91 | const candidate = await this.redis.rpop(key); 92 | if (!candidate) break; 93 | const online = await this.isOnline(candidate); 94 | if (!online) continue; // lazy skip 95 | const banned = await this.isBanned(meta.id, candidate); 96 | if (banned) continue; 97 | // Found a partner 98 | return candidate; 99 | } 100 | } 101 | 102 | // No partner found; push to queues (primary + light secondary) 103 | await this.redis.lpush(primary, meta.id); 104 | if (meta.language) { 105 | await this.redis.lpush(this.langKey(meta.language), meta.id); 106 | } 107 | if (meta.industry) { 108 | await this.redis.lpush(this.indKey(meta.industry), meta.id); 109 | } 110 | await this.redis.lpush(this.globalKey(), meta.id); 111 | return null; 112 | } 113 | 114 | async requeue(id: string, meta: UserMeta) { 115 | // Simple requeue to primary for immediate rematch attempt by caller 116 | await this.redis.lpush(this.shardKey(meta), id); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /backend/src/chat/chat.ts: -------------------------------------------------------------------------------- 1 | // server/chat.ts 2 | import type { Server, Socket } from "socket.io"; 3 | 4 | // --- Simple in-memory per-room history --- 5 | type HistItem = { 6 | text: string; 7 | from: string; 8 | clientId: string; 9 | ts: number; 10 | kind?: "user" | "system"; 11 | }; 12 | 13 | const MAX_HISTORY = 300; 14 | const roomHistories = new Map(); 15 | 16 | function pushRoomHistory(room: string, item: HistItem) { 17 | const arr = roomHistories.get(room) || []; 18 | arr.push(item); 19 | if (arr.length > MAX_HISTORY) { 20 | arr.splice(0, arr.length - MAX_HISTORY); 21 | } 22 | roomHistories.set(room, arr); 23 | } 24 | 25 | import type { 26 | SocketData, 27 | ChatJoinPayload, 28 | ChatMessagePayload, 29 | ChatTypingPayload, 30 | ChatLeavePayload, 31 | } from "../type"; 32 | export async function joinChatRoom(socket: Socket, roomId: string, name: string) { 33 | if (!roomId) return; 34 | const room = `chat:${roomId}`; 35 | 36 | // per-socket data bag 37 | const data = (socket.data as SocketData) || ((socket as Socket & { data: SocketData }).data = {}); 38 | data.chatNames = data.chatNames || {}; 39 | data.chatNames[room] = name; 40 | // guard against duplicate rapid joins 41 | (data as any).chatJoining = (data as any).chatJoining || {}; 42 | (data as any).chatJoinedOnce = (data as any).chatJoinedOnce || {}; 43 | if ((data as any).chatJoining[room]) return; // join in-flight, ignore 44 | (data as any).chatJoining[room] = true; 45 | 46 | try { 47 | const alreadyInRoom = socket.rooms.has(room); 48 | await socket.join(room); 49 | 50 | // only announce join once per socket per room 51 | if (!alreadyInRoom) { 52 | // broadcast a single generic join notice to the entire room (including self) 53 | const sys = { text: `peer joined the chat`, ts: Date.now() }; 54 | socket.nsp.in(room).emit("chat:system", sys); 55 | // Do not store join events in history to avoid duplicating on fetch 56 | } 57 | } finally { 58 | (data as any).chatJoining[room] = false; 59 | } 60 | 61 | // After successful join, send recent history for this room (messages + leave events) 62 | const history = roomHistories.get(room) || []; 63 | socket.emit("chat:history", { roomId, messages: history }); 64 | } 65 | 66 | export function wireChat(io: Server, socket: Socket) { 67 | // Allows explicit joins (reconnects/late-joins) 68 | socket.on("chat:join", async ({ roomId, name }: ChatJoinPayload) => { 69 | await joinChatRoom(socket, roomId, name || "A user"); 70 | }); 71 | 72 | // Broadcast a message to everyone in the chat room 73 | socket.on("chat:message", (payload: ChatMessagePayload) => { 74 | const { roomId, text, from, clientId, ts } = payload || {}; 75 | const safeText = (text || "").toString().trim().slice(0, 1000); 76 | if (!roomId || !safeText) return; 77 | 78 | const final = { 79 | text: safeText, 80 | from, 81 | clientId, 82 | ts: ts || Date.now(), 83 | }; 84 | socket.nsp.in(`chat:${roomId}`).emit("chat:message", final); 85 | pushRoomHistory(`chat:${roomId}`, { ...final, kind: "user" }); 86 | }); 87 | 88 | // Typing indicator to peers (not echoed to sender) 89 | socket.on("chat:typing", ({ roomId, from, typing }: ChatTypingPayload) => { 90 | if (!roomId) return; 91 | socket.to(`chat:${roomId}`).emit("chat:typing", { from, typing }); 92 | }); 93 | 94 | // Explicit leave (e.g., navigating away or switching rooms) 95 | socket.on("chat:leave", ({ roomId, name }: ChatLeavePayload) => { 96 | if (!roomId) return; 97 | const room = `chat:${roomId}`; 98 | if (socket.rooms.has(room)) { 99 | // emit to room BEFORE leaving so the leaver also gets the message once 100 | const msg = { text: `peer left the chat`, ts: Date.now() }; 101 | socket.nsp.in(room).emit("chat:system", msg); 102 | pushRoomHistory(room, { text: msg.text, from: "system", clientId: "system", ts: msg.ts!, kind: "system" }); 103 | socket.leave(room); 104 | } 105 | const data = (socket.data as SocketData) || ((socket as Socket & { data: SocketData }).data = {}); 106 | // mark this room as explicitly left to prevent duplicate leave on disconnecting 107 | (data as any).chatLeftRooms = (data as any).chatLeftRooms || {}; 108 | (data as any).chatLeftRooms[room] = true; 109 | if (data.chatNames) delete data.chatNames[room]; 110 | }); 111 | 112 | // Announce leave on disconnect across all chat rooms this socket was part of 113 | socket.on("disconnecting", () => { 114 | const data = (socket.data as SocketData) || {}; 115 | for (const room of socket.rooms) { 116 | if (typeof room === "string" && room.startsWith("chat:")) { 117 | const alreadyLeft = (data as any).chatLeftRooms?.[room]; 118 | if (!alreadyLeft) { 119 | const sys = { text: `peer left the chat`, ts: Date.now() }; 120 | socket.nsp.in(room).emit("chat:system", sys); 121 | pushRoomHistory(room, { text: sys.text, from: "system", clientId: "system", ts: sys.ts!, kind: "system" }); 122 | } 123 | } 124 | } 125 | }); 126 | } -------------------------------------------------------------------------------- /frontend/components/RTC/ControlBar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | IconMicrophone, 5 | IconMicrophoneOff, 6 | IconVideo, 7 | IconVideoOff, 8 | IconPhoneOff, 9 | IconScreenShare, 10 | IconScreenShareOff, 11 | IconUserOff, 12 | IconRefresh, 13 | IconMessage, 14 | IconFlag, 15 | } from "@tabler/icons-react"; 16 | import { MediaState } from "./VideoGrid"; 17 | import Tooltip from "../ui/tooltip"; 18 | 19 | interface ControlBarProps { 20 | mediaState: MediaState; 21 | showChat: boolean; 22 | onToggleMic: () => void; 23 | onToggleCam: () => void; 24 | onToggleScreenShare: () => void; 25 | onToggleChat: () => void; 26 | onRecheck: () => void; 27 | onNext: () => void; 28 | onLeave: () => void; 29 | onReport: () => void; 30 | } 31 | 32 | export default function ControlBar({ 33 | mediaState, 34 | showChat, 35 | onToggleMic, 36 | onToggleCam, 37 | onToggleScreenShare, 38 | onToggleChat, 39 | onRecheck, 40 | onNext, 41 | onLeave, 42 | onReport 43 | }: ControlBarProps) { 44 | const { micOn, camOn, screenShareOn } = mediaState; 45 | 46 | return ( 47 |
48 |
49 | {/* Bottom controls */} 50 |
51 | 52 | 58 | 59 | 60 | 61 | 69 | 70 | 71 | 72 | 80 | 81 | 82 | 83 | 91 | 92 | 93 | 94 | 100 | 101 | 102 | 103 | 110 | 111 |
112 | 113 | {/* Right side controls */} 114 |
115 |
116 | 117 | 125 | 126 | 127 | 128 | 134 | 135 |
136 |
137 |
138 |
139 | ); 140 | } -------------------------------------------------------------------------------- /frontend/components/ui/animated-beam.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { motion } from "motion/react"; 4 | import { RefObject, useEffect, useId, useState } from "react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | export interface AnimatedBeamProps { 9 | className?: string; 10 | containerRef: RefObject; // Container ref 11 | fromRef: RefObject; 12 | toRef: RefObject; 13 | curvature?: number; 14 | reverse?: boolean; 15 | pathColor?: string; 16 | pathWidth?: number; 17 | pathOpacity?: number; 18 | gradientStartColor?: string; 19 | gradientStopColor?: string; 20 | delay?: number; 21 | duration?: number; 22 | startXOffset?: number; 23 | startYOffset?: number; 24 | endXOffset?: number; 25 | endYOffset?: number; 26 | } 27 | 28 | export const AnimatedBeam: React.FC = ({ 29 | className, 30 | containerRef, 31 | fromRef, 32 | toRef, 33 | curvature = 0, 34 | reverse = false, // Include the reverse prop 35 | duration = Math.random() * 3 + 4, 36 | delay = 0, 37 | pathColor = "gray", 38 | pathWidth = 2, 39 | pathOpacity = 0.2, 40 | gradientStartColor = "#ffaa40", 41 | gradientStopColor = "#9c40ff", 42 | startXOffset = 0, 43 | startYOffset = 0, 44 | endXOffset = 0, 45 | endYOffset = 0, 46 | }) => { 47 | const id = useId(); 48 | const [pathD, setPathD] = useState(""); 49 | const [svgDimensions, setSvgDimensions] = useState({ width: 0, height: 0 }); 50 | 51 | // Calculate the gradient coordinates based on the reverse prop 52 | const gradientCoordinates = reverse 53 | ? { 54 | x1: ["90%", "-10%"], 55 | x2: ["100%", "0%"], 56 | y1: ["0%", "0%"], 57 | y2: ["0%", "0%"], 58 | } 59 | : { 60 | x1: ["10%", "110%"], 61 | x2: ["0%", "100%"], 62 | y1: ["0%", "0%"], 63 | y2: ["0%", "0%"], 64 | }; 65 | 66 | useEffect(() => { 67 | const updatePath = () => { 68 | if (containerRef.current && fromRef.current && toRef.current) { 69 | const containerRect = containerRef.current.getBoundingClientRect(); 70 | const rectA = fromRef.current.getBoundingClientRect(); 71 | const rectB = toRef.current.getBoundingClientRect(); 72 | 73 | const svgWidth = containerRect.width; 74 | const svgHeight = containerRect.height; 75 | setSvgDimensions({ width: svgWidth, height: svgHeight }); 76 | 77 | const startX = 78 | rectA.left - containerRect.left + rectA.width / 2 + startXOffset; 79 | const startY = 80 | rectA.top - containerRect.top + rectA.height / 2 + startYOffset; 81 | const endX = 82 | rectB.left - containerRect.left + rectB.width / 2 + endXOffset; 83 | const endY = 84 | rectB.top - containerRect.top + rectB.height / 2 + endYOffset; 85 | 86 | const controlY = startY - curvature; 87 | const d = `M ${startX},${startY} Q ${ 88 | (startX + endX) / 2 89 | },${controlY} ${endX},${endY}`; 90 | setPathD(d); 91 | } 92 | }; 93 | 94 | // Initialize ResizeObserver 95 | const resizeObserver = new ResizeObserver((entries) => { 96 | // For all entries, recalculate the path 97 | for (let entry of entries) { 98 | updatePath(); 99 | } 100 | }); 101 | 102 | // Observe the container element 103 | if (containerRef.current) { 104 | resizeObserver.observe(containerRef.current); 105 | } 106 | 107 | // Call the updatePath initially to set the initial path 108 | updatePath(); 109 | 110 | // Clean up the observer on component unmount 111 | return () => { 112 | resizeObserver.disconnect(); 113 | }; 114 | }, [ 115 | containerRef, 116 | fromRef, 117 | toRef, 118 | curvature, 119 | startXOffset, 120 | startYOffset, 121 | endXOffset, 122 | endYOffset, 123 | ]); 124 | 125 | return ( 126 | 137 | 144 | 151 | 152 | 176 | 177 | 178 | 179 | 184 | 185 | 186 | 187 | ); 188 | }; 189 | -------------------------------------------------------------------------------- /backend/src/index.ts: -------------------------------------------------------------------------------- 1 | import http from "http"; 2 | import express from "express"; 3 | import { Server, Socket } from "socket.io"; 4 | 5 | import { UserManager } from "./managers/UserManger"; // corrected spelling 6 | // import { pubClient, subClient } from "./cache/redis"; 7 | // import { presenceUp, presenceHeartbeat, presenceDown, countOnline } from "./cache/presence"; 8 | // import { createAdapter } from "@socket.io/redis-adapter"; 9 | 10 | import { wireChat /*, joinChatRoom */ } from "./chat/chat"; // keep wiring util 11 | 12 | import type { HandshakeAuth, HandshakeQuery, ChatJoinPayload } from "./type"; 13 | 14 | const app = express(); 15 | const server = http.createServer(app); 16 | 17 | const io = new Server(server, { cors: { origin: "*" } }); 18 | // io.adapter(createAdapter(pubClient, subClient)); 19 | 20 | const userManager = new UserManager(); 21 | 22 | // Set the io instance for UserManager after creation 23 | userManager.setIo(io); 24 | 25 | // Health endpoint 26 | app.get("/healthz", async (_req, res) => { 27 | try { 28 | // const online = await countOnline().catch(() => -1); 29 | // res.json({ ok: true, online }); 30 | res.json({ ok: true, online: -1 }); // fallback without Redis 31 | } catch { 32 | res.json({ ok: true, online: -1 }); 33 | } 34 | }); 35 | 36 | const HEARTBEAT_MS = Number(process.env.SOCKET_HEARTBEAT_MS || 30_000); 37 | const heartbeats = new Map(); 38 | 39 | io.on("connection", (socket: Socket) => { 40 | // console.log(`[io] connected ${socket.id}`); 41 | 42 | // Derive meta 43 | const meta = { 44 | name: (socket.handshake.auth as HandshakeAuth)?.name || "guest", 45 | ip: socket.handshake.address || null, 46 | ua: (socket.handshake.headers["user-agent"] as string) || null, 47 | }; 48 | 49 | // Presence (disabled Redis for now) 50 | // presenceUp(socket.id, meta).catch((e) => console.warn("[presenceUp]", e?.message)); 51 | 52 | const hb = setInterval(() => { 53 | // presenceHeartbeat(socket.id).catch((e) => console.warn("[presenceHeartbeat]", e?.message)); 54 | }, HEARTBEAT_MS); 55 | heartbeats.set(socket.id, hb); 56 | 57 | // Track user 58 | userManager.addUser(meta.name, socket, meta); 59 | 60 | // Hook up chat listeners (chat:join, chat:message, chat:typing) 61 | wireChat(io, socket); 62 | 63 | // Auto-join a chat room if the client provided it (supports auth or query) 64 | // Normalize to using `chat:` as the room namespace everywhere 65 | const roomFromAuth = (socket.handshake.auth as HandshakeAuth)?.roomId; 66 | const roomFromQuery = (socket.handshake.query as HandshakeQuery)?.roomId; 67 | const initialRoomRaw = (roomFromAuth || roomFromQuery || "").toString().trim(); 68 | const normalizeRoom = (r: string) => (r ? `chat:${r}` : ""); 69 | 70 | const initialRoomId = normalizeRoom(initialRoomRaw); 71 | // Do not auto-join here; let chat.ts handle joining and announcements to avoid duplicates 72 | if (initialRoomId) { 73 | userManager.setRoom(socket.id, initialRoomId); 74 | } 75 | 76 | // Keep UserManager in sync when client explicitly joins later 77 | socket.on("chat:join", ({ roomId }: ChatJoinPayload) => { 78 | try { 79 | if (!roomId || typeof roomId !== "string") return; 80 | const namespaced = normalizeRoom(roomId.trim()); 81 | // Keep UserManager in sync only; actual join + announcements are handled in chat.ts 82 | userManager.setRoom(socket.id, namespaced); 83 | } catch (err) { 84 | console.warn("[chat:join] error", err); 85 | } 86 | }); 87 | 88 | // Screen share + media + renegotiation handlers (same behavior, use namespaced rooms) 89 | const toRoom = (roomId?: string) => (roomId ? `chat:${roomId}` : undefined); 90 | 91 | socket.on("screen:state", ({ roomId, on }: { roomId: string; on: boolean }) => { 92 | const r = toRoom(roomId); 93 | if (r) socket.to(r).emit("screen:state", { on, from: socket.id }); 94 | }); 95 | 96 | socket.on("screenshare:offer", ({ roomId, sdp }) => { 97 | const r = toRoom(roomId); 98 | if (r) socket.to(r).emit("screenshare:offer", { sdp, from: socket.id }); 99 | }); 100 | 101 | socket.on("screenshare:answer", ({ roomId, sdp }) => { 102 | const r = toRoom(roomId); 103 | if (r) socket.to(r).emit("screenshare:answer", { sdp, from: socket.id }); 104 | }); 105 | 106 | socket.on("screenshare:ice-candidate", ({ roomId, candidate }) => { 107 | const r = toRoom(roomId); 108 | if (r) socket.to(r).emit("screenshare:ice-candidate", { candidate, from: socket.id }); 109 | }); 110 | 111 | socket.on("screenshare:track-start", ({ roomId }) => { 112 | const r = toRoom(roomId); 113 | if (r) socket.to(r).emit("screenshare:track-start", { from: socket.id }); 114 | }); 115 | 116 | socket.on("screenshare:track-stop", ({ roomId }) => { 117 | const r = toRoom(roomId); 118 | if (r) socket.to(r).emit("screenshare:track-stop", { from: socket.id }); 119 | }); 120 | 121 | // Media state 122 | socket.on("media:state", ({ roomId, state }: { roomId: string; state: { micOn?: boolean; camOn?: boolean } }) => { 123 | const r = toRoom(roomId); 124 | if (r) socket.to(r).emit("peer:media-state", { state, from: socket.id }); 125 | }); 126 | 127 | socket.on("media:cam", ({ roomId, on }: { roomId: string; on: boolean }) => { 128 | const r = toRoom(roomId); 129 | if (r) socket.to(r).emit("media:cam", { on, from: socket.id }); 130 | }); 131 | 132 | socket.on("media:mic", ({ roomId, on }: { roomId: string; on: boolean }) => { 133 | const r = toRoom(roomId); 134 | if (r) socket.to(r).emit("media:mic", { on, from: socket.id }); 135 | }); 136 | 137 | // Backwards-compat aliases 138 | socket.on("state:update", ({ roomId, micOn, camOn }) => { 139 | const r = toRoom(roomId); 140 | if (r) socket.to(r).emit("peer:state", { micOn, camOn, from: socket.id }); 141 | }); 142 | 143 | // Renegotiation passthrough 144 | socket.on("renegotiate-offer", ({ roomId, sdp, role }) => { 145 | const r = toRoom(roomId); 146 | if (r) socket.to(r).emit("renegotiate-offer", { sdp, role, from: socket.id }); 147 | }); 148 | 149 | socket.on("renegotiate-answer", ({ roomId, sdp, role }) => { 150 | const r = toRoom(roomId); 151 | if (r) socket.to(r).emit("renegotiate-answer", { sdp, role, from: socket.id }); 152 | }); 153 | 154 | socket.on("disconnect", (reason) => { 155 | // console.log(`[io] disconnected ${socket.id} (${reason})`); 156 | 157 | const hbRef = heartbeats.get(socket.id); 158 | if (hbRef) { 159 | clearInterval(hbRef); 160 | heartbeats.delete(socket.id); 161 | } 162 | 163 | // presenceDown(socket.id).catch((e) => console.warn("[presenceDown]", e?.message)); 164 | 165 | // chat.ts handles leave announcements in its disconnecting handler 166 | 167 | userManager.removeUser(socket.id); 168 | }); 169 | 170 | socket.on("error", (err) => console.warn(`[io] socket error ${socket.id}:`, err)); 171 | }); 172 | 173 | // --- Routes already defined above --- 174 | 175 | // 404 handler (must be AFTER routes) 176 | app.use((_req: express.Request, res: express.Response) => { 177 | res.status(404).send("Routes Not Found"); 178 | }); 179 | 180 | // Global error handler (must be LAST) 181 | app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { 182 | // Respect err.status and err.message if present 183 | const status = err?.status || 500; 184 | const message = err?.message || "Internal Server Error"; 185 | 186 | console.error("Unhandled error:", err?.stack || err); 187 | res.status(status).json({ message }); 188 | }); 189 | 190 | // Graceful shutdown 191 | const PORT = Number(process.env.PORT || 5001); 192 | server.listen(PORT, () => console.log(`listening on *:${PORT}`)); 193 | 194 | const shutdown = (signal: string) => { 195 | console.log(`Received ${signal}. Shutting down gracefully...`); 196 | server.close(() => { 197 | console.log("HTTP server closed."); 198 | // cleanup: clear all heartbeats 199 | heartbeats.forEach((hb) => clearInterval(hb)); 200 | process.exit(0); 201 | }); 202 | }; 203 | 204 | process.on("SIGINT", () => shutdown("SIGINT")); 205 | process.on("SIGTERM", () => shutdown("SIGTERM")); 206 | -------------------------------------------------------------------------------- /frontend/components/RTC/VideoGrid.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | IconUser, 5 | IconLoader2, 6 | IconMicrophoneOff, 7 | IconScreenShare 8 | } from "@tabler/icons-react"; 9 | 10 | interface MediaState { 11 | micOn: boolean; 12 | camOn: boolean; 13 | screenShareOn: boolean; 14 | } 15 | 16 | interface PeerState { 17 | peerMicOn: boolean; 18 | peerCamOn: boolean; 19 | peerScreenShareOn: boolean; 20 | } 21 | 22 | interface VideoGridProps { 23 | localVideoRef: React.RefObject; 24 | remoteVideoRef: React.RefObject; 25 | localScreenShareRef: React.RefObject; 26 | remoteScreenShareRef: React.RefObject; 27 | showChat: boolean; 28 | lobby: boolean; 29 | status: string; 30 | name: string; 31 | mediaState: MediaState; 32 | peerState: PeerState; 33 | } 34 | 35 | export default function VideoGrid({ 36 | localVideoRef, 37 | remoteVideoRef, 38 | localScreenShareRef, 39 | remoteScreenShareRef, 40 | showChat, 41 | lobby, 42 | status, 43 | name, 44 | mediaState, 45 | peerState 46 | }: VideoGridProps) { 47 | const { micOn, camOn, screenShareOn } = mediaState; 48 | const { peerMicOn, peerCamOn, peerScreenShareOn } = peerState; 49 | 50 | if (peerScreenShareOn || screenShareOn) { 51 | return ( 52 |
53 | {/* Top: Two small videos side by side */} 54 |
55 | {/* My Video */} 56 |
57 |
73 | 74 | {/* Peer Video */} 75 |
76 |
94 |
95 | 96 | {/* Center: Large Screen Share */} 97 |
98 | {screenShareOn && ( 99 |
122 |
123 | ); 124 | } 125 | 126 | return ( 127 |
132 | {/* Remote/Peer Video */} 133 |
136 |
139 |
180 |
181 | 182 | {/* Local/Your Video */} 183 |
186 |
189 |
216 |
217 |
218 | ); 219 | } 220 | 221 | export type { MediaState, PeerState }; -------------------------------------------------------------------------------- /frontend/components/RTC/DeviceCheck.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useEffect, useRef, useState } from "react"; 3 | import Room from "./Room"; 4 | import { toast } from "sonner"; 5 | import { 6 | IconMicrophone, 7 | IconMicrophoneOff, 8 | IconVideo, 9 | IconVideoOff, 10 | IconRefresh, 11 | IconUser 12 | } from "@tabler/icons-react"; 13 | import Tooltip from "../ui/tooltip"; 14 | 15 | export default function DeviceCheck() { 16 | const [name, setName] = useState(""); 17 | const [localAudioTrack, setLocalAudioTrack] = useState(null); 18 | const [localVideoTrack, setLocalVideoTrack] = useState(null); 19 | const [joined, setJoined] = useState(false); 20 | const [videoOn, setVideoOn] = useState(true); 21 | const [audioOn, setAudioOn] = useState(true); 22 | 23 | const videoRef = useRef(null); 24 | const localAudioTrackRef = useRef(null); 25 | const localVideoTrackRef = useRef(null); 26 | const getCamRef = useRef<() => Promise>(() => Promise.resolve()); 27 | 28 | const getCam = async () => { 29 | try { 30 | localAudioTrackRef.current?.stop(); 31 | localVideoTrackRef.current?.stop(); 32 | let videoTrack: MediaStreamTrack | null = null; 33 | let audioTrack: MediaStreamTrack | null = null; 34 | // request camera stream only if videoOn is true 35 | if (videoOn) { 36 | try { 37 | const videoStream = await navigator.mediaDevices.getUserMedia({ video: true }); 38 | videoTrack = videoStream.getVideoTracks()[0] || null; 39 | } catch (err) { 40 | console.warn("Camera access denied or unavailable:", err); 41 | toast.error("Camera Error", { description: "Could not access camera" }); 42 | } 43 | } 44 | // Request microphone stream only if audioOn is true 45 | if (audioOn) { 46 | try { 47 | const audioStream = await navigator.mediaDevices.getUserMedia({ audio: true }); 48 | audioTrack = audioStream.getAudioTracks()[0] || null; 49 | } catch (err) { 50 | console.warn("Microphone access denied or unavailable:", err); 51 | toast.error("Microphone Error", { description: "Could not access microphone" }); 52 | } 53 | } 54 | // Save tracks to refs & state 55 | localVideoTrackRef.current = videoTrack; 56 | localAudioTrackRef.current = audioTrack; 57 | setLocalVideoTrack(videoTrack); 58 | setLocalAudioTrack(audioTrack); 59 | // Attach video stream if available 60 | if (videoRef.current) { 61 | videoRef.current.srcObject = videoTrack ? new MediaStream([videoTrack]) : null; 62 | if (videoTrack) await videoRef.current.play().catch(() => {}); 63 | } 64 | // Clear stream if both are off 65 | if (!videoOn && !audioOn && videoRef.current) { 66 | videoRef.current.srcObject = null; 67 | } 68 | 69 | } catch (e: any) { 70 | const errorMessage = e?.message || "Could not access camera/microphone"; 71 | toast.error("Device Access Error", { 72 | description: errorMessage 73 | }); 74 | } 75 | }; 76 | useEffect(() => { 77 | let permissionStatus: PermissionStatus | null = null; 78 | async function watchCameraPermission() { 79 | try { 80 | permissionStatus = await navigator.permissions.query({ name: "camera" as PermissionName }); 81 | permissionStatus.onchange = () => { 82 | if (permissionStatus?.state === "granted") { 83 | getCamRef.current(); 84 | } 85 | }; 86 | } catch (e) { 87 | console.warn("Permissions API not supported on this browser."); 88 | } 89 | } 90 | watchCameraPermission(); 91 | return () => { 92 | if (permissionStatus) permissionStatus.onchange = null; 93 | localAudioTrackRef.current?.stop(); 94 | localVideoTrackRef.current?.stop(); 95 | }; 96 | }, []); 97 | useEffect(() => { 98 | getCam(); 99 | }, [videoOn, audioOn]); 100 | useEffect(() => { 101 | getCamRef.current = getCam; 102 | }); 103 | if (joined) { 104 | const handleOnLeave = () => { 105 | setJoined(false); 106 | try { 107 | localAudioTrack?.stop(); 108 | } catch {} 109 | try { 110 | localVideoTrack?.stop(); 111 | } catch {} 112 | setLocalAudioTrack(null); 113 | setLocalVideoTrack(null); 114 | }; 115 | 116 | return ( 117 | 125 | ); 126 | } 127 | 128 | return ( 129 |
130 | {/* Main centered container */} 131 |
132 | {/* Header */} 133 |
134 |

Ready to connect?

135 |

Check your camera and microphone before joining

136 |
137 | 138 | {/* Main content grid */} 139 |
140 | 141 | {/* Left Side - Video Preview */} 142 |
143 | {/* Video preview container - rounded */} 144 |
145 |
146 | {videoOn ? ( 147 |
173 |
174 | 175 | {/* Control buttons below video */} 176 |
177 | 178 | 186 | 187 | 188 | 189 | 197 | 198 | 199 | 200 | 206 | 207 |
208 |
209 | 210 | {/* Right Side - Join Form */} 211 |
212 |
213 |
214 |
215 |

Join the conversation

216 | 217 |
218 | 221 | setName(e.target.value)} 225 | placeholder="Enter your name" 226 | className="w-full h-12 px-4 rounded-xl border border-white/10 bg-neutral-800/50 text-white placeholder-neutral-500 focus:border-white/30 focus:outline-none transition-colors backdrop-blur" 227 | /> 228 |
229 | 236 | 237 |

238 | By joining, you agree to our terms of service and privacy policy 239 |

240 |
241 |
242 |
243 |
244 |
245 |
246 |
247 | ); 248 | } 249 | -------------------------------------------------------------------------------- /frontend/components/RTC/webrtc-utils.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { toast } from "sonner"; 4 | 5 | // WebRTC Utility Functions 6 | export function ensureRemoteStream( 7 | remoteStreamRef: React.RefObject, 8 | remoteVideoRef: React.RefObject, 9 | remoteAudioRef: React.RefObject, 10 | remoteScreenShareRef: React.RefObject, 11 | peerScreenShareOn: boolean 12 | ) { 13 | // console.log("🔄 ensureRemoteStream called"); 14 | 15 | if (!remoteStreamRef.current) { 16 | // console.log("📺 Creating new remote MediaStream"); 17 | remoteStreamRef.current = new MediaStream(); 18 | } 19 | 20 | const v = remoteVideoRef.current; 21 | if (v) { 22 | // console.log("🎥 Remote video element found"); 23 | if (v.srcObject !== remoteStreamRef.current) { 24 | // console.log("🔗 Setting remote video srcObject"); 25 | v.srcObject = remoteStreamRef.current; 26 | v.playsInline = true; 27 | v.play().catch((err) => { 28 | // console.error("❌ Error playing remote video:", err); 29 | }); 30 | } 31 | } 32 | 33 | const screenShareVideo = remoteScreenShareRef.current; 34 | if (screenShareVideo && peerScreenShareOn) { 35 | if (screenShareVideo.srcObject !== remoteStreamRef.current) { 36 | // console.log("🖥️ Setting remote screen share video srcObject"); 37 | screenShareVideo.srcObject = remoteStreamRef.current; 38 | screenShareVideo.playsInline = true; 39 | screenShareVideo.play().catch((err) => { 40 | // console.error("❌ Error playing remote screen share video:", err); 41 | }); 42 | } 43 | } 44 | 45 | const a = remoteAudioRef.current; 46 | if (a) { 47 | if (a.srcObject !== remoteStreamRef.current) { 48 | // console.log("🔊 Setting remote audio srcObject"); 49 | a.srcObject = remoteStreamRef.current; 50 | a.autoplay = true; 51 | a.muted = false; 52 | a.play().catch((err) => { 53 | // console.error("❌ Error playing remote audio:", err); 54 | }); 55 | } 56 | } 57 | } 58 | 59 | export function detachLocalPreview(localVideoRef: React.RefObject) { 60 | try { 61 | const localStream = localVideoRef.current?.srcObject as MediaStream | null; 62 | if (localStream) { 63 | localStream.getTracks().forEach((t) => { 64 | try { 65 | // console.log(`Stopping track of kind ${t.kind}`); 66 | t.stop(); 67 | } catch (err) { 68 | // console.error(`Error stopping ${t.kind} track:`, err); 69 | } 70 | }); 71 | } 72 | } catch (err) { 73 | // console.error("Error in detachLocalPreview:", err); 74 | } 75 | 76 | if (localVideoRef.current) { 77 | try { 78 | localVideoRef.current.pause(); 79 | } catch {} 80 | localVideoRef.current.srcObject = null; 81 | } 82 | } 83 | 84 | export function stopProvidedTracks( 85 | localVideoTrack: MediaStreamTrack | null, 86 | localAudioTrack: MediaStreamTrack | null, 87 | currentVideoTrackRef: React.RefObject 88 | ) { 89 | try { 90 | if (localVideoTrack) { 91 | localVideoTrack.stop(); 92 | // console.log("Local video track stopped"); 93 | } 94 | } catch (err) { 95 | // console.error("Error stopping local video track:", err); 96 | } 97 | 98 | try { 99 | if (localAudioTrack) { 100 | localAudioTrack.stop(); 101 | } 102 | } catch (err) { 103 | // console.error("Error stopping local audio track:", err); 104 | } 105 | 106 | try { 107 | const currentTrack = currentVideoTrackRef.current; 108 | if (currentTrack) { 109 | currentTrack.stop(); 110 | currentVideoTrackRef.current = null; 111 | // console.log("Current video track stopped"); 112 | } 113 | } catch (err) { 114 | // console.error("Error stopping current video track:", err); 115 | } 116 | } 117 | 118 | export function teardownPeers( 119 | reason: string, 120 | sendingPcRef: React.RefObject, 121 | receivingPcRef: React.RefObject, 122 | remoteStreamRef: React.RefObject, 123 | remoteVideoRef: React.RefObject, 124 | remoteAudioRef: React.RefObject, 125 | videoSenderRef: React.RefObject, 126 | localScreenShareStreamRef: React.RefObject, 127 | currentScreenShareTrackRef: React.RefObject, 128 | localScreenShareRef: React.RefObject, 129 | setters: { 130 | setShowChat: (value: boolean) => void; 131 | setPeerMicOn: (value: boolean) => void; 132 | setPeerCamOn: (value: boolean) => void; 133 | setScreenShareOn: (value: boolean) => void; 134 | setPeerScreenShareOn: (value: boolean) => void; 135 | setLobby: (value: boolean) => void; 136 | setStatus: (value: string) => void; 137 | } 138 | ) { 139 | // console.log("Tearing down peers, reason:", reason); 140 | 141 | // Clean up peer connections 142 | try { 143 | if (sendingPcRef.current) { 144 | try { 145 | sendingPcRef.current.getSenders().forEach((sn) => { 146 | try { 147 | sendingPcRef.current?.removeTrack(sn); 148 | } catch (err) { 149 | // console.error("Error removing sender track:", err); 150 | } 151 | }); 152 | } catch {} 153 | sendingPcRef.current.close(); 154 | } 155 | if (receivingPcRef.current) { 156 | try { 157 | receivingPcRef.current.getSenders().forEach((sn) => { 158 | try { 159 | receivingPcRef.current?.removeTrack(sn); 160 | } catch (err) { 161 | // console.error("Error removing receiver track:", err); 162 | } 163 | }); 164 | } catch {} 165 | receivingPcRef.current.close(); 166 | } 167 | } catch (err) { 168 | // console.error("Error in peer connection cleanup:", err); 169 | } 170 | 171 | sendingPcRef.current = null; 172 | receivingPcRef.current = null; 173 | 174 | // Clean up remote stream 175 | if (remoteStreamRef.current) { 176 | try { 177 | const tracks = remoteStreamRef.current.getTracks(); 178 | // console.log(`Stopping ${tracks.length} remote tracks`); 179 | tracks.forEach((t) => { 180 | try { 181 | t.stop(); 182 | } catch (err) { 183 | // console.error(`Error stopping remote ${t.kind} track:`, err); 184 | } 185 | }); 186 | } catch (err) { 187 | // console.error("Error stopping remote tracks:", err); 188 | } 189 | } 190 | 191 | remoteStreamRef.current = new MediaStream(); 192 | 193 | // Clear video elements 194 | if (remoteVideoRef.current) { 195 | remoteVideoRef.current.srcObject = null; 196 | try { 197 | remoteVideoRef.current.load(); 198 | } catch {} 199 | } 200 | if (remoteAudioRef.current) { 201 | remoteAudioRef.current.srcObject = null; 202 | try { 203 | remoteAudioRef.current.load(); 204 | } catch {} 205 | } 206 | 207 | // Reset UI states 208 | setters.setShowChat(false); 209 | setters.setPeerMicOn(true); 210 | setters.setPeerCamOn(true); 211 | setters.setScreenShareOn(false); 212 | setters.setPeerScreenShareOn(false); 213 | 214 | videoSenderRef.current = null; 215 | 216 | // Clean up screenshare streams 217 | if (localScreenShareStreamRef.current) { 218 | localScreenShareStreamRef.current.getTracks().forEach((t: MediaStreamTrack) => t.stop()); 219 | localScreenShareStreamRef.current = null; 220 | } 221 | if (currentScreenShareTrackRef.current) { 222 | currentScreenShareTrackRef.current.stop(); 223 | currentScreenShareTrackRef.current = null; 224 | } 225 | 226 | if (localScreenShareRef.current) localScreenShareRef.current.srcObject = null; 227 | 228 | setters.setLobby(true); 229 | if (reason === "partner-left") { 230 | setters.setStatus("Partner left. Finding a new match…"); 231 | } else if (reason === "next") { 232 | setters.setStatus("Searching for your next match…"); 233 | } else { 234 | setters.setStatus("Waiting to connect you to someone…"); 235 | } 236 | } 237 | 238 | export async function toggleCameraTrack( 239 | camOn: boolean, 240 | setCamOn: (value: boolean) => void, 241 | currentVideoTrackRef: React.RefObject, 242 | localVideoRef: React.RefObject, 243 | videoSenderRef: React.RefObject, 244 | sendingPcRef: React.RefObject, 245 | receivingPcRef: React.RefObject, 246 | roomId: string | null, 247 | socketRef: React.RefObject, 248 | localVideoTrack: MediaStreamTrack | null 249 | ) { 250 | const turningOn = !camOn; 251 | setCamOn(turningOn); 252 | 253 | try { 254 | const pc = sendingPcRef.current || receivingPcRef.current; 255 | 256 | if (turningOn) { 257 | let track = currentVideoTrackRef.current; 258 | if (!track || track.readyState === "ended") { 259 | const stream = await navigator.mediaDevices.getUserMedia({ video: true }); 260 | track = stream.getVideoTracks()[0]; 261 | currentVideoTrackRef.current = track; 262 | } 263 | 264 | if (localVideoRef.current) { 265 | const ms = (localVideoRef.current.srcObject as MediaStream) || new MediaStream(); 266 | if (!localVideoRef.current.srcObject) localVideoRef.current.srcObject = ms; 267 | ms.getVideoTracks().forEach((t) => ms.removeTrack(t)); 268 | ms.addTrack(track); 269 | await localVideoRef.current.play().catch(() => {}); 270 | } 271 | 272 | if (videoSenderRef.current) { 273 | await videoSenderRef.current.replaceTrack(track); 274 | } else if (pc) { 275 | const sender = pc.addTrack(track); 276 | videoSenderRef.current = sender; 277 | // console.log("Added new video track to existing connection"); 278 | 279 | if (sendingPcRef.current === pc) { 280 | const offer = await pc.createOffer(); 281 | await pc.setLocalDescription(offer); 282 | socketRef.current?.emit("renegotiate-offer", { 283 | roomId, 284 | sdp: offer, 285 | role: "caller" 286 | }); 287 | // console.log("📤 Sent renegotiation offer for camera turn on"); 288 | } 289 | } 290 | } else { 291 | if (videoSenderRef.current) { 292 | await videoSenderRef.current.replaceTrack(null); 293 | } 294 | 295 | const track = currentVideoTrackRef.current; 296 | if (track) { 297 | try { 298 | track.stop(); 299 | // console.log("Camera track stopped"); 300 | } catch (err) { 301 | // console.error("Error stopping camera track:", err); 302 | } 303 | currentVideoTrackRef.current = null; 304 | } 305 | 306 | if (localVideoRef.current && localVideoRef.current.srcObject) { 307 | const ms = localVideoRef.current.srcObject as MediaStream; 308 | const videoTracks = ms.getVideoTracks(); 309 | for (const t of videoTracks) { 310 | try { 311 | t.stop(); 312 | ms.removeTrack(t); 313 | } catch (err) { 314 | // console.error("Error stopping local preview track:", err); 315 | } 316 | } 317 | } 318 | 319 | if (localVideoTrack) { 320 | try { 321 | localVideoTrack.stop(); 322 | } catch {} 323 | } 324 | } 325 | } catch (e: any) { 326 | // console.error("toggleCam error", e); 327 | toast.error("Camera Error", { 328 | description: e?.message || "Failed to toggle camera" 329 | }); 330 | } 331 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity granting the License. 13 | 14 | "Legal Entity" shall mean the union of the acting entity and all 15 | other entities that control, are controlled by, or are under common 16 | control with that entity. For the purposes of this definition, 17 | "control" means (i) the power, direct or indirect, to cause the 18 | direction or management of such entity, whether by contract or 19 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 20 | outstanding shares, or (iii) beneficial ownership of such entity. 21 | 22 | "You" (or "Your") shall mean an individual or Legal Entity 23 | exercising permissions granted by this License. 24 | 25 | "Source" form shall mean the preferred form for making modifications, 26 | including but not limited to software source code, documentation 27 | source, and configuration files. 28 | 29 | "Object" form shall mean any form resulting from mechanical 30 | transformation or translation of a Source form, including but 31 | not limited to compiled object code, generated documentation, 32 | and conversions to other media types. 33 | 34 | "Work" shall mean the work of authorship, whether in Source or 35 | Object form, made available under the License, as indicated by a 36 | copyright notice that is included in or attached to the work 37 | (which shall not include communications that are conspicuously 38 | marked or otherwise designated in writing by the copyright owner 39 | as "Not a Contribution"). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based upon (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and derivative works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control 58 | systems, and issue tracking systems that are managed by, or on behalf 59 | of, the Licensor for the purpose of discussing and improving the Work, 60 | but excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution". 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to use, reproduce, modify, merge, publish, 71 | distribute, sublicense, and/or sell copies of the Work, and to 72 | permit persons to whom the Work is furnished to do so, subject to 73 | the following conditions: 74 | 75 | The above copyright notice and this permission notice shall be 76 | included in all copies or substantial portions of the Work. 77 | 78 | 3. Grant of Patent License. Subject to the terms and conditions of 79 | this License, each Contributor hereby grants to You a perpetual, 80 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 81 | (except as stated in this section) patent license to make, have made, 82 | use, offer to sell, sell, import, and otherwise transfer the Work, 83 | where such license applies only to those patent claims licensable 84 | by such Contributor that are necessarily infringed by their 85 | Contribution(s) alone or by combination of their Contribution(s) 86 | with the Work to which such Contribution(s) was submitted. If You 87 | institute patent litigation against any entity (including a 88 | cross-claim or counterclaim in a lawsuit) alleging that the Work 89 | or a Contribution incorporated within the Work constitutes direct 90 | or contributory patent infringement, then any patent licenses 91 | granted to You under this License for that Work shall terminate 92 | as of the date such litigation is filed. 93 | 94 | 4. Redistribution. You may reproduce and distribute copies of the 95 | Work or Derivative Works thereof in any medium, with or without 96 | modifications, and in Source or Object form, provided that You 97 | meet the following conditions: 98 | 99 | (a) You must give any other recipients of the Work or 100 | Derivative Works a copy of this License; and 101 | 102 | (b) You must cause any modified files to carry prominent notices 103 | stating that You changed the files; and 104 | 105 | (c) You must retain, in the Source form of any Derivative Works 106 | that You distribute, all copyright, trademark, patent, 107 | attribution and other notices from the Source form of the Work, 108 | excluding those notices that do not pertain to any part of 109 | the Derivative Works; and 110 | 111 | (d) If the Work includes a "NOTICE" text file as part of its 112 | distribution, then any Derivative Works that You distribute must 113 | include a readable copy of the attribution notices contained 114 | within such NOTICE file, excluding those notices that do not 115 | pertain to any part of the Derivative Works, in at least one 116 | of the following places: within a NOTICE text file distributed 117 | as part of the Derivative Works; within the Source form or 118 | documentation, if provided along with the Derivative Works; or, 119 | within a display generated by the Derivative Works, if and 120 | wherever such third-party notices normally appear. The contents 121 | of the NOTICE file are for informational purposes only and 122 | do not modify the License. You may add Your own attribution 123 | notices within Derivative Works that You distribute, alongside 124 | or as an addendum to the NOTICE text from the Work, provided 125 | that such additional attribution notices cannot be construed 126 | as modifying the License. 127 | 128 | You may add Your own copyright notice to Your modifications and 129 | may provide additional or different license terms and conditions 130 | for use, reproduction, or distribution of Your modifications, or 131 | for any such Derivative Works as a whole, provided Your use, 132 | reproduction, and distribution of the Work otherwise complies with 133 | the conditions stated in this License. 134 | 135 | 5. Submission of Contributions. Unless You explicitly state otherwise, 136 | any Contribution intentionally submitted for inclusion in the Work 137 | by You to the Licensor shall be under the terms and conditions of 138 | this License, without any additional terms or conditions. 139 | Notwithstanding the above, nothing herein shall supersede or modify 140 | the terms of any separate license agreement you may have executed 141 | with Licensor regarding such Contributions. 142 | 143 | 6. Trademarks. This License does not grant permission to use the trade 144 | names, trademarks, service marks, or product names of the Licensor, 145 | except as required for reasonable and customary use in describing the 146 | origin of the Work and reproducing the content of the NOTICE file. 147 | 148 | 7. Disclaimer of Warranty. Unless required by applicable law or 149 | agreed to in writing, Licensor provides the Work (and each 150 | Contributor provides its Contributions) on an "AS IS" BASIS, 151 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 152 | implied, including, without limitation, any warranties or conditions 153 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 154 | PARTICULAR PURPOSE. You are solely responsible for determining the 155 | appropriateness of using or redistributing the Work and assume any 156 | risks associated with Your exercise of permissions under this License. 157 | 158 | 8. Limitation of Liability. In no event and under no legal theory, 159 | whether in tort (including negligence), contract, or otherwise, 160 | unless required by applicable law (such as deliberate and grossly 161 | negligent acts) or agreed to in writing, shall any Contributor be 162 | liable to You for damages, including any direct, indirect, special, 163 | incidental, or consequential damages of any character arising as a 164 | result of this License or out of the use or inability to use the 165 | Work (including but not limited to damages for loss of goodwill, 166 | work stoppage, computer failure or malfunction, or any and all 167 | other commercial damages or losses), even if such Contributor 168 | has been advised of the possibility of such damages. 169 | 170 | 9. Accepting Warranty or Additional Liability. When redistributing 171 | the Work or Derivative Works thereof, You may choose to offer, 172 | and charge a fee for, acceptance of support, warranty, indemnity, 173 | or other liability obligations and/or rights consistent with this 174 | License. However, in accepting such obligations, You may act only 175 | on Your own behalf and on Your sole responsibility, not on behalf 176 | of any other Contributor, and only if You agree to indemnify, 177 | defend, and hold each Contributor harmless for any liability 178 | incurred by, or claims asserted against, such Contributor by reason 179 | of your accepting any such warranty or additional liability. 180 | 181 | END OF TERMS AND CONDITIONS 182 | 183 | APPENDIX: How to apply the Apache License to your work. 184 | 185 | To apply the Apache License to your work, attach the following 186 | boilerplate notice, with the fields enclosed by brackets "[]" 187 | replaced with your own identifying information. (Don't include 188 | the brackets!) The text should be enclosed in the appropriate 189 | comment syntax for the file format. We also recommend that a 190 | file or class name and description of purpose be included on the 191 | same "printed page" as the copyright notice for easier 192 | identification within third-party archives. 193 | 194 | Copyright 2024 Helixque 195 | 196 | Licensed under the Apache License, Version 2.0 (the "License"); 197 | you may not use this file except in compliance with the License. 198 | You may obtain a copy of the License at 199 | 200 | http://www.apache.org/licenses/LICENSE-2.0 201 | 202 | Unless required by applicable law or agreed to in writing, software 203 | distributed under the License is distributed on an "AS IS" BASIS, 204 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 205 | See the License for the specific language governing permissions and 206 | limitations under the License. -------------------------------------------------------------------------------- /backend/src/managers/UserManger.ts: -------------------------------------------------------------------------------- 1 | import { Server, Socket } from "socket.io"; 2 | import { RoomManager } from "./RoomManager"; 3 | import { User } from "../type"; 4 | 5 | const QUEUE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes 6 | 7 | export class UserManager { 8 | private users: User[]; 9 | private queue: string[]; 10 | 11 | // track bans, partner links, online, and per-user room 12 | private bans: Map>; 13 | private partnerOf: Map; 14 | private online: Set; 15 | private roomOf: Map; // chat/match room id per user 16 | 17 | private queueEntryTime: Map; 18 | private timeoutIntervals: Map; 19 | 20 | private roomManager: RoomManager; 21 | private io: Server | null = null; 22 | 23 | constructor(io?: Server) { 24 | this.users = []; 25 | this.queue = []; 26 | this.roomManager = new RoomManager(); 27 | 28 | this.bans = new Map(); 29 | this.partnerOf = new Map(); 30 | this.online = new Set(); 31 | this.roomOf = new Map(); 32 | this.queueEntryTime = new Map(); 33 | this.timeoutIntervals = new Map(); 34 | 35 | if (io) { 36 | this.io = io; 37 | } 38 | } 39 | 40 | // Method to set the io instance after construction 41 | setIo(io: Server) { 42 | this.io = io; 43 | } 44 | 45 | // accepts optional meta; safe to call as addUser(name, socket) 46 | addUser(name: string, socket: Socket, meta?: Record) { 47 | this.users.push({ name, socket, meta, joinedAt: Date.now() }); 48 | this.online.add(socket.id); 49 | 50 | // join queue immediately (kept from your original flow) 51 | if (!this.queue.includes(socket.id)) { 52 | this.queue.push(socket.id); 53 | this.startQueueTimeout(socket.id); 54 | } 55 | 56 | socket.emit("lobby"); 57 | this.clearQueue(); // preserve your behavior 58 | 59 | this.initHandlers(socket); 60 | } 61 | 62 | removeUser(socketId: string) { 63 | // remove from list 64 | this.users = this.users.filter((x) => x.socket.id !== socketId); 65 | 66 | // remove from queue (fix) 67 | this.queue = this.queue.filter((x) => x !== socketId); 68 | 69 | // clean presence 70 | this.online.delete(socketId); 71 | 72 | // clean timeout tracking 73 | this.clearQueueTimeout(socketId); 74 | 75 | // if they were in a room/paired, handle like leave 76 | this.handleLeave(socketId, "explicit-remove"); 77 | } 78 | 79 | // ---------- PUBLIC HELPERS (used by index.ts / chat integration) ---------- 80 | 81 | /** Record current chat/match room for this user. Pass undefined to clear. */ 82 | setRoom(socketId: string, roomId?: string) { 83 | if (!roomId) this.roomOf.delete(socketId); 84 | else this.roomOf.set(socketId, roomId); 85 | } 86 | 87 | /** Get current room id (if any) for this user. */ 88 | getRoom(socketId: string): string | undefined { 89 | return this.roomOf.get(socketId); 90 | } 91 | 92 | /** Get user's display name quickly. */ 93 | getName(socketId: string): string | undefined { 94 | const u = this.users.find((x) => x.socket.id === socketId); 95 | return u?.name; 96 | } 97 | 98 | /** Return a shallow user object plus roomId (if set). */ 99 | getUser( 100 | socketId: string 101 | ): (User & { roomId?: string }) | undefined { 102 | const u = this.users.find((x) => x.socket.id === socketId); 103 | if (!u) return undefined; 104 | const roomId = this.roomOf.get(socketId); 105 | return roomId ? { ...u, roomId } : u; 106 | } 107 | 108 | count() { 109 | return this.users.length; 110 | } 111 | 112 | private startQueueTimeout(socketId: string) { 113 | console.log(`[TIMEOUT] Starting timeout for socket: ${socketId}, timeout: ${QUEUE_TIMEOUT_MS}ms`); 114 | this.clearQueueTimeout(socketId); 115 | 116 | this.queueEntryTime.set(socketId, Date.now()); 117 | 118 | const timeout = setTimeout(() => { 119 | this.handleQueueTimeout(socketId); 120 | }, QUEUE_TIMEOUT_MS); 121 | 122 | this.timeoutIntervals.set(socketId, timeout); 123 | } 124 | 125 | private clearQueueTimeout(socketId: string) { 126 | const timeout = this.timeoutIntervals.get(socketId); 127 | if (timeout) { 128 | clearTimeout(timeout); 129 | this.timeoutIntervals.delete(socketId); 130 | } 131 | this.queueEntryTime.delete(socketId); 132 | } 133 | 134 | private handleQueueTimeout(socketId: string) { 135 | console.log(`[TIMEOUT] Handling timeout for socket: ${socketId}`); 136 | const user = this.users.find(u => u.socket.id === socketId); 137 | if (!user || !this.online.has(socketId) || !this.queue.includes(socketId)) { 138 | console.log(`[TIMEOUT] User not found, offline, or not in queue:`, { 139 | user: !!user, 140 | online: this.online.has(socketId), 141 | inQueue: this.queue.includes(socketId) 142 | }); 143 | return; 144 | } 145 | 146 | console.log(`[TIMEOUT] Emitting timeout event to user: ${socketId}`); 147 | try { 148 | user.socket.emit("queue:timeout", { 149 | message: "We couldn't find a match right now. Please try again later.", 150 | waitTime: Date.now() - (this.queueEntryTime.get(socketId) || Date.now()) 151 | }); 152 | console.log(`[TIMEOUT] Successfully emitted timeout event`); 153 | } catch (error) { 154 | console.error("Failed to emit queue:timeout:", error); 155 | } 156 | 157 | this.queue = this.queue.filter(id => id !== socketId); 158 | this.clearQueueTimeout(socketId); 159 | } 160 | 161 | // ---------- MATCHING / QUEUE (your logic kept intact) ---------- 162 | 163 | clearQueue() { 164 | console.log("inside clear queues"); 165 | console.log(this.queue.length); 166 | if (this.queue.length < 2) { 167 | return; 168 | } 169 | 170 | // find first valid pair not banned from each other and both online 171 | let id1: string | undefined; 172 | let id2: string | undefined; 173 | 174 | outer: for (let i = 0; i < this.queue.length; i++) { 175 | const a = this.queue[i]; 176 | if (!this.online.has(a)) continue; 177 | 178 | const bansA = this.bans.get(a) || new Set(); 179 | 180 | for (let j = i + 1; j < this.queue.length; j++) { 181 | const b = this.queue[j]; 182 | if (!this.online.has(b)) continue; 183 | 184 | const bansB = this.bans.get(b) || new Set(); 185 | if (bansA.has(b) || bansB.has(a)) continue; // never rematch 186 | 187 | id1 = a; 188 | id2 = b; 189 | break outer; 190 | } 191 | } 192 | 193 | if (!id1 || !id2) { 194 | return; // no valid pair right now 195 | } 196 | 197 | console.log("id is " + id1 + " " + id2); 198 | 199 | const user1 = this.users.find((x) => x.socket.id === id1); 200 | const user2 = this.users.find((x) => x.socket.id === id2); 201 | if (!user1 || !user2) return; 202 | 203 | console.log("creating roonm"); 204 | 205 | // remove both from queue for pairing 206 | this.queue = this.queue.filter((x) => x !== id1 && x !== id2); 207 | 208 | // clear timeouts for matched users 209 | this.clearQueueTimeout(id1); 210 | this.clearQueueTimeout(id2); 211 | 212 | // create room and remember links 213 | const roomId = this.roomManager.createRoom(user1, user2); 214 | 215 | this.partnerOf.set(id1, id2); 216 | this.partnerOf.set(id2, id1); 217 | this.roomOf.set(id1, roomId); 218 | this.roomOf.set(id2, roomId); 219 | 220 | // keep matching others if possible 221 | this.clearQueue(); 222 | } 223 | 224 | // Try to get this user matched immediately (used after requeue) 225 | private tryMatchFor(userId: string) { 226 | if (!this.online.has(userId)) return; 227 | if (!this.queue.includes(userId)) this.queue.push(userId); 228 | this.clearQueue(); 229 | } 230 | 231 | // ---------- LEAVE / DISCONNECT / NEXT ---------- 232 | 233 | // Unified leave handler. If a user leaves, partner is requeued + notified. 234 | private handleLeave(leaverId: string, reason: string = "leave") { 235 | const partnerId = this.partnerOf.get(leaverId); 236 | 237 | // always remove leaver from queue 238 | this.queue = this.queue.filter((x) => x !== leaverId); 239 | 240 | // clean leaver links 241 | const leaverRoomId = this.roomOf.get(leaverId); 242 | if (leaverRoomId) { 243 | this.roomManager.teardownUser(leaverRoomId, leaverId); 244 | this.roomOf.delete(leaverId); 245 | } 246 | this.partnerOf.delete(leaverId); 247 | 248 | if (partnerId) { 249 | // ban each other to prevent rematch 250 | const bansA = this.bans.get(leaverId) || new Set(); 251 | const bansB = this.bans.get(partnerId) || new Set(); 252 | bansA.add(partnerId); 253 | bansB.add(leaverId); 254 | this.bans.set(leaverId, bansA); 255 | this.bans.set(partnerId, bansB); 256 | 257 | // clean partner side of the room/pair 258 | const partnerRoomId = this.roomOf.get(partnerId); 259 | if (partnerRoomId) { 260 | this.roomManager.teardownUser(partnerRoomId, partnerId); 261 | this.roomOf.delete(partnerId); 262 | } 263 | this.partnerOf.delete(partnerId); 264 | 265 | // keep partner waiting: requeue + notify + try match now 266 | const partnerUser = this.users.find((u) => u.socket.id === partnerId); 267 | if (partnerUser && this.online.has(partnerId)) { 268 | partnerUser.socket.emit("partner:left", { reason }); 269 | if (!this.queue.includes(partnerId)) this.queue.push(partnerId); 270 | this.tryMatchFor(partnerId); 271 | } 272 | } 273 | } 274 | 275 | private onNext(userId: string) { 276 | const partnerId = this.partnerOf.get(userId); 277 | if (!partnerId) { 278 | // user is not currently paired; just ensure they are queued 279 | if (!this.queue.includes(userId)) this.queue.push(userId); 280 | this.tryMatchFor(userId); 281 | return; 282 | } 283 | 284 | // Get room ID to send system message BEFORE teardown 285 | const roomId = this.roomOf.get(userId); 286 | 287 | // Send system message that peer left the chat BEFORE teardown to ensure users are still in the chat room 288 | if (roomId && this.io) { 289 | const chatRoom = `chat:${roomId}`; 290 | this.io.to(chatRoom).emit("chat:system", { 291 | text: "Peer left the chat", 292 | ts: Date.now() 293 | }); 294 | } 295 | 296 | // Ban both users from matching with each other again 297 | const bansU = this.bans.get(userId) || new Set(); 298 | const bansP = this.bans.get(partnerId) || new Set(); 299 | bansU.add(partnerId); 300 | bansP.add(userId); 301 | this.bans.set(userId, bansU); 302 | this.bans.set(partnerId, bansP); 303 | 304 | // Teardown room and clear mappings 305 | if (roomId) this.roomManager.teardownRoom(roomId); 306 | this.partnerOf.delete(userId); 307 | this.partnerOf.delete(partnerId); 308 | this.roomOf.delete(userId); 309 | this.roomOf.delete(partnerId); 310 | 311 | // Requeue caller immediately; notify partner their match ended 312 | if (!this.queue.includes(userId)) this.queue.push(userId); 313 | const partnerUser = this.users.find((u) => u.socket.id === partnerId); 314 | if (partnerUser && this.online.has(partnerId)) { 315 | partnerUser.socket.emit("partner:left", { reason: "next" }); 316 | // Also requeue partner automatically 317 | if (!this.queue.includes(partnerId)) this.queue.push(partnerId); 318 | } 319 | 320 | // Try to rematch the caller right away 321 | this.tryMatchFor(userId); 322 | } 323 | 324 | // ---------- SOCKET HANDLERS ---------- 325 | 326 | initHandlers(socket: Socket) { 327 | // WebRTC signaling passthrough 328 | socket.on("offer", ({ sdp, roomId }: { sdp: string; roomId: string }) => { 329 | this.roomManager.onOffer(roomId, sdp, socket.id); 330 | }); 331 | 332 | socket.on("answer", ({ sdp, roomId }: { sdp: string; roomId: string }) => { 333 | this.roomManager.onAnswer(roomId, sdp, socket.id); 334 | }); 335 | 336 | socket.on("add-ice-candidate", ({ candidate, roomId, type }) => { 337 | this.roomManager.onIceCandidates(roomId, socket.id, candidate, type); 338 | }); 339 | 340 | // user actions 341 | socket.on("queue:next", () => { 342 | this.onNext(socket.id); 343 | }); 344 | 345 | socket.on("queue:leave", () => { 346 | // user wants to leave matching; remove from queue and clean links 347 | this.queue = this.queue.filter((x) => x !== socket.id); 348 | this.clearQueueTimeout(socket.id); 349 | this.handleLeave(socket.id, "leave-button"); 350 | }); 351 | 352 | socket.on("queue:retry", () => { 353 | if (!this.queue.includes(socket.id) && this.online.has(socket.id)) { 354 | this.queue.push(socket.id); 355 | this.startQueueTimeout(socket.id); 356 | socket.emit("queue:waiting"); 357 | this.clearQueue(); 358 | } 359 | }); 360 | 361 | socket.on("disconnect", () => { 362 | // treat as a leave, but do not remove the partner; requeue them 363 | this.handleLeave(socket.id, "disconnect"); 364 | this.online.delete(socket.id); 365 | }); 366 | } 367 | } 368 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

4 | 5 | Helixque Header 6 | 7 |

8 |

Professional real-time video chat with preference-based matching

9 |

10 | Contributing Guidelines ✦ 11 | Releases ✦ 12 | Code Of Conduct 13 |

14 |
15 | 16 | ![Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue?labelColor=black&style=for-the-badge&color=D20A2E) 17 | [![Discord](https://img.shields.io/badge/Discord-Join-5865F2?labelColor=black&logo=discord&logoColor=D20A2E&style=for-the-badge&color=D20A2E)](https://discord.gg/dQUh6SY9Uk) 18 | [![Website](https://img.shields.io/badge/website-HelixQue-FFF?labelColor=black&logo=globe&style=for-the-badge&color=D20A2E)](https://helixque.com) 19 |
20 | ![Stars](https://img.shields.io/github/stars/HXQLabs/Helixque?labelColor=black&style=for-the-badge&color=D20A2E)  21 | ![Forks](https://img.shields.io/github/forks/HXQLabs/Helixque?labelColor=black&style=for-the-badge&color=D20A2E)  22 | ![Commit-Activity](https://img.shields.io/github/commit-activity/m/HXQLabs/Helixque?labelColor=black&color=D20A2E&logo=code&logoColor=D20A2E&style=for-the-badge) 23 |
24 | 25 |

26 | This project is backed by 27 |
28 |
29 | 30 | Vercel OSS Program 31 | 32 |
33 |
34 |

35 | 36 | 37 | 38 | 39 | 40 | Meet [Helixque](https://github.com/HXQLabs/Helixque), a professional real-time video chat application that pairs people based on their preferences. Built with WebRTC for secure, low-latency peer-to-peer media and Socket.IO for reliable signaling delivering a modern experience for networking, and collaboration. 🎥 41 | 42 | > \[!IMPORTANT] 43 | > 44 | > Helixque is continuously evolving. Your suggestions, ideas, and reported bugs help us immensely. Do not hesitate to join the conversation on [Discord](https://discord.gg/dQUh6SY9Uk) or raise a GitHub issue. We read everything and respond to most. 45 | 46 | 47 | 48 | 49 | ## Note 50 | You can now preview the latest updates and improvements every 2–3 days at the following link: 51 | 👉 [Helixque-Changes](https://helixque-changes.netlify.app/) 52 | 53 | ## 🚀 Quick Start 54 | 55 | Getting started with Helixque is simple: 56 | 57 | 1. **Clone the repository** 58 | 59 | ```bash 60 | git clone https://github.com/HXQLabs/Helixque.git 61 | cd Helixque 62 | ``` 63 | 64 | 2. **Install dependencies** 65 | 66 | ```bash 67 | # Backend 68 | cd backend && npm install 69 | 70 | # Frontend 71 | cd ../frontend && npm install 72 | ``` 73 | 74 | 3. **Configure environment variables** 75 | 76 | ```bash 77 | # Backend: Copy and edit .env.example 78 | cp backend/.env.example backend/.env 79 | 80 | # Frontend: Create .env.local 81 | echo "NEXT_PUBLIC_BACKEND_URL=http://localhost:5001" > frontend/.env.local 82 | ``` 83 | 84 | 4. **Start development servers** 85 | 86 | ```bash 87 | # Terminal 1 - Backend 88 | cd backend && npm run dev 89 | 90 | # Terminal 2 - Frontend 91 | cd frontend && npm run dev 92 | ``` 93 | 94 | Open your browser at `http://localhost:3000` and allow camera/microphone access. 🎉 95 | 96 | ## 🌟 Features 97 | 98 | - **Enhanced UI & Layout** 99 | Enjoy a cleaner, smoother interface with improved stability when switching between users. Seamless navigation and responsive design ensure a premium user experience. 100 | 101 | - **Seamless Media Switching** 102 | Toggle between video and audio effortlessly with smooth transitions for uninterrupted conversations. Real-time device management keeps your calls crystal clear. 103 | 104 | - **Instant Messaging** 105 | Send and receive messages in real time for seamless communication alongside video calls. Perfect for sharing links, notes, or quick thoughts during conversations. 106 | 107 | - **One-on-One Video Calling** 108 | Connect directly with other users for private, high-quality video conversations. WebRTC ensures low-latency, peer-to-peer connections for the best quality. 109 | 110 | - **Random Connect with Professionals** 111 | Meet and network with professionals from various fields instantly. Expand your connections effortlessly with intelligent preference-based matching. 112 | 113 | - **Unlimited Skips** 114 | No limits on finding the right match. Skip as many times as you need until you find the perfect conversation partner. 115 | 116 | ## 🛠️ Local Development 117 | 118 | ### Frontend 119 | 120 | The frontend is a Next.js application (App Router) that manages device selection, user preferences, UI state, and the RTCPeerConnection lifecycle. 121 | 122 | **Development commands:** 123 | 124 | ```bash 125 | cd frontend 126 | npm install # Install dependencies 127 | npm run dev # Start development server 128 | npm run build # Build for production 129 | npm start # Start production server 130 | ``` 131 | 132 | **Environment variables:** 133 | 134 | Create `frontend/.env.local`: 135 | 136 | ```env 137 | NEXT_PUBLIC_BACKEND_URL=http://localhost:5001 138 | ``` 139 | 140 | > **Note:** Frontend requires HTTPS in production for getUserMedia to function correctly. Device permissions must be granted by the user. 141 | 142 | ### Backend 143 | 144 | The backend is a Node.js + TypeScript server providing Socket.IO signaling, user presence, and preference-based matchmaking. 145 | 146 | **Development commands:** 147 | 148 | ```bash 149 | cd backend 150 | npm install # Install dependencies 151 | npm run dev # Start development server 152 | npm run build # Build for production 153 | npm start # Start production server 154 | ``` 155 | 156 | **Environment variables:** 157 | 158 | Copy `backend/.env.example` to `backend/.env`: 159 | 160 | ```env 161 | PORT=5001 162 | NODE_ENV=production 163 | CORS_ORIGINS=http://localhost:3000 164 | # Optional: REDIS_URL=redis://localhost:6379 165 | # Optional: STUN/TURN server configuration 166 | ``` 167 | 168 | > **Note:** Use a TURN server in production to ensure media relay when direct P2P is not possible. For multiple backend instances, configure Socket.IO Redis adapter. 169 | 170 | ## ⚙️ Built With 171 | 172 | [![Next.js](https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white)](https://nextjs.org/) 173 | [![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) 174 | [![Node.js](https://img.shields.io/badge/node.js-339933?style=for-the-badge&logo=Node.js&logoColor=white)](https://nodejs.org/) 175 | [![Socket.io](https://img.shields.io/badge/Socket.io-010101?style=for-the-badge&logo=socketdotio&logoColor=white)](https://socket.io/) 176 | [![WebRTC](https://img.shields.io/badge/WebRTC-333333?style=for-the-badge&logo=webrtc&logoColor=white)](https://webrtc.org/) 177 | [![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-38B2AC?style=for-the-badge&logo=tailwind-css&logoColor=white)](https://tailwindcss.com/) 178 | 179 | ## 🏗️ Project Structure 180 | 181 | ``` 182 | Helixque/ 183 | ├─ backend/ # Signaling server (Node.js + TypeScript) 184 | │ ├─ src/ 185 | │ │ ├─ managers/ # UserManager, RoomManager 186 | │ │ └─ index.ts # Entry point 187 | │ ├─ .env.example 188 | │ └─ package.json 189 | ├─ frontend/ # Next.js app (TypeScript) 190 | │ ├─ app/ # App Router pages 191 | │ ├─ components/ # UI + RTC components 192 | │ ├─ .env.local 193 | │ └─ package.json 194 | ├─ assets/ # Images and static files 195 | └─ README.md 196 | ``` 197 | 198 | ### Core Components 199 | 200 | - **UserManager** (backend) — Queue management, matching logic, presence tracking, and session state 201 | - **RoomManager** (backend) — Room lifecycle, signaling orchestration, and cleanup operations 202 | - **Room** (frontend) — RTCPeerConnection lifecycle, media controls, and UI state management 203 | 204 | ## 📡 Socket.IO Events 205 | 206 | ### Client → Server 207 | 208 | | Event | Description | Payload | 209 | |-------|-------------|---------| 210 | | `offer` | Send WebRTC offer | `{ sdp: string, roomId: string }` | 211 | | `answer` | Send WebRTC answer | `{ sdp: string, roomId: string }` | 212 | | `add-ice-candidate` | Send ICE candidate | `{ candidate: RTCIceCandidate, roomId: string, type: 'sender' \| 'receiver' }` | 213 | | `queue:next` | Request next match | — | 214 | | `queue:leave` | Leave queue / room | — | 215 | 216 | ### Server → Client 217 | 218 | | Event | Description | Payload | 219 | |-------|-------------|---------| 220 | | `lobby` | User joined lobby | — | 221 | | `queue:waiting` | Waiting for a match | — | 222 | | `send-offer` | Instruct client to create/send offer | `{ roomId: string }` | 223 | | `offer` | Deliver remote offer | `{ sdp: string, roomId: string }` | 224 | | `answer` | Deliver remote answer | `{ sdp: string, roomId: string }` | 225 | | `add-ice-candidate` | Deliver remote ICE candidate | `{ candidate: RTCIceCandidate, type: 'sender' \| 'receiver' }` | 226 | | `partner:left` | Remote peer disconnected | `{ reason?: string }` | 227 | 228 | ## 🚢 Deployment 229 | 230 | ### Backend (Render / Railway / Heroku) 231 | 232 | | Platform | Guide | 233 | |----------|-------| 234 | | Render | Deploy Node.js app with environment variables | 235 | | Railway | Auto-deploy from GitHub with build commands | 236 | | Heroku | Use Procfile with `npm start` | 237 | 238 | **Deployment steps:** 239 | 240 | 1. Set environment variables (`PORT`, `NODE_ENV`, `CORS_ORIGINS`, optional `REDIS_URL` and `TURN_*`) 241 | 2. Build and run: 242 | 243 | ```bash 244 | cd backend 245 | npm run build 246 | npm start 247 | ``` 248 | 249 | ### Frontend (Vercel / Netlify) 250 | 251 | | Platform | Guide | 252 | |----------|-------| 253 | | Vercel | Automatic Next.js deployment from GitHub | 254 | | Netlify | Configure build command: `npm run build` | 255 | 256 | **Deployment steps:** 257 | 258 | 1. Set `NEXT_PUBLIC_BACKEND_URL` to your backend's HTTPS endpoint 259 | 2. Deploy using your platform's Next.js build pipeline 260 | 261 | > **Docker:** Container examples are included in the project for advanced deployments. 262 | 263 | ## ❤️ Community 264 | 265 | Join the Helixque community on [Discord](https://discord.gg/dQUh6SY9Uk) and [GitHub Discussions](https://github.com/HXQLabs/Helixque/discussions). 266 | 267 | Feel free to ask questions, report bugs, participate in discussions, share ideas, request features, or showcase your projects. We'd love to hear from you! 268 | 269 | ## 🛡️ Security 270 | 271 | If you discover a security vulnerability in Helixque, please report it responsibly instead of opening a public issue. We take all legitimate reports seriously and will investigate them promptly. 272 | 273 | To disclose any security issues, please contact the maintainers through Discord or open a private security advisory on GitHub. 274 | 275 | ## 🤝 Contributing 276 | 277 | There are many ways you can contribute to Helixque: 278 | 279 | - ⭐ **Star the repository** to support the project 280 | - 🐛 Report bugs or submit feature requests via [GitHub Issues](https://github.com/HXQLabs/Helixque/issues) 281 | - 📖 Review and improve documentation 282 | - 💬 Talk about Helixque in your community and [let us know](https://discord.gg/dQUh6SY9Uk) 283 | - 👍 Show your support by upvoting popular feature requests 284 | 285 | ### Contribution Guidelines 286 | 287 | - Open an issue to discuss larger features before implementing 288 | - Use small, focused pull requests with descriptive titles and testing notes 289 | - Maintain TypeScript types and follow existing code style 290 | - Run linters and formatters before committing 291 | - Join our [Discord](https://discord.gg/dQUh6SY9Uk) to coordinate work and get faster PR reviews 292 | 293 | > **Important:** Signing up and completing the brief onboarding in the app is required for all contributors. Maintainers will use registered accounts to verify changes. 294 | 295 | ### Repo Activity 296 | 297 | ![Helixque Repo Activity](https://repobeats.axiom.co/api/embed/104659808acdebcf5999205983b83c1cae5b9ae4.svg "Repobeats analytics image") 298 | 299 | 300 | ### We Couldn't Have Done This Without You 301 | 302 | 303 | 304 | 305 | 306 | ## 📄 License 307 | 308 | This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details. 309 | 310 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 311 | 312 | ### Acknowledgments 313 | 314 | Thanks to the open-source projects that made Helixque possible: 315 | - [WebRTC](https://webrtc.org/) - Real-time communication 316 | - [Socket.IO](https://socket.io/) - Real-time bidirectional communication 317 | - [Next.js](https://nextjs.org/) - React framework 318 | - [React](https://react.dev/) - UI library 319 | - [Tailwind CSS](https://tailwindcss.com/) - Utility-first CSS framework 320 | - [TypeScript](https://www.typescriptlang.org/) - Type-safe JavaScript 321 | 322 |
323 | 324 | 325 | -------------------------------------------------------------------------------- /frontend/components/RTC/Chat/chat.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useEffect, useRef, useState } from "react"; 3 | import type { Socket } from "socket.io-client"; 4 | import { toast } from "sonner"; 5 | import type { EmojiClickData} from "emoji-picker-react" 6 | import EmojiPicker, {Theme} from "emoji-picker-react"; 7 | import { RiEmojiStickerLine } from 'react-icons/ri' 8 | 9 | type ChatMessage = { 10 | text: string; 11 | from: string; 12 | clientId: string; 13 | ts: number; 14 | kind?: "user" | "system"; 15 | }; 16 | 17 | const MAX_LEN = 1000; // match server cap 18 | const MAX_BUFFER = 300; // keep memory tidy 19 | const TYPING_DEBOUNCE = 350; // ms 20 | 21 | export default function ChatPanel({ 22 | socket, 23 | roomId, 24 | name, 25 | mySocketId, 26 | collapsed = false, 27 | isOpen = false, 28 | }: { 29 | socket: Socket | null; 30 | roomId: string | null; 31 | name: string; 32 | mySocketId: string | null; 33 | collapsed?: boolean; 34 | isOpen?: boolean; 35 | }) { 36 | const [messages, setMessages] = useState([]); 37 | const [input, setInput] = useState(""); 38 | const [peerTyping, setPeerTyping] = useState(null); 39 | const scrollerRef = useRef(null); 40 | const typingTimeoutRef = useRef | null>(null); 41 | const typingDebounceRef = useRef | null>(null); 42 | const sidRef = useRef(mySocketId ?? null); 43 | const emojiRef=useRef(null) 44 | const inputRef=useRef(null) 45 | const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); 46 | const [cursorPosition, setCursorPosition] = useState(0); 47 | const didJoinRef = useRef>({}); 48 | 49 | useEffect(()=>{ 50 | function handleClickOutside(event:MouseEvent){ 51 | if(emojiRef.current && !emojiRef.current.contains(event.target as Node)){ 52 | // Only proceed if the picker is currently open 53 | if(emojiPickerOpen) { 54 | setEmojiPickerOpen(false); 55 | setTimeout(() => { 56 | if(inputRef.current){ 57 | inputRef.current.focus(); 58 | } 59 | }, 5); 60 | } 61 | } 62 | } 63 | document.addEventListener("mousedown",handleClickOutside); 64 | return ()=>{ 65 | document.removeEventListener("mousedown",handleClickOutside) 66 | } 67 | }, [emojiPickerOpen]); // Add dependency here 68 | 69 | // derive & keep socket.id fresh for self-dedupe 70 | useEffect(() => { 71 | if (!socket) return; 72 | const setSid = () => { 73 | sidRef.current = socket.id || sidRef.current || null; 74 | }; 75 | setSid(); 76 | socket.on("connect", setSid); 77 | return () => { 78 | socket.off("connect", setSid); 79 | }; 80 | }, [socket]); 81 | 82 | const canSend = !!socket && socket.connected && !!roomId && !!name && !!(sidRef.current || mySocketId); 83 | 84 | // Dismiss existing toasts when chat window opens 85 | useEffect(() => { 86 | if (isOpen) { 87 | toast.dismiss(); 88 | } 89 | }, [isOpen]); 90 | 91 | // auto-scroll to bottom on new messages - DISABLED per user request 92 | // useEffect(() => { 93 | // scrollerRef.current?.scrollTo({ 94 | // top: scrollerRef.current.scrollHeight, 95 | // behavior: "smooth", 96 | // }); 97 | // }, [messages.length]); 98 | 99 | // wire socket events + (re)join on mount/room change/reconnect 100 | useEffect(() => { 101 | if (!socket || !roomId) return; 102 | 103 | const join = () => socket.emit("chat:join", { roomId, name }); 104 | const joinOnce = () => { 105 | const sid = socket.id ?? null; 106 | const key = `${roomId}`; 107 | if (sid && didJoinRef.current[key] === sid) return; // already joined for this room with this socket id 108 | if (sid) didJoinRef.current[key] = sid; 109 | join(); 110 | }; 111 | // initial join will be emitted after listeners are attached 112 | const onConnect = () => { 113 | // re-join on reconnect 114 | sidRef.current = socket.id ?? null; 115 | joinOnce(); 116 | }; 117 | 118 | const onMsg = (m: ChatMessage) => { 119 | // skip server echo of my optimistic send 120 | const myId = mySocketId || sidRef.current; 121 | if (m.clientId === myId) return; 122 | setMessages((prev) => { 123 | const next = [...prev, { ...m, kind: "user" as const }]; 124 | return next.length > MAX_BUFFER ? next.slice(-MAX_BUFFER) : next; 125 | }); 126 | try { 127 | // Only show toast if chat window is closed 128 | if (!isOpen) { 129 | // Hide actual username in notification for privacy - always show "Peer" 130 | toast.success( 131 | `Peer: ${m.text.length > 80 ? m.text.slice(0, 77) + '...' : m.text}`, 132 | { 133 | duration: 3500, 134 | position: 'bottom-right', 135 | style: { 136 | bottom: '100px', // Position above the control icons 137 | right: '20px', 138 | } 139 | } 140 | ); 141 | } 142 | } catch {} 143 | }; 144 | 145 | const onSystem = (m: { text: string; ts?: number }) => { 146 | // normalize system text: keep my own name, anonymize peers as "peer" 147 | const normalize = (txt: string) => { 148 | try { 149 | const re = /^(.*)\s+(joined|left) the chat.*$/; 150 | const match = txt.match(re); 151 | if (match) { 152 | const who = (match[1] || "").trim(); 153 | const action = match[2]; 154 | const isSelf = who.length > 0 && who.toLowerCase() === (name || "").toLowerCase(); 155 | return `${isSelf ? name : "Peer"} ${action} the chat`; 156 | } 157 | } catch {} 158 | return txt; 159 | }; 160 | const text = normalize(m.text); 161 | 162 | setMessages((prev) => { 163 | // simple de-dupe: if last system message has identical text, skip 164 | const last = prev[prev.length - 1]; 165 | if (last?.kind === "system" && last.text === text) return prev; 166 | const next = [ 167 | ...prev, 168 | { text, from: "system", clientId: "system", ts: m.ts ?? Date.now(), kind: "system" as const }, 169 | ]; 170 | return next.length > MAX_BUFFER ? next.slice(-MAX_BUFFER) : next; 171 | }); 172 | }; 173 | 174 | const onTyping = ({ from, typing }: { from: string; typing: boolean }) => { 175 | setPeerTyping(typing ? `Peer is typing…` : null); 176 | if (typing) { 177 | if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); 178 | typingTimeoutRef.current = setTimeout(() => setPeerTyping(null), 3000); 179 | } 180 | }; 181 | 182 | 183 | // Handler for when partner leaves the chat 184 | // const onPartnerLeft = ({ reason }: { reason: string }) => { 185 | // console.log("👋 PARTNER LEFT - Chat event received with reason:", reason); 186 | // onSystem({ text: `Your partner left (${reason}).` }); 187 | // }; 188 | 189 | // Server-sent history: merge with current messages and de-dupe 190 | const onHistory = (payload: { roomId: string; messages: ChatMessage[] }) => { 191 | if (!payload || payload.roomId !== roomId) return; 192 | const incoming = Array.isArray(payload.messages) ? payload.messages : []; 193 | if (incoming.length === 0) return; 194 | setMessages((prev) => { 195 | const keyOf = (x: ChatMessage) => `${x.kind || 'user'}|${x.ts}|${x.clientId}|${x.text}`; 196 | const seen = new Set(prev.map(keyOf)); 197 | const add = incoming.filter((m) => !seen.has(keyOf(m))); 198 | if (add.length === 0) return prev; 199 | const merged = [...prev, ...add]; 200 | return merged.length > MAX_BUFFER ? merged.slice(-MAX_BUFFER) : merged; 201 | }); 202 | }; 203 | 204 | socket.on("connect", onConnect); 205 | socket.on("chat:message", onMsg); 206 | socket.on("chat:system", onSystem); 207 | socket.on("chat:typing", onTyping); 208 | socket.on("chat:history", onHistory); 209 | // socket.on("partner:left", onPartnerLeft); 210 | 211 | // perform initial join only once per socket.id+room 212 | if (socket.connected) { 213 | onConnect(); 214 | } 215 | 216 | return () => { 217 | socket.off("connect", onConnect); 218 | socket.off("chat:message", onMsg); 219 | socket.off("chat:system", onSystem); 220 | socket.off("chat:typing", onTyping); 221 | socket.off("chat:history", onHistory); 222 | // socket.off("partner:left", onPartnerLeft); 223 | // stop typing when leaving room/unmounting 224 | socket.emit("chat:typing", { roomId, from: name, typing: false }); 225 | // announce leaving the chat room 226 | socket.emit("chat:leave", { roomId, name }); 227 | }; 228 | }, [socket, roomId]); 229 | 230 | const sendMessage = () => { 231 | if (!canSend || !input.trim()) return; 232 | const myId = mySocketId || sidRef.current!; 233 | const payload = { 234 | roomId: roomId!, 235 | text: input.trim().slice(0, MAX_LEN), 236 | from: name, 237 | clientId: myId, 238 | ts: Date.now(), 239 | }; 240 | // optimistic add 241 | setMessages((prev) => { 242 | const next = [...prev, { ...payload, kind: "user" as const }]; 243 | return next.length > MAX_BUFFER ? next.slice(-MAX_BUFFER) : next; 244 | }); 245 | try { 246 | // toast for outgoing message (short & subtle) 247 | toast.success("Message sent", { duration: 1200 }); 248 | } catch {} 249 | socket!.emit("chat:message", payload); 250 | setInput(""); 251 | socket!.emit("chat:typing", { roomId, from: name, typing: false }); 252 | }; 253 | 254 | const handleAddEmoji=(emoji:EmojiClickData)=>{ 255 | const start = cursorPosition; 256 | const newMsg = input.slice(0, start) + emoji.emoji + input.slice(start); 257 | setInput(newMsg); 258 | 259 | const newCursorPos = start + emoji.emoji.length; 260 | setCursorPosition(newCursorPos); 261 | 262 | 263 | if (!socket || !roomId) return; 264 | if (typingDebounceRef.current) clearTimeout(typingDebounceRef.current); 265 | typingDebounceRef.current = setTimeout(() => { 266 | socket.emit("chat:typing", { roomId, from: name, typing: !!newMsg }); 267 | }, TYPING_DEBOUNCE); 268 | 269 | 270 | } 271 | 272 | const handleTyping = (value: string) => { 273 | setInput(value); 274 | 275 | if(inputRef.current){ 276 | setCursorPosition(inputRef.current.selectionStart || value.length) 277 | } 278 | if (!socket || !roomId) return; 279 | 280 | if (typingDebounceRef.current) clearTimeout(typingDebounceRef.current); 281 | typingDebounceRef.current = setTimeout(() => { 282 | socket.emit("chat:typing", { roomId, from: name, typing: !!value }); 283 | }, TYPING_DEBOUNCE); 284 | }; 285 | 286 | if (collapsed) return null; 287 | 288 | return ( 289 |
290 |
291 | {messages.map((m, idx) => { 292 | const myId = mySocketId || sidRef.current; 293 | const mine = m.clientId === myId; 294 | const isSystem = m.kind === "system"; 295 | return ( 296 |
297 |
307 | {isSystem ? ( 308 | {m.text} 309 | ) : ( 310 | <> 311 | {!mine &&
Peer
} 312 |
{m.text}
313 | 314 | )} 315 |
316 |
317 | ); 318 | })} 319 | {peerTyping &&
{peerTyping}
} 320 |
321 | 322 |
323 |
324 |
325 | handleTyping(e.target.value)} 331 | onClick={(e)=>setCursorPosition(e.currentTarget.selectionStart||input.length)} 332 | onKeyUp={(e) => setCursorPosition(e.currentTarget.selectionStart || input.length)} 333 | onKeyDown={(e) => { 334 | if (e.key === "Enter") sendMessage(); 335 | }} 336 | disabled={!canSend} 337 | maxLength={MAX_LEN} 338 | /> 339 |
340 | 343 |
344 | 345 |
346 |
347 |
348 | 355 |
356 |
357 |
358 | ); 359 | } -------------------------------------------------------------------------------- /frontend/components/RTC/Room.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useRef } from "react"; 4 | import { io, Socket } from "socket.io-client"; 5 | import { toast } from "sonner"; 6 | import { useRouter } from "next/navigation"; 7 | import ChatPanel from "./Chat/chat"; // ← adjust path if different 8 | import VideoGrid from "./VideoGrid"; 9 | import ControlBar from "./ControlBar"; 10 | import TimeoutAlert from "./TimeoutAlert"; 11 | import { useMediaState, usePeerState, useRoomState } from "./hooks"; 12 | import { 13 | ensureRemoteStream, 14 | detachLocalPreview, 15 | stopProvidedTracks, 16 | teardownPeers, 17 | toggleCameraTrack 18 | } from "./webrtc-utils"; 19 | 20 | const URL = process.env.NEXT_PUBLIC_BACKEND_URI || "http://localhost:5001"; 21 | 22 | interface RoomProps { 23 | name: string; 24 | localAudioTrack: MediaStreamTrack | null; 25 | localVideoTrack: MediaStreamTrack | null; 26 | audioOn?: boolean; 27 | videoOn?: boolean; 28 | onLeave?: () => void; 29 | } 30 | 31 | export default function Room({ 32 | name, 33 | localAudioTrack, 34 | localVideoTrack, 35 | audioOn, 36 | videoOn, 37 | onLeave, 38 | }: RoomProps) { 39 | const router = useRouter(); 40 | 41 | // Custom hooks for state management 42 | const mediaState = useMediaState(audioOn, videoOn); 43 | const peerState = usePeerState(); 44 | const roomState = useRoomState(); 45 | 46 | const { micOn, setMicOn, camOn, setCamOn, screenShareOn, setScreenShareOn } = mediaState; 47 | const { peerMicOn, setPeerMicOn, peerCamOn, setPeerCamOn, peerScreenShareOn, setPeerScreenShareOn } = peerState; 48 | const { 49 | showChat, setShowChat, roomId, setRoomId, mySocketId, setMySocketId, 50 | lobby, setLobby, status, setStatus, showTimeoutAlert, setShowTimeoutAlert, 51 | timeoutMessage, setTimeoutMessage 52 | } = roomState; 53 | 54 | // DOM refs 55 | const localVideoRef = useRef(null); 56 | const remoteVideoRef = useRef(null); 57 | const remoteAudioRef = useRef(null); 58 | const localScreenShareRef = useRef(null); 59 | const remoteScreenShareRef = useRef(null); 60 | 61 | // socket/pc refs 62 | const socketRef = useRef(null); 63 | const peerIdRef = useRef(null); 64 | const sendingPcRef = useRef(null); 65 | const receivingPcRef = useRef(null); 66 | const joinedRef = useRef(false); 67 | 68 | // video and screenshare refs 69 | const videoSenderRef = useRef(null); 70 | const currentVideoTrackRef = useRef(localVideoTrack); 71 | const currentScreenShareTrackRef = useRef(null); 72 | const localScreenShareStreamRef = useRef(null); 73 | const remoteStreamRef = useRef(null); 74 | 75 | // ICE candidate queues for handling candidates before remote description is set 76 | const senderIceCandidatesQueue = useRef([]); 77 | const receiverIceCandidatesQueue = useRef([]); 78 | 79 | // Helper function for remote stream management 80 | const ensureRemoteStreamLocal = () => { 81 | if (!remoteStreamRef.current) { 82 | remoteStreamRef.current = new MediaStream(); 83 | } 84 | if (remoteVideoRef.current && !peerScreenShareOn) { 85 | remoteVideoRef.current.srcObject = remoteStreamRef.current; 86 | } 87 | if (remoteAudioRef.current) { 88 | remoteAudioRef.current.srcObject = remoteStreamRef.current; 89 | } 90 | if (remoteScreenShareRef.current && peerScreenShareOn) { 91 | remoteScreenShareRef.current.srcObject = remoteStreamRef.current; 92 | } 93 | }; 94 | 95 | // Helper function to process queued ICE candidates 96 | const processQueuedIceCandidates = async (pc: RTCPeerConnection, queue: RTCIceCandidate[]) => { 97 | while (queue.length > 0) { 98 | const candidate = queue.shift(); 99 | if (candidate) { 100 | try { 101 | await pc.addIceCandidate(candidate); 102 | // console.log("Processed queued ICE candidate"); 103 | } catch (e) { 104 | // console.error("Error processing queued ICE candidate:", e); 105 | } 106 | } 107 | } 108 | }; 109 | 110 | // Helper for common PC setup 111 | const setupPeerConnection = async (pc: RTCPeerConnection, isOffer: boolean, rid: string, socket: Socket) => { 112 | videoSenderRef.current = null; 113 | 114 | if (localAudioTrack && localAudioTrack.readyState === "live" && micOn) { 115 | pc.addTrack(localAudioTrack); 116 | } 117 | 118 | let videoTrack = currentVideoTrackRef.current; 119 | if (!videoTrack || videoTrack.readyState === "ended") { 120 | try { 121 | const stream = await navigator.mediaDevices.getUserMedia({ video: true }); 122 | videoTrack = stream.getVideoTracks()[0]; 123 | currentVideoTrackRef.current = videoTrack; 124 | } catch (err) { 125 | console.error("Error creating video track:", err); 126 | videoTrack = null; 127 | } 128 | } 129 | 130 | if (videoTrack && videoTrack.readyState === "live") { 131 | const vs = pc.addTrack(videoTrack); 132 | videoSenderRef.current = vs; 133 | } 134 | 135 | ensureRemoteStreamLocal(); 136 | pc.ontrack = (e) => { 137 | if (!remoteStreamRef.current) remoteStreamRef.current = new MediaStream(); 138 | if (e.track.kind === 'video') { 139 | remoteStreamRef.current.getVideoTracks().forEach(track => 140 | remoteStreamRef.current?.removeTrack(track) 141 | ); 142 | } 143 | remoteStreamRef.current.addTrack(e.track); 144 | ensureRemoteStreamLocal(); 145 | }; 146 | 147 | pc.onicecandidate = (e) => { 148 | if (e.candidate) { 149 | socket.emit("add-ice-candidate", { 150 | candidate: e.candidate, 151 | type: isOffer ? "sender" : "receiver", 152 | roomId: rid 153 | }); 154 | } 155 | }; 156 | }; 157 | 158 | // ===== EVENT HANDLERS ===== 159 | const handleRetryMatchmaking = () => { 160 | if (socketRef.current) { 161 | socketRef.current.emit("queue:retry"); 162 | setShowTimeoutAlert(false); 163 | setStatus("Searching for the best match…"); 164 | } 165 | }; 166 | 167 | const handleCancelTimeout = () => { 168 | if (socketRef.current) { 169 | socketRef.current.emit("queue:leave"); 170 | } 171 | setShowTimeoutAlert(false); 172 | setLobby(false); 173 | window.location.reload(); 174 | }; 175 | 176 | const handleKeyDown = (e: React.KeyboardEvent) => { 177 | if (e.key === 'Escape') { 178 | handleCancelTimeout(); 179 | } 180 | }; 181 | 182 | const toggleMic = () => { 183 | const on = !micOn; 184 | setMicOn(on); 185 | try { 186 | if (localAudioTrack) localAudioTrack.enabled = on; 187 | } catch {} 188 | }; 189 | 190 | const toggleCam = async () => { 191 | await toggleCameraTrack( 192 | camOn, 193 | setCamOn, 194 | currentVideoTrackRef, 195 | localVideoRef, 196 | videoSenderRef, 197 | sendingPcRef, 198 | receivingPcRef, 199 | roomId, 200 | socketRef, 201 | localVideoTrack 202 | ); 203 | }; 204 | 205 | const toggleScreenShare = async () => { 206 | const turningOn = !screenShareOn; 207 | setScreenShareOn(turningOn); 208 | 209 | try { 210 | const socket = socketRef.current; 211 | 212 | if (turningOn) { 213 | try { 214 | const screenStream = await navigator.mediaDevices.getDisplayMedia({ 215 | video: true, 216 | audio: true 217 | }); 218 | 219 | const screenTrack = screenStream.getVideoTracks()[0]; 220 | currentScreenShareTrackRef.current = screenTrack; 221 | localScreenShareStreamRef.current = screenStream; 222 | 223 | if (localScreenShareRef.current) { 224 | localScreenShareRef.current.srcObject = screenStream; 225 | await localScreenShareRef.current.play().catch(() => {}); 226 | } 227 | 228 | if (videoSenderRef.current) { 229 | await videoSenderRef.current.replaceTrack(screenTrack); 230 | toast.success("Screen Share Started", { 231 | description: "You are now sharing your screen" 232 | }); 233 | } 234 | 235 | if (socket && roomId) { 236 | socket.emit("media-state-change", { 237 | isScreenSharing: true, 238 | micOn, 239 | camOn: false 240 | }); 241 | } 242 | 243 | screenTrack.onended = async () => { 244 | setScreenShareOn(false); 245 | 246 | let cameraTrack = currentVideoTrackRef.current; 247 | if (!cameraTrack || cameraTrack.readyState === "ended") { 248 | if (camOn) { 249 | try { 250 | const cameraStream = await navigator.mediaDevices.getUserMedia({ video: true }); 251 | cameraTrack = cameraStream.getVideoTracks()[0]; 252 | currentVideoTrackRef.current = cameraTrack; 253 | } catch (err: any) { 254 | // console.error("Error getting camera after screen share:", err); 255 | cameraTrack = null; 256 | } 257 | } 258 | } 259 | 260 | if (videoSenderRef.current) { 261 | await videoSenderRef.current.replaceTrack(camOn ? cameraTrack : null); 262 | } 263 | 264 | if (localScreenShareRef.current) { 265 | localScreenShareRef.current.srcObject = null; 266 | } 267 | currentScreenShareTrackRef.current = null; 268 | localScreenShareStreamRef.current = null; 269 | 270 | toast.success("Screen Share Stopped", { 271 | description: "You have stopped sharing your screen" 272 | }); 273 | 274 | if (socket && roomId) { 275 | socket.emit("media-state-change", { 276 | isScreenSharing: false, 277 | micOn, 278 | camOn 279 | }); 280 | } 281 | }; 282 | 283 | } catch (error: any) { 284 | // console.error("Error starting screen share:", error); 285 | toast.error("Screen Share Error", { 286 | description: error?.message || "Failed to start screen sharing" 287 | }); 288 | setScreenShareOn(false); 289 | } 290 | } else { 291 | // Stop screen sharing manually 292 | if (currentScreenShareTrackRef.current) { 293 | currentScreenShareTrackRef.current.stop(); 294 | } 295 | if (localScreenShareStreamRef.current) { 296 | localScreenShareStreamRef.current.getTracks().forEach(t => t.stop()); 297 | localScreenShareStreamRef.current = null; 298 | } 299 | if (localScreenShareRef.current) { 300 | localScreenShareRef.current.srcObject = null; 301 | } 302 | 303 | let cameraTrack = currentVideoTrackRef.current; 304 | if (!cameraTrack || cameraTrack.readyState === "ended") { 305 | if (camOn) { 306 | try { 307 | const cameraStream = await navigator.mediaDevices.getUserMedia({ video: true }); 308 | cameraTrack = cameraStream.getVideoTracks()[0]; 309 | currentVideoTrackRef.current = cameraTrack; 310 | 311 | if (localVideoRef.current) { 312 | const ms = localVideoRef.current.srcObject as MediaStream || new MediaStream(); 313 | ms.getVideoTracks().forEach(t => ms.removeTrack(t)); 314 | ms.addTrack(cameraTrack); 315 | if (!localVideoRef.current.srcObject) localVideoRef.current.srcObject = ms; 316 | await localVideoRef.current.play().catch(() => {}); 317 | } 318 | } catch (err: any) { 319 | // console.error("Error getting camera after stopping screen share:", err); 320 | toast.error("Camera Error", { 321 | description: "Failed to restore camera after stopping screen share" 322 | }); 323 | cameraTrack = null; 324 | } 325 | } 326 | } 327 | 328 | if (videoSenderRef.current) { 329 | await videoSenderRef.current.replaceTrack(camOn ? cameraTrack : null); 330 | } 331 | 332 | if (socket && roomId) { 333 | socket.emit("media-state-change", { 334 | isScreenSharing: false, 335 | micOn, 336 | camOn 337 | }); 338 | } 339 | 340 | currentScreenShareTrackRef.current = null; 341 | } 342 | } catch (error: any) { 343 | // console.error("toggleScreenShare error", error); 344 | toast.error("Screen Share Error", { 345 | description: error?.message || "Failed to toggle screen sharing" 346 | }); 347 | setScreenShareOn(false); 348 | } 349 | }; 350 | 351 | const handleNext = () => { 352 | const s = socketRef.current; 353 | if (!s) return; 354 | 355 | const actualCamState = !!(currentVideoTrackRef.current && currentVideoTrackRef.current.readyState === "live" && camOn); 356 | const actualMicState = !!(localAudioTrack && localAudioTrack.readyState === "live" && micOn); 357 | 358 | try { 359 | remoteStreamRef.current?.getTracks().forEach((t) => t.stop()); 360 | } catch {} 361 | if (remoteVideoRef.current) remoteVideoRef.current.srcObject = null; 362 | if (remoteAudioRef.current) remoteAudioRef.current.srcObject = null; 363 | 364 | s.emit("queue:next"); 365 | handleNextConnection(actualCamState, actualMicState, "next"); 366 | }; 367 | 368 | const handleLeave = () => { 369 | const s = socketRef.current; 370 | 371 | try { 372 | s?.emit("queue:leave"); 373 | } catch {} 374 | 375 | if (screenShareOn) { 376 | if (currentScreenShareTrackRef.current) { 377 | try { 378 | currentScreenShareTrackRef.current.stop(); 379 | } catch {} 380 | } 381 | if (localScreenShareStreamRef.current) { 382 | try { 383 | localScreenShareStreamRef.current.getTracks().forEach(t => t.stop()); 384 | } catch {} 385 | } 386 | } 387 | 388 | teardownPeers( 389 | "teardown", 390 | sendingPcRef, 391 | receivingPcRef, 392 | remoteStreamRef, 393 | remoteVideoRef, 394 | remoteAudioRef, 395 | videoSenderRef, 396 | localScreenShareStreamRef, 397 | currentScreenShareTrackRef, 398 | localScreenShareRef, 399 | { 400 | setShowChat, 401 | setPeerMicOn, 402 | setPeerCamOn, 403 | setScreenShareOn, 404 | setPeerScreenShareOn, 405 | setLobby, 406 | setStatus 407 | } 408 | ); 409 | stopProvidedTracks(localVideoTrack, localAudioTrack, currentVideoTrackRef); 410 | detachLocalPreview(localVideoRef); 411 | 412 | try { 413 | s?.disconnect(); 414 | } catch {} 415 | socketRef.current = null; 416 | 417 | try { 418 | router.replace(`/match`); 419 | } catch (e) { 420 | try { 421 | router.replace(`/`); 422 | } catch {} 423 | } 424 | 425 | try { 426 | onLeave?.(); 427 | } catch {} 428 | }; 429 | 430 | const handleRecheck = () => { 431 | setLobby(true); 432 | setStatus("Rechecking…"); 433 | }; 434 | 435 | const handleReport = (reason?: string) => { 436 | const s = socketRef.current; 437 | const reporter = mySocketId || s?.id || null; 438 | const reported = peerIdRef.current || null; 439 | try { 440 | if (s && reporter) { 441 | s.emit("report", { reporterId: reporter, reportedId: reported, roomId, reason }); 442 | toast.success("Report submitted", { description: "Thank you. We received your report." }); 443 | } else { 444 | toast.error("Report failed", { description: "Could not submit report (no socket)." }); 445 | } 446 | } catch (e) { 447 | console.error("report emit error", e); 448 | try { toast.error("Report failed", { description: "An error occurred." }); } catch {} 449 | } 450 | }; 451 | 452 | function handleNextConnection(currentCamState: boolean, currentMicState: boolean, reason: "next" | "partner-left" = "next") { 453 | // Clear ICE candidate queues 454 | senderIceCandidatesQueue.current = []; 455 | receiverIceCandidatesQueue.current = []; 456 | 457 | teardownPeers( 458 | reason, 459 | sendingPcRef, 460 | receivingPcRef, 461 | remoteStreamRef, 462 | remoteVideoRef, 463 | remoteAudioRef, 464 | videoSenderRef, 465 | localScreenShareStreamRef, 466 | currentScreenShareTrackRef, 467 | localScreenShareRef, 468 | { 469 | setShowChat, 470 | setPeerMicOn, 471 | setPeerCamOn, 472 | setScreenShareOn: () => {}, // Don't reset screen share on next 473 | setPeerScreenShareOn, 474 | setLobby, 475 | setStatus 476 | } 477 | ); 478 | 479 | if (!currentCamState) { 480 | if (currentVideoTrackRef.current) { 481 | try { 482 | currentVideoTrackRef.current.stop(); 483 | currentVideoTrackRef.current = null; 484 | } catch (err) { 485 | // console.error("❌ Error stopping video track:", err); 486 | } 487 | } 488 | 489 | if (localVideoRef.current && localVideoRef.current.srcObject) { 490 | const ms = localVideoRef.current.srcObject as MediaStream; 491 | const videoTracks = ms.getVideoTracks(); 492 | for (const t of videoTracks) { 493 | try { 494 | t.stop(); 495 | ms.removeTrack(t); 496 | } catch (err) { 497 | // console.error("❌ Error stopping local preview track:", err); 498 | } 499 | } 500 | } 501 | } 502 | } 503 | 504 | // ===== EFFECTS ===== 505 | useEffect(() => { 506 | if (localVideoTrack) { 507 | currentVideoTrackRef.current = localVideoTrack; 508 | } 509 | }, [localVideoTrack]); 510 | 511 | 512 | 513 | useEffect(() => { 514 | const el = localVideoRef.current; 515 | if (!el) return; 516 | if (!localVideoTrack && !localAudioTrack) return; 517 | 518 | const stream = new MediaStream([ 519 | ...(localVideoTrack ? [localVideoTrack] : []), 520 | ...(localAudioTrack ? [localAudioTrack] : []), 521 | ]); 522 | 523 | el.srcObject = stream; 524 | el.muted = true; 525 | el.playsInline = true; 526 | 527 | const tryPlay = () => el.play().catch(() => {}); 528 | tryPlay(); 529 | 530 | const onceClick = () => { 531 | tryPlay(); 532 | window.removeEventListener("click", onceClick); 533 | }; 534 | window.addEventListener("click", onceClick, { once: true }); 535 | 536 | return () => window.removeEventListener("click", onceClick); 537 | }, [localAudioTrack, localVideoTrack]); 538 | 539 | useEffect(() => { 540 | if (!roomId || !socketRef.current) return; 541 | socketRef.current.emit("media:state", { roomId, state: { micOn, camOn } }); 542 | }, [micOn, camOn, roomId]); 543 | 544 | // Main socket connection effect - simplified, actual WebRTC logic would be here 545 | useEffect(() => { 546 | if (socketRef.current) return; 547 | 548 | const s = io(URL, { 549 | transports: ["websocket"], 550 | autoConnect: false, 551 | reconnection: true, 552 | reconnectionAttempts: 5, 553 | auth: { name }, 554 | }); 555 | 556 | socketRef.current = s; 557 | s.connect(); 558 | 559 | s.on("connect", () => { 560 | setMySocketId(s.id ?? null); 561 | if (!joinedRef.current) { 562 | joinedRef.current = true; 563 | } 564 | }); 565 | 566 | // ----- CALLER ----- 567 | s.on("send-offer", async ({ roomId: rid }) => { 568 | setRoomId(rid); 569 | setLobby(false); 570 | setStatus("Connecting…"); 571 | 572 | // Add a small delay to ensure any previous toasts are displayed 573 | setTimeout(() => { 574 | toast.success("Connected!", { 575 | id: "connected-toast-" + rid, // Unique ID per room 576 | description: "You've been connected to someone" 577 | }); 578 | // Emit chat join after a small delay to ensure listeners are attached 579 | setTimeout(() => { 580 | s.emit("chat:join", { roomId: rid, name }); 581 | }, 100); 582 | }, 100); 583 | 584 | const pc = new RTCPeerConnection(); 585 | sendingPcRef.current = pc; 586 | peerIdRef.current = rid; 587 | 588 | await setupPeerConnection(pc, true, rid, s); 589 | 590 | const offer = await pc.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true }); 591 | await pc.setLocalDescription(offer); 592 | s.emit("offer", { sdp: offer, roomId: rid }); 593 | }); 594 | 595 | // ----- ANSWERER ----- 596 | s.on("offer", async ({ roomId: rid, sdp: remoteSdp }) => { 597 | setRoomId(rid); 598 | setLobby(false); 599 | setStatus("Connecting…"); 600 | 601 | // Add a small delay to ensure any previous toasts are displayed 602 | setTimeout(() => { 603 | toast.success("Connected!", { 604 | id: "connected-toast-" + rid, // Unique ID per room 605 | description: "You've been connected to someone" 606 | }); 607 | // Emit chat join after a small delay to ensure listeners are attached 608 | setTimeout(() => { 609 | s.emit("chat:join", { roomId: rid, name }); 610 | }, 100); 611 | }, 100); 612 | 613 | const pc = new RTCPeerConnection(); 614 | receivingPcRef.current = pc; 615 | peerIdRef.current = rid; 616 | 617 | await setupPeerConnection(pc, false, rid, s); 618 | await pc.setRemoteDescription(new RTCSessionDescription(remoteSdp)); 619 | await processQueuedIceCandidates(pc, receiverIceCandidatesQueue.current); 620 | 621 | const answer = await pc.createAnswer(); 622 | await pc.setLocalDescription(answer); 623 | s.emit("answer", { roomId: rid, sdp: answer }); 624 | }); 625 | 626 | // caller receives answer 627 | s.on("answer", async ({ sdp: remoteSdp }) => { 628 | const pc = sendingPcRef.current; 629 | if (!pc) return; 630 | await pc.setRemoteDescription(new RTCSessionDescription(remoteSdp)); 631 | 632 | // Process any queued ICE candidates now that remote description is set 633 | await processQueuedIceCandidates(pc, senderIceCandidatesQueue.current); 634 | }); 635 | 636 | // trickle ICE 637 | s.on("add-ice-candidate", async ({ candidate, type }) => { 638 | try { 639 | const ice = new RTCIceCandidate(candidate); 640 | 641 | if (type === "sender") { 642 | const pc = receivingPcRef.current; 643 | if (pc && pc.remoteDescription) { 644 | await pc.addIceCandidate(ice); 645 | } else { 646 | // Queue the candidate until remote description is set 647 | receiverIceCandidatesQueue.current.push(ice); 648 | } 649 | } else { 650 | const pc = sendingPcRef.current; 651 | if (pc && pc.remoteDescription) { 652 | await pc.addIceCandidate(ice); 653 | } else { 654 | // Queue the candidate until remote description is set 655 | senderIceCandidatesQueue.current.push(ice); 656 | } 657 | } 658 | } catch (e) { 659 | // console.error("addIceCandidate error", e); 660 | } 661 | }); 662 | 663 | // Renegotiation handlers 664 | s.on("renegotiate-offer", async ({ sdp, role }) => { 665 | const pc = receivingPcRef.current; 666 | if (pc) { 667 | await pc.setRemoteDescription(new RTCSessionDescription(sdp)); 668 | const answer = await pc.createAnswer(); 669 | await pc.setLocalDescription(answer); 670 | s.emit("renegotiate-answer", { roomId, sdp: answer, role: "answerer" }); 671 | } 672 | }); 673 | 674 | s.on("renegotiate-answer", async ({ sdp, role }) => { 675 | const pc = sendingPcRef.current; 676 | if (pc) { 677 | await pc.setRemoteDescription(new RTCSessionDescription(sdp)); 678 | } 679 | }); 680 | 681 | // Simplified event handlers - full WebRTC logic would go here 682 | s.on("lobby", () => { 683 | setLobby(true); 684 | setStatus("Waiting to connect you to someone…"); 685 | }); 686 | 687 | s.on("queue:waiting", () => { 688 | setLobby(true); 689 | setStatus("Searching for the best match…"); 690 | }); 691 | 692 | s.on("queue:timeout", ({ message }: { message: string }) => { 693 | setTimeoutMessage(message); 694 | setShowTimeoutAlert(true); 695 | setLobby(true); 696 | setStatus("No match found. Try again?"); 697 | }); 698 | 699 | s.on("partner:left", () => { 700 | toast.warning("Partner Left", { 701 | id: "partner-left-toast-" + Date.now(), // Unique ID to prevent duplicates 702 | description: "Your partner has left the call" 703 | }); 704 | const actualCamState = !!(currentVideoTrackRef.current && currentVideoTrackRef.current.readyState === "live" && camOn); 705 | const actualMicState = !!(localAudioTrack && localAudioTrack.readyState === "live" && micOn); 706 | handleNextConnection(actualCamState, actualMicState, "partner-left"); 707 | }); 708 | 709 | s.on("peer:media-state", ({ state }: { state: { micOn?: boolean; camOn?: boolean } }) => { 710 | if (typeof state?.micOn === "boolean") setPeerMicOn(state.micOn); 711 | if (typeof state?.camOn === "boolean") setPeerCamOn(state.camOn); 712 | }); 713 | 714 | s.on("peer-media-state-change", ({ isScreenSharing, micOn: peerMic, camOn: peerCam, from, userId }) => { 715 | if (typeof isScreenSharing === "boolean") { 716 | setPeerScreenShareOn(isScreenSharing); 717 | } 718 | if (typeof peerMic === "boolean") { 719 | setPeerMicOn(peerMic); 720 | } 721 | if (typeof peerCam === "boolean") { 722 | setPeerCamOn(peerCam); 723 | } 724 | }); 725 | 726 | const onBeforeUnload = () => { 727 | try { 728 | s.emit("queue:leave"); 729 | } catch {} 730 | stopProvidedTracks(localVideoTrack, localAudioTrack, currentVideoTrackRef); 731 | detachLocalPreview(localVideoRef); 732 | }; 733 | window.addEventListener("beforeunload", onBeforeUnload); 734 | 735 | return () => { 736 | window.removeEventListener("beforeunload", onBeforeUnload); 737 | s.disconnect(); 738 | socketRef.current = null; 739 | detachLocalPreview(localVideoRef); 740 | }; 741 | }, [name, localAudioTrack, localVideoTrack]); 742 | 743 | // ===== RENDER ===== 744 | return ( 745 |
746 | {/* Main Content Area */} 747 |
748 |
751 | 752 | 764 | 765 | {/* Hidden remote audio */} 766 |
768 | 769 | {/* Chat Drawer */} 770 |
775 |
776 | 784 |
785 |
786 |
787 | 788 | setShowChat((v) => !v)} 795 | onRecheck={handleRecheck} 796 | onNext={handleNext} 797 | onLeave={handleLeave} 798 | onReport={() => handleReport()} 799 | /> 800 | 801 | 808 |
809 | ); 810 | } --------------------------------------------------------------------------------