├── src ├── vite-env.d.ts ├── index.css ├── lib │ ├── index.ts │ ├── utils.ts │ ├── handleKeyPress.ts │ ├── sendMsg.ts │ ├── createRoom.ts │ └── joinRoom.ts ├── main.tsx ├── components │ └── ui │ │ ├── input.tsx │ │ ├── toaster.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ └── toast.tsx ├── App.css ├── assets │ └── react.svg ├── hooks │ └── use-toast.ts └── App.tsx ├── postcss.config.js ├── tsconfig.json ├── vite.config.ts ├── index.html ├── .gitignore ├── public ├── index.html ├── vite.svg └── rtc-icon.svg ├── components.json ├── tsconfig.node.json ├── eslint.config.js ├── tsconfig.app.json ├── package.json ├── tailwind.config.js └── README.md /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | @layer base { 5 | :root { 6 | --radius: 0.5rem 7 | } 8 | } -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { sendMsg } from "./sendMsg"; 2 | export { createRoom } from "./createRoom"; 3 | export { joinRoom } from "./joinRoom"; 4 | export { handleKeyPress } from "./handleKeyPress"; -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ], 7 | "compilerOptions": { 8 | "baseUrl": ".", 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import react from "@vitejs/plugin-react" 3 | import { defineConfig } from "vite" 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | resolve: { 8 | alias: { 9 | "@": path.resolve(__dirname, "./src"), 10 | }, 11 | }, 12 | }) 13 | -------------------------------------------------------------------------------- /src/lib/handleKeyPress.ts: -------------------------------------------------------------------------------- 1 | 2 | import { sendMsg } from "./sendMsg"; 3 | 4 | export function handleKeyPress( 5 | e: React.KeyboardEvent, 6 | ws: WebSocket | null, 7 | currentMsg: string, 8 | username: string, 9 | setCMsg: (msg: string) => void 10 | ) { 11 | if (e.key === "Enter" && currentMsg.trim() !== "") { 12 | sendMsg(ws, currentMsg, username, setCMsg); 13 | } 14 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Room Chat 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .env 27 | .vercel 28 | 29 | # Local Netlify folder 30 | .netlify 31 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/lib/sendMsg.ts: -------------------------------------------------------------------------------- 1 | 2 | export function sendMsg( 3 | ws: WebSocket | null, 4 | currentMsg: string, 5 | username: string, 6 | setCMsg: (msg: string) => void 7 | ) { 8 | if (currentMsg == "") { 9 | return; 10 | } 11 | if (!ws) { 12 | return; 13 | } 14 | ws.send( 15 | JSON.stringify({ 16 | type: "chat", 17 | payload: { 18 | name: username, 19 | message: currentMsg, 20 | }, 21 | }) 22 | ); 23 | setCMsg(""); 24 | } -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | "baseUrl": ".", 10 | "paths": { 11 | "@/*": ["./src/*"] 12 | }, 13 | 14 | /* Bundler mode */ 15 | "moduleResolution": "bundler", 16 | "allowImportingTsExtensions": true, 17 | "isolatedModules": true, 18 | "moduleDetection": "force", 19 | "noEmit": true, 20 | "jsx": "react-jsx", 21 | 22 | /* Linting */ 23 | "strict": true, 24 | "noUnusedLocals": true, 25 | "noUnusedParameters": true, 26 | "noFallthroughCasesInSwitch": true, 27 | "noUncheckedSideEffectImports": true 28 | }, 29 | "include": ["src"] 30 | } 31 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /src/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | import { useToast } from "@/hooks/use-toast" 2 | import { 3 | Toast, 4 | ToastClose, 5 | ToastDescription, 6 | ToastProvider, 7 | ToastTitle, 8 | ToastViewport, 9 | } from "@/components/ui/toast" 10 | 11 | export function Toaster() { 12 | const { toasts } = useToast() 13 | 14 | return ( 15 | 16 | {toasts.map(function ({ id, title, description, action, ...props }) { 17 | return ( 18 | 19 |
20 | {title && {title}} 21 | {description && ( 22 | {description} 23 | )} 24 |
25 | {action} 26 | 27 |
28 | ) 29 | })} 30 | 31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Lexend:wght@100..900&family=Oswald:wght@200..700&family=Roboto+Condensed:ital,wght@0,100..900;1,100..900&display=swap'); 2 | 3 | 4 | #root { 5 | margin: 0 auto; 6 | text-align: center; 7 | font-family: "Lexend", sans-serif; 8 | font-optical-sizing: auto; 9 | font-style: normal; 10 | } 11 | 12 | .logo { 13 | height: 6em; 14 | padding: 1.5em; 15 | will-change: filter; 16 | transition: filter 300ms; 17 | } 18 | .logo:hover { 19 | filter: drop-shadow(0 0 2em #646cffaa); 20 | } 21 | .logo.react:hover { 22 | filter: drop-shadow(0 0 2em #61dafbaa); 23 | } 24 | 25 | @keyframes logo-spin { 26 | from { 27 | transform: rotate(0deg); 28 | } 29 | to { 30 | transform: rotate(360deg); 31 | } 32 | } 33 | 34 | @media (prefers-reduced-motion: no-preference) { 35 | a:nth-of-type(2) .logo { 36 | animation: logo-spin infinite 20s linear; 37 | } 38 | } 39 | 40 | .card { 41 | padding: 2em; 42 | } 43 | 44 | .read-the-docs { 45 | color: #888; 46 | } 47 | -------------------------------------------------------------------------------- /src/lib/createRoom.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Bounce, toast } from "react-toastify"; 3 | import { joinRoom } from "./joinRoom"; 4 | 5 | export function createRoom( 6 | ws: WebSocket | null, 7 | username: string, 8 | setCon: (con: boolean) => void 9 | ) { 10 | if (username == "") { 11 | toast.error("Please enter your name", { 12 | position: "top-left", 13 | autoClose: 2000, 14 | hideProgressBar: false, 15 | closeOnClick: true, 16 | pauseOnHover: false, 17 | draggable: true, 18 | progress: 0, 19 | theme: "light", 20 | transition: Bounce, 21 | }); 22 | return; 23 | } 24 | const chars = [ 25 | "A","B","C","D","E","F","G","H","I","J","K","L", 26 | "M","N","O","P","Q","R","S","T","U","V","W","X", 27 | "Y","Z","0","1","2","3","4","5","6","7","8","9", 28 | ]; 29 | 30 | let id = ""; 31 | 32 | for (let i = 0; i < 5; i++) { 33 | const randomIndex = Math.floor(Math.random() * chars.length); 34 | id += chars[randomIndex]; 35 | } 36 | joinRoom(ws, username, id, setCon); 37 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "relay-chat", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@radix-ui/react-slot": "^1.1.1", 14 | "@radix-ui/react-toast": "^1.2.3", 15 | "class-variance-authority": "^0.7.1", 16 | "clsx": "^2.1.1", 17 | "framer-motion": "^11.15.0", 18 | "lucide-react": "^0.468.0", 19 | "react": "^18.3.1", 20 | "react-dom": "^18.3.1", 21 | "react-router-dom": "^7.0.2", 22 | "react-toastify": "^10.0.6", 23 | "tailwind-merge": "^2.5.5", 24 | "tailwindcss-animate": "^1.0.7" 25 | }, 26 | "devDependencies": { 27 | "@eslint/js": "^9.15.0", 28 | "@types/node": "^22.10.2", 29 | "@types/react": "^18.3.12", 30 | "@types/react-dom": "^18.3.1", 31 | "@vitejs/plugin-react": "^4.3.4", 32 | "autoprefixer": "^10.4.20", 33 | "eslint": "^9.15.0", 34 | "eslint-plugin-react-hooks": "^5.0.0", 35 | "eslint-plugin-react-refresh": "^0.4.14", 36 | "globals": "^15.12.0", 37 | "postcss": "^8.4.49", 38 | "tailwindcss": "^3.4.16", 39 | "typescript": "~5.6.2", 40 | "typescript-eslint": "^8.15.0", 41 | "vite": "^6.0.1" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/joinRoom.ts: -------------------------------------------------------------------------------- 1 | import { Bounce, toast } from "react-toastify"; 2 | 3 | export function joinRoom( 4 | ws: WebSocket | null, 5 | username: string, 6 | roomid: string, 7 | setCon: (con: boolean) => void, 8 | ) { 9 | if (roomid == "") { 10 | toast.error("Please enter a room id", { 11 | position: "top-left", 12 | autoClose: 2000, 13 | hideProgressBar: false, 14 | closeOnClick: true, 15 | pauseOnHover: false, 16 | draggable: true, 17 | progress: 0, 18 | theme: "light", 19 | transition: Bounce, 20 | }); 21 | return; 22 | } 23 | if (username == "") { 24 | toast.error("Please enter your name", { 25 | position: "top-left", 26 | autoClose: 2000, 27 | hideProgressBar: false, 28 | closeOnClick: true, 29 | pauseOnHover: false, 30 | draggable: true, 31 | progress: 0, 32 | theme: "light", 33 | transition: Bounce, 34 | }); 35 | return; 36 | } 37 | if (!ws) { 38 | return; 39 | } 40 | const message = { 41 | type: "join", 42 | payload: { 43 | roomid: roomid, 44 | }, 45 | }; 46 | setCon(true); 47 | localStorage.setItem("roomid", roomid); 48 | localStorage.setItem("name", username); 49 | ws.send(JSON.stringify(message)); 50 | toast.success("Room joined, Room ID: " + localStorage.getItem("roomid"), { 51 | position: "top-left", 52 | autoClose: 2000, 53 | hideProgressBar: false, 54 | closeOnClick: true, 55 | pauseOnHover: false, 56 | draggable: true, 57 | progress: 0, 58 | theme: "light", 59 | transition: Bounce, 60 | }); 61 | } -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | darkMode: ["class"], 4 | content: [ 5 | "./index.html", 6 | "./src/**/*.{js,ts,jsx,tsx}", 7 | ], 8 | theme: { 9 | extend: { 10 | borderRadius: { 11 | lg: 'var(--radius)', 12 | md: 'calc(var(--radius) - 2px)', 13 | sm: 'calc(var(--radius) - 4px)' 14 | }, 15 | colors: { 16 | background: 'hsl(var(--background))', 17 | foreground: 'hsl(var(--foreground))', 18 | card: { 19 | DEFAULT: 'hsl(var(--card))', 20 | foreground: 'hsl(var(--card-foreground))' 21 | }, 22 | popover: { 23 | DEFAULT: 'hsl(var(--popover))', 24 | foreground: 'hsl(var(--popover-foreground))' 25 | }, 26 | primary: { 27 | DEFAULT: 'hsl(var(--primary))', 28 | foreground: 'hsl(var(--primary-foreground))' 29 | }, 30 | secondary: { 31 | DEFAULT: 'hsl(var(--secondary))', 32 | foreground: 'hsl(var(--secondary-foreground))' 33 | }, 34 | muted: { 35 | DEFAULT: 'hsl(var(--muted))', 36 | foreground: 'hsl(var(--muted-foreground))' 37 | }, 38 | accent: { 39 | DEFAULT: 'hsl(var(--accent))', 40 | foreground: 'hsl(var(--accent-foreground))' 41 | }, 42 | destructive: { 43 | DEFAULT: 'hsl(var(--destructive))', 44 | foreground: 'hsl(var(--destructive-foreground))' 45 | }, 46 | border: 'hsl(var(--border))', 47 | input: 'hsl(var(--input))', 48 | ring: 'hsl(var(--ring))', 49 | chart: { 50 | '1': 'hsl(var(--chart-1))', 51 | '2': 'hsl(var(--chart-2))', 52 | '3': 'hsl(var(--chart-3))', 53 | '4': 'hsl(var(--chart-4))', 54 | '5': 'hsl(var(--chart-5))' 55 | } 56 | } 57 | } 58 | }, 59 | plugins: [require("tailwindcss-animate")], 60 | } -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLDivElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |
53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |
61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # 💬 Relay-Chat Typescript/React 3 | 4 | RTC Terminal is a modern real-time chat application that enables users to create or join chat rooms and communicate instantly with other participants. The application features a clean, responsive interface and supports real-time message delivery using WebSocket technology. 5 | 6 | --- 7 | 8 | ## 🚀 Getting Started 9 | 10 | 11 | 2. **Cloning the Repository**: 12 | ```bash 13 | git clone https://github.com/yashksaini-coder/relay-chat 14 | ``` 15 | 16 | 2. **Installing Dependencies**: 17 | ```bash 18 | npm install 19 | ``` 20 | 21 | 3. **Running the application**: 22 | ``` 23 | npm run dev 24 | ``` 25 | 26 | 4. **Click on the local deployment URL of the Application**: 27 | ```bash 28 | https://localhost/5173 29 | ``` 30 | 31 | --- 32 | 33 | ## ✨ Features 34 | 35 | ### 🏠 Room Management 36 | - 🆕 Create new rooms with auto-generated unique room IDs 37 | - 🔗 Join existing rooms using room IDs 38 | - 📋 Copy room IDs to clipboard for easy sharing 39 | - 👥 Real-time user connection tracking 40 | 41 | ### 💬 Chat Features 42 | - ⚡ Real-time message delivery 43 | - 🖼️ User-friendly message interface with distinct styling for sent/received messages 44 | - 👨‍👩‍👧‍👦 Support for multiple users in the same room 45 | - 📝 Username display for each message 46 | - ⏎ Enter key support for sending messages 47 | 48 | ### 🎨 UI/UX 49 | - 🌓 Clean, modern interface with dark theme 50 | - 📱 Responsive design that works on mobile and desktop 51 | - 🔔 Toast notifications for important actions 52 | - 🎢 Smooth transitions and hover effects 53 | - 📜 Scrollable message history 54 | 55 | 56 | ## 🛠️ Tech Stack 57 | 58 | ### 🌐 Frontend 59 | | React | TypeScript | Tailwind CSS | Vite | React Toastify | Lucide React | 60 | | :---: | :--------: | :----------: | :--: | :------------: | :----------: | 61 | | ![React](https://skillicons.dev/icons?i=react) | ![TypeScript](https://skillicons.dev/icons?i=ts) | ![Tailwind CSS](https://skillicons.dev/icons?i=tailwind) | ![Vite](https://skillicons.dev/icons?i=vite) | ![React Toastify](https://skillicons.dev/icons?i=react) | ![Lucide React](https://github.com/user-attachments/assets/f4ad1606-9ad2-4726-910d-7843e45e8f9f) | 62 | 63 | ## 🏗️ Architecture 64 | 65 | ### 🌐 Frontend Architecture 66 | The frontend is built as a single-page application (SPA) with React. Key components include: 67 | - 🔗 Connection management with WebSocket 68 | - 🗃️ State management using React hooks 69 | - 📐 Responsive UI components 70 | - ⚡ Real-time message handling and display 71 | 72 | ### 🖥️ Backend Architecture 73 | The backend implements a WebSocket server that handles: 74 | - 👥 User connections and disconnections 75 | - 🏠 Room management 76 | - 📡 Message broadcasting to room participants 77 | - 🔢 User count tracking 78 | 79 | ### 📡 Communication Protocol 80 | The application uses a simple message protocol over WebSocket: 81 | 82 | #### 🚪 Join Room Message 83 | ```json 84 | { 85 | "type": "join", 86 | "payload": { 87 | "roomid": "ROOM_ID" 88 | } 89 | } 90 | ``` 91 | 92 | #### 💬 Chat Message 93 | ```json 94 | { 95 | "type": "chat", 96 | "payload": { 97 | "name": "USERNAME", 98 | "message": "MESSAGE_CONTENT" 99 | } 100 | } 101 | ``` 102 | 103 | ## 🔒 Security Features 104 | - 🔐 Secure WebSocket connections (WSS) 105 | - 🛡️ Input validation 106 | - 🚪 Room isolation (messages only broadcast to users in the same room) 107 | 108 | 109 | ## 🌍 Deployment 110 | - 🌐 Frontend deployed on Vercel 111 | - 🖥️ Backend deployed on Render 112 | - 🔧 WebSocket server configured for production use 113 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/hooks/use-toast.ts: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | // Inspired by react-hot-toast library 4 | import * as React from "react" 5 | 6 | import type { 7 | ToastActionElement, 8 | ToastProps, 9 | } from "@/components/ui/toast" 10 | 11 | const TOAST_LIMIT = 1 12 | const TOAST_REMOVE_DELAY = 1000000 13 | 14 | type ToasterToast = ToastProps & { 15 | id: string 16 | title?: React.ReactNode 17 | description?: React.ReactNode 18 | action?: ToastActionElement 19 | } 20 | 21 | const actionTypes = { 22 | ADD_TOAST: "ADD_TOAST", 23 | UPDATE_TOAST: "UPDATE_TOAST", 24 | DISMISS_TOAST: "DISMISS_TOAST", 25 | REMOVE_TOAST: "REMOVE_TOAST", 26 | } as const 27 | 28 | let count = 0 29 | 30 | function genId() { 31 | count = (count + 1) % Number.MAX_SAFE_INTEGER 32 | return count.toString() 33 | } 34 | 35 | type ActionType = typeof actionTypes 36 | 37 | type Action = 38 | | { 39 | type: ActionType["ADD_TOAST"] 40 | toast: ToasterToast 41 | } 42 | | { 43 | type: ActionType["UPDATE_TOAST"] 44 | toast: Partial 45 | } 46 | | { 47 | type: ActionType["DISMISS_TOAST"] 48 | toastId?: ToasterToast["id"] 49 | } 50 | | { 51 | type: ActionType["REMOVE_TOAST"] 52 | toastId?: ToasterToast["id"] 53 | } 54 | 55 | interface State { 56 | toasts: ToasterToast[] 57 | } 58 | 59 | const toastTimeouts = new Map>() 60 | 61 | const addToRemoveQueue = (toastId: string) => { 62 | if (toastTimeouts.has(toastId)) { 63 | return 64 | } 65 | 66 | const timeout = setTimeout(() => { 67 | toastTimeouts.delete(toastId) 68 | dispatch({ 69 | type: "REMOVE_TOAST", 70 | toastId: toastId, 71 | }) 72 | }, TOAST_REMOVE_DELAY) 73 | 74 | toastTimeouts.set(toastId, timeout) 75 | } 76 | 77 | export const reducer = (state: State, action: Action): State => { 78 | switch (action.type) { 79 | case "ADD_TOAST": 80 | return { 81 | ...state, 82 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 83 | } 84 | 85 | case "UPDATE_TOAST": 86 | return { 87 | ...state, 88 | toasts: state.toasts.map((t) => 89 | t.id === action.toast.id ? { ...t, ...action.toast } : t 90 | ), 91 | } 92 | 93 | case "DISMISS_TOAST": { 94 | const { toastId } = action 95 | 96 | // ! Side effects ! - This could be extracted into a dismissToast() action, 97 | // but I'll keep it here for simplicity 98 | if (toastId) { 99 | addToRemoveQueue(toastId) 100 | } else { 101 | state.toasts.forEach((toast) => { 102 | addToRemoveQueue(toast.id) 103 | }) 104 | } 105 | 106 | return { 107 | ...state, 108 | toasts: state.toasts.map((t) => 109 | t.id === toastId || toastId === undefined 110 | ? { 111 | ...t, 112 | open: false, 113 | } 114 | : t 115 | ), 116 | } 117 | } 118 | case "REMOVE_TOAST": 119 | if (action.toastId === undefined) { 120 | return { 121 | ...state, 122 | toasts: [], 123 | } 124 | } 125 | return { 126 | ...state, 127 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 128 | } 129 | } 130 | } 131 | 132 | const listeners: Array<(state: State) => void> = [] 133 | 134 | let memoryState: State = { toasts: [] } 135 | 136 | function dispatch(action: Action) { 137 | memoryState = reducer(memoryState, action) 138 | listeners.forEach((listener) => { 139 | listener(memoryState) 140 | }) 141 | } 142 | 143 | type Toast = Omit 144 | 145 | function toast({ ...props }: Toast) { 146 | const id = genId() 147 | 148 | const update = (props: ToasterToast) => 149 | dispatch({ 150 | type: "UPDATE_TOAST", 151 | toast: { ...props, id }, 152 | }) 153 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) 154 | 155 | dispatch({ 156 | type: "ADD_TOAST", 157 | toast: { 158 | ...props, 159 | id, 160 | open: true, 161 | onOpenChange: (open) => { 162 | if (!open) dismiss() 163 | }, 164 | }, 165 | }) 166 | 167 | return { 168 | id: id, 169 | dismiss, 170 | update, 171 | } 172 | } 173 | 174 | function useToast() { 175 | const [state, setState] = React.useState(memoryState) 176 | 177 | React.useEffect(() => { 178 | listeners.push(setState) 179 | return () => { 180 | const index = listeners.indexOf(setState) 181 | if (index > -1) { 182 | listeners.splice(index, 1) 183 | } 184 | } 185 | }, [state]) 186 | 187 | return { 188 | ...state, 189 | toast, 190 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), 191 | } 192 | } 193 | 194 | export { useToast, toast } 195 | -------------------------------------------------------------------------------- /src/components/ui/toast.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ToastPrimitives from "@radix-ui/react-toast" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | import { X } from "lucide-react" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const ToastProvider = ToastPrimitives.Provider 9 | 10 | const ToastViewport = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )) 23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName 24 | 25 | const toastVariants = cva( 26 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", 27 | { 28 | variants: { 29 | variant: { 30 | default: "border bg-background text-foreground", 31 | destructive: 32 | "destructive group border-destructive bg-destructive text-destructive-foreground", 33 | }, 34 | }, 35 | defaultVariants: { 36 | variant: "default", 37 | }, 38 | } 39 | ) 40 | 41 | const Toast = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef & 44 | VariantProps 45 | >(({ className, variant, ...props }, ref) => { 46 | return ( 47 | 52 | ) 53 | }) 54 | Toast.displayName = ToastPrimitives.Root.displayName 55 | 56 | const ToastAction = React.forwardRef< 57 | React.ElementRef, 58 | React.ComponentPropsWithoutRef 59 | >(({ className, ...props }, ref) => ( 60 | 68 | )) 69 | ToastAction.displayName = ToastPrimitives.Action.displayName 70 | 71 | const ToastClose = React.forwardRef< 72 | React.ElementRef, 73 | React.ComponentPropsWithoutRef 74 | >(({ className, ...props }, ref) => ( 75 | 84 | 85 | 86 | )) 87 | ToastClose.displayName = ToastPrimitives.Close.displayName 88 | 89 | const ToastTitle = React.forwardRef< 90 | React.ElementRef, 91 | React.ComponentPropsWithoutRef 92 | >(({ className, ...props }, ref) => ( 93 | 98 | )) 99 | ToastTitle.displayName = ToastPrimitives.Title.displayName 100 | 101 | const ToastDescription = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )) 111 | ToastDescription.displayName = ToastPrimitives.Description.displayName 112 | 113 | type ToastProps = React.ComponentPropsWithoutRef 114 | 115 | type ToastActionElement = React.ReactElement 116 | 117 | export { 118 | type ToastProps, 119 | type ToastActionElement, 120 | ToastProvider, 121 | ToastViewport, 122 | Toast, 123 | ToastTitle, 124 | ToastDescription, 125 | ToastClose, 126 | ToastAction, 127 | } 128 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { toast, ToastContainer } from "react-toastify"; 3 | import { Send, Copy, Plus, Users } from "lucide-react"; 4 | import "react-toastify/dist/ReactToastify.css"; 5 | import "./App.css"; 6 | import { sendMsg, createRoom, joinRoom, handleKeyPress } from "./lib"; 7 | import { Input } from "@/components/ui/input"; 8 | import { Button } from "@/components/ui/button"; 9 | import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; 10 | 11 | function App() { 12 | const [connected, setConnected] = useState(false); 13 | const [roomid, setRoom] = useState(""); 14 | const [username, setUsername] = useState(""); 15 | const [ws, setWs] = useState(null); 16 | const [msgs, setMsg] = useState([]); 17 | const [currentMsg, setCMsg] = useState(""); 18 | 19 | interface Messages { 20 | name: string; 21 | message: string; 22 | } 23 | 24 | useEffect(() => { 25 | // const wsUrl = import.meta.env.VITE_BACKEND_URL; 26 | const wsUrl = "https://relay-chat-backend.onrender.com/"; 27 | if (!wsUrl) { 28 | console.error("WebSocket URL is not defined"); 29 | return; 30 | } 31 | const ws = new WebSocket(wsUrl); 32 | setWs(ws); 33 | 34 | ws.onmessage = (e) => { 35 | const data = JSON.parse(e.data); 36 | setMsg((prev) => [...prev, data]); 37 | }; 38 | }, []); 39 | 40 | const handleJoinRoom = () => { 41 | if (!username) { 42 | toast.error("Please enter a username"); 43 | return; 44 | } 45 | joinRoom(ws, username, roomid, setConnected); 46 | toast.success("Joined room successfully!"); 47 | }; 48 | 49 | const handleCreateRoom = () => { 50 | if (!username) { 51 | toast.error("Please enter a username"); 52 | return; 53 | } 54 | createRoom(ws, username, setConnected); 55 | // toast.success("Room created successfully!"); 56 | }; 57 | 58 | const handleSendMsg = () => { 59 | sendMsg(ws, currentMsg, username, setCMsg); 60 | }; 61 | 62 | return ( 63 |
64 | 65 | {!connected ? ( 66 | 67 | 68 |

69 | RTC Terminal 70 |

71 |
72 | 73 |
74 | 75 | setUsername(e.target.value)} 81 | /> 82 |
83 |
84 | setRoom(e.target.value)} 89 | /> 90 | 97 |
98 |
99 |
100 | or 101 |
102 |
103 | 111 |
112 |
113 | ) : ( 114 |
115 | 116 | 117 |
118 |
Room ID: {localStorage.getItem("roomid")}
119 | 131 |
132 |
133 |
134 | 135 | 136 | {msgs.map((e, index) => { 137 | const isUserMessage = localStorage.getItem("name") === e.name; 138 | return ( 139 |
143 | {e.name} 144 |
148 | {e.message} 149 |
150 |
151 | ); 152 | })} 153 |
154 |
155 | 156 | setCMsg(e.target.value)} 162 | onKeyDown={(e) => handleKeyPress(e, ws, currentMsg, username, setCMsg)} 163 | /> 164 | 171 | 172 |
173 | )} 174 |
175 | ); 176 | } 177 | 178 | export default App; 179 | -------------------------------------------------------------------------------- /public/rtc-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 125 | 130 | 146 | 147 | 149 | 151 | 153 | 155 | 157 | 158 | 168 | 170 | 172 | 174 | 176 | 178 | 180 | 182 | 184 | 186 | 188 | 190 | 192 | 194 | 196 | 198 | 200 | 202 | 204 | 207 | 209 | 211 | 214 | 218 | 220 | 227 | 229 | 231 | 232 | 233 | --------------------------------------------------------------------------------