diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 4272c3a3..cae0cecc 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -124,7 +124,9 @@ func setupRouter(cfg *config.Config) *gin.Engine {
auth.GET("/rooms", routes.GetRoomsHandler)
auth.POST("/rooms", routes.CreateRoomHandler)
+ auth.POST("/rooms/challenge", routes.CreateChallengeHandler)
auth.POST("/rooms/:id/join", routes.JoinRoomHandler)
+ auth.POST("/rooms/:id/rematch", routes.RematchHandler)
auth.GET("/rooms/:id/participants", routes.GetRoomParticipantsHandler)
routes.SetupTeamRoutes(auth)
diff --git a/backend/routes/rooms.go b/backend/routes/rooms.go
index b4df0c75..c345702a 100644
--- a/backend/routes/rooms.go
+++ b/backend/routes/rooms.go
@@ -2,10 +2,13 @@ package routes
import (
"context"
+ "crypto/rand"
+ "encoding/hex"
"math"
- "math/rand"
+ "math/big"
"net/http"
"strconv"
+ "strings"
"time"
"arguehub/db"
@@ -14,15 +17,20 @@ import (
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
+ "go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
// Room represents a debate room.
type Room struct {
- ID string `json:"id" bson:"_id"`
- Type string `json:"type" bson:"type"`
- OwnerID string `json:"ownerId" bson:"ownerId"`
- Participants []Participant `json:"participants" bson:"participants"`
+ ID string `json:"id" bson:"_id"`
+ Type string `json:"type" bson:"type"`
+ OwnerID string `json:"ownerId" bson:"ownerId"`
+ Participants []Participant `json:"participants" bson:"participants"`
+ InviteToken string `json:"inviteToken,omitempty" bson:"inviteToken,omitempty"`
+ Topic string `json:"topic,omitempty" bson:"topic,omitempty"`
+ InvitedUsername string `json:"invitedUsername,omitempty" bson:"invitedUsername,omitempty"`
+ RematchOfRoomID string `json:"rematchOfRoomId,omitempty" bson:"rematchOfRoomId,omitempty"`
}
// Participant represents a user in a room.
@@ -36,8 +44,44 @@ type Participant struct {
// generateRoomID creates a random six-digit room ID as a string.
func generateRoomID() string {
- rand.Seed(time.Now().UnixNano())
- return strconv.Itoa(rand.Intn(900000) + 100000)
+ n, err := rand.Int(rand.Reader, big.NewInt(900000))
+ if err != nil {
+ return strconv.FormatInt(time.Now().UnixNano()%900000+100000, 10)
+ }
+ return strconv.Itoa(int(n.Int64()) + 100000)
+}
+
+func generateInviteToken() string {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ return strconv.FormatInt(time.Now().UnixNano(), 36)
+ }
+ return hex.EncodeToString(b)
+}
+
+type roomUser struct {
+ ID primitive.ObjectID `bson:"_id"`
+ Email string `bson:"email"`
+ DisplayName string `bson:"displayName"`
+ Rating float64 `bson:"rating"`
+ AvatarURL string `bson:"avatarUrl"`
+}
+
+func fetchUserByEmail(ctx context.Context, email string) (roomUser, error) {
+ userCollection := db.MongoDatabase.Collection("users")
+ var user roomUser
+ err := userCollection.FindOne(ctx, bson.M{"email": email}).Decode(&user)
+ return user, err
+}
+
+func userToParticipant(user roomUser) Participant {
+ return Participant{
+ ID: user.ID.Hex(),
+ Username: user.DisplayName,
+ Elo: int(math.Round(user.Rating)),
+ AvatarURL: user.AvatarURL,
+ Email: user.Email,
+ }
}
// CreateRoomHandler handles POST /rooms and creates a new debate room.
@@ -61,7 +105,7 @@ func CreateRoomHandler(c *gin.Context) {
}
// Query user document using email
- userCollection := db.MongoClient.Database("DebateAI").Collection("users")
+ userCollection := db.MongoDatabase.Collection("users")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -96,7 +140,7 @@ func CreateRoomHandler(c *gin.Context) {
Participants: []Participant{creatorParticipant},
}
- roomCollection := db.MongoClient.Database("DebateAI").Collection("rooms")
+ roomCollection := db.MongoDatabase.Collection("rooms")
_, err = roomCollection.InsertOne(ctx, newRoom)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create room"})
@@ -109,11 +153,11 @@ func CreateRoomHandler(c *gin.Context) {
// GetRoomsHandler handles GET /rooms and returns all rooms.
func GetRoomsHandler(c *gin.Context) {
- collection := db.MongoClient.Database("DebateAI").Collection("rooms")
+ collection := db.MongoDatabase.Collection("rooms")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
- cursor, err := collection.Find(ctx, bson.D{})
+ cursor, err := collection.Find(ctx, bson.M{"type": bson.M{"$ne": "invite"}})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Error fetching rooms"})
return
@@ -132,49 +176,94 @@ func GetRoomsHandler(c *gin.Context) {
func JoinRoomHandler(c *gin.Context) {
roomId := c.Param("id")
- // Get user email from middleware-set context
+ type JoinRoomInput struct {
+ InviteToken string `json:"inviteToken"`
+ }
+ var input JoinRoomInput
+ _ = c.ShouldBindJSON(&input)
+
email, exists := c.Get("email")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: user email not found"})
return
}
- // Query user document using email
- userCollection := db.MongoClient.Database("DebateAI").Collection("users")
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
-
emailStr, ok := email.(string)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid email format"})
return
}
- var user struct {
- ID primitive.ObjectID `bson:"_id"`
- Email string `bson:"email"`
- DisplayName string `bson:"displayName"`
- Rating float64 `bson:"rating"`
- AvatarURL string `bson:"avatarUrl"`
- }
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
- err := userCollection.FindOne(ctx, bson.M{"email": emailStr}).Decode(&user)
+ user, err := fetchUserByEmail(ctx, emailStr)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
- // Create participant
- participant := Participant{
- ID: user.ID.Hex(),
- Username: user.DisplayName,
- Elo: int(math.Round(user.Rating)),
- AvatarURL: user.AvatarURL,
- Email: user.Email,
+ participant := userToParticipant(user)
+
+ roomCollection := db.MongoDatabase.Collection("rooms")
+ var room Room
+ if err := roomCollection.FindOne(ctx, bson.M{"_id": roomId}).Decode(&room); err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Room not found"})
+ return
+ }
+
+ alreadyIn := false
+ for _, p := range room.Participants {
+ if p.ID == participant.ID {
+ alreadyIn = true
+ break
+ }
+ }
+
+ if room.InviteToken != "" && !alreadyIn {
+ if input.InviteToken == "" || input.InviteToken != room.InviteToken {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Invalid invite token"})
+ return
+ }
+ if room.InvitedUsername != "" && !strings.EqualFold(strings.TrimSpace(user.DisplayName), room.InvitedUsername) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "This challenge was sent to another user"})
+ return
+ }
+
+ filter := bson.M{
+ "_id": roomId,
+ "inviteToken": input.InviteToken,
+ "$expr": bson.M{
+ "$lt": []interface{}{bson.M{"$size": "$participants"}, 2},
+ },
+ }
+ update := bson.M{
+ "$addToSet": bson.M{"participants": participant},
+ }
+ opts := options.FindOneAndUpdate().SetReturnDocument(options.After)
+
+ var updatedRoom Room
+ if err := roomCollection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&updatedRoom); err != nil {
+ if err == mongo.ErrNoDocuments {
+ c.JSON(http.StatusConflict, gin.H{"error": "Room is full"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Could not join room"})
+ return
+ }
+
+ matchmakingService := services.GetMatchmakingService()
+ matchmakingService.RemoveFromPool(user.ID.Hex())
+
+ c.JSON(http.StatusOK, updatedRoom)
+ return
+ }
+
+ if alreadyIn {
+ c.JSON(http.StatusOK, room)
+ return
}
- // Use atomic operation to join room
- roomCollection := db.MongoClient.Database("DebateAI").Collection("rooms")
filter := bson.M{"_id": roomId}
update := bson.M{
"$addToSet": bson.M{"participants": participant},
@@ -187,7 +276,6 @@ func JoinRoomHandler(c *gin.Context) {
return
}
- // Remove user from matchmaking pool if they were in it
matchmakingService := services.GetMatchmakingService()
matchmakingService.RemoveFromPool(user.ID.Hex())
@@ -206,8 +294,8 @@ func GetRoomParticipantsHandler(c *gin.Context) {
}
// Query room document
- roomCollection := db.MongoClient.Database("DebateAI").Collection("rooms")
- userCollection := db.MongoClient.Database("DebateAI").Collection("users")
+ roomCollection := db.MongoDatabase.Collection("rooms")
+ userCollection := db.MongoDatabase.Collection("users")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -326,3 +414,153 @@ func GetRoomParticipantsHandler(c *gin.Context) {
"participants": participantsWithDetails,
})
}
+
+// CreateChallengeHandler handles POST /rooms/challenge.
+func CreateChallengeHandler(c *gin.Context) {
+ type CreateChallengeInput struct {
+ OpponentUsername string `json:"opponentUsername"`
+ Topic string `json:"topic"`
+ }
+
+ var input CreateChallengeInput
+ if err := c.ShouldBindJSON(&input); err != nil || strings.TrimSpace(input.Topic) == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Topic is required"})
+ return
+ }
+
+ email, exists := c.Get("email")
+ if !exists {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: user email not found"})
+ return
+ }
+
+ emailStr, ok := email.(string)
+ if !ok {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid email format"})
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ user, err := fetchUserByEmail(ctx, emailStr)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
+ return
+ }
+
+ opponentUsername := strings.TrimSpace(input.OpponentUsername)
+ if opponentUsername != "" {
+ if strings.EqualFold(opponentUsername, strings.TrimSpace(user.DisplayName)) {
+ opponentUsername = ""
+ } else {
+ userCollection := db.MongoDatabase.Collection("users")
+ var opponent roomUser
+ if err := userCollection.FindOne(ctx, bson.M{"displayName": opponentUsername}).Decode(&opponent); err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Opponent not found. Leave blank to share link with anyone."})
+ return
+ }
+ if opponent.ID == user.ID {
+ opponentUsername = ""
+ }
+ }
+ }
+
+ creatorParticipant := userToParticipant(user)
+ inviteToken := generateInviteToken()
+ roomID := generateRoomID()
+
+ newRoom := Room{
+ ID: roomID,
+ Type: "invite",
+ OwnerID: creatorParticipant.ID,
+ Participants: []Participant{creatorParticipant},
+ InviteToken: inviteToken,
+ Topic: strings.TrimSpace(input.Topic),
+ InvitedUsername: opponentUsername,
+ }
+
+ roomCollection := db.MongoDatabase.Collection("rooms")
+ if _, err := roomCollection.InsertOne(ctx, newRoom); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create challenge room"})
+ return
+ }
+
+ c.JSON(http.StatusOK, newRoom)
+}
+
+// RematchHandler handles POST /rooms/:id/rematch.
+func RematchHandler(c *gin.Context) {
+ roomId := c.Param("id")
+
+ email, exists := c.Get("email")
+ if !exists {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: user email not found"})
+ return
+ }
+
+ emailStr, ok := email.(string)
+ if !ok {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid email format"})
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ user, err := fetchUserByEmail(ctx, emailStr)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
+ return
+ }
+
+ roomCollection := db.MongoDatabase.Collection("rooms")
+ var room Room
+ if err := roomCollection.FindOne(ctx, bson.M{"_id": roomId}).Decode(&room); err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Room not found"})
+ return
+ }
+
+ if room.InviteToken == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Rematch is only available for challenge rooms"})
+ return
+ }
+
+ wasParticipant := false
+ for _, p := range room.Participants {
+ if p.ID == user.ID.Hex() {
+ wasParticipant = true
+ break
+ }
+ }
+ if !wasParticipant {
+ c.JSON(http.StatusForbidden, gin.H{"error": "You were not in this challenge"})
+ return
+ }
+
+ var existingRematch Room
+ if err := roomCollection.FindOne(ctx, bson.M{"rematchOfRoomId": roomId}).Decode(&existingRematch); err == nil {
+ c.JSON(http.StatusOK, existingRematch)
+ return
+ }
+
+ rematchParticipants := make([]Participant, len(room.Participants))
+ copy(rematchParticipants, room.Participants)
+
+ newRoom := Room{
+ ID: generateRoomID(),
+ Type: "invite",
+ OwnerID: user.ID.Hex(),
+ Participants: rematchParticipants,
+ InviteToken: generateInviteToken(),
+ Topic: room.Topic,
+ RematchOfRoomID: roomId,
+ }
+
+ if _, err := roomCollection.InsertOne(ctx, newRoom); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create rematch room"})
+ return
+ }
+
+ c.JSON(http.StatusOK, newRoom)
+}
diff --git a/backend/websocket/websocket.go b/backend/websocket/websocket.go
index 79470aba..91a9ca0b 100644
--- a/backend/websocket/websocket.go
+++ b/backend/websocket/websocket.go
@@ -30,8 +30,9 @@ var upgrader = websocket.Upgrader{
// Room represents a debate room with connected clients.
type Room struct {
- Clients map[*websocket.Conn]*Client
- Mutex sync.Mutex
+ Clients map[*websocket.Conn]*Client
+ Mutex sync.Mutex
+ CurrentTopic string
}
// Client represents a connected client with user information
@@ -50,7 +51,9 @@ type Client struct {
LastActivity time.Time
IsMuted bool // New field to track mute status
Role string // New field to track debate role (for/against)
+ Ready bool // Ready status during setup
Ready bool // Whether the debater is ready to start
+
SpeechText string // New field to store speech text
ConnectionID string
}
@@ -196,6 +199,72 @@ func broadcastParticipants(room *Room) {
}
}
+func lookupRoomTopic(roomID string) string {
+ if db.MongoDatabase == nil {
+ return ""
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ var room struct {
+ Topic string `bson:"topic"`
+ }
+ if err := db.MongoDatabase.Collection("rooms").FindOne(ctx, bson.M{"_id": roomID}).Decode(&room); err != nil {
+ return ""
+ }
+ return strings.TrimSpace(room.Topic)
+}
+
+func syncRoomStateToClient(room *Room, client *Client, conn *websocket.Conn, roomID string) {
+ room.Mutex.Lock()
+ needsTopic := room.CurrentTopic == ""
+ room.Mutex.Unlock()
+
+ if needsTopic {
+ if topic := lookupRoomTopic(roomID); topic != "" {
+ room.Mutex.Lock()
+ if room.CurrentTopic == "" {
+ room.CurrentTopic = topic
+ }
+ room.Mutex.Unlock()
+ }
+ }
+
+ room.Mutex.Lock()
+ currentTopic := room.CurrentTopic
+ peerMessages := make([]map[string]interface{}, 0)
+ for connRef, existing := range room.Clients {
+ if connRef == conn || existing.IsSpectator {
+ continue
+ }
+ if existing.Role != "" {
+ peerMessages = append(peerMessages, map[string]interface{}{
+ "type": "roleSelection",
+ "role": existing.Role,
+ "userId": existing.UserID,
+ })
+ }
+ peerMessages = append(peerMessages, map[string]interface{}{
+ "type": "ready",
+ "ready": existing.Ready,
+ "userId": existing.UserID,
+ })
+ }
+ room.Mutex.Unlock()
+
+ if currentTopic != "" {
+ _ = client.SafeWriteJSON(map[string]interface{}{
+ "type": "topicChange",
+ "topic": currentTopic,
+ })
+ }
+
+ for _, msg := range peerMessages {
+ _ = client.SafeWriteJSON(msg)
+ }
+}
+
func notifySpectatorStatus(room *Room, spectator *Client, joined bool) {
if spectator == nil {
return
@@ -389,6 +458,8 @@ func WebsocketHandler(c *gin.Context) {
r.SafeWriteJSON(participantsMsg)
}
+ syncRoomStateToClient(room, client, conn, roomID)
+
if client.IsSpectator {
log.Printf("[ws] spectator connected: room=%s connectionId=%s user=%s", roomID, client.ConnectionID, client.Email)
notifySpectatorStatus(room, client, true)
@@ -657,6 +728,24 @@ func handlePhaseChange(room *Room, conn *websocket.Conn, message Message, roomID
// handleTopicChange handles topic changes
func handleTopicChange(room *Room, conn *websocket.Conn, message Message, roomID string) {
+ room.Mutex.Lock()
+ if message.Topic != "" {
+ room.CurrentTopic = message.Topic
+ }
+ room.Mutex.Unlock()
+
+ if message.Topic != "" && db.MongoDatabase != nil {
+ go func(topic string) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _, _ = db.MongoDatabase.Collection("rooms").UpdateOne(
+ ctx,
+ bson.M{"_id": roomID},
+ bson.M{"$set": bson.M{"topic": topic}},
+ )
+ }(message.Topic)
+ }
+
// Broadcast topic change to other clients
for _, r := range snapshotRecipients(room, conn) {
if err := r.SafeWriteJSON(message); err != nil {
@@ -674,6 +763,7 @@ func handleRoleSelection(room *Room, conn *websocket.Conn, message Message, room
return
}
client.Role = message.Role
+ message.UserID = client.UserID
}
room.Mutex.Unlock()
@@ -689,6 +779,15 @@ func handleRoleSelection(room *Room, conn *websocket.Conn, message Message, room
// handleReadyStatus handles ready status
func handleReadyStatus(room *Room, conn *websocket.Conn, message Message, roomID string) {
+
+ room.Mutex.Lock()
+ if client, exists := room.Clients[conn]; exists {
+ if message.Ready != nil {
+ client.Ready = *message.Ready
+ }
+ message.UserID = client.UserID
+ }
+
if message.Ready == nil {
return
}
@@ -709,7 +808,10 @@ func handleReadyStatus(room *Room, conn *websocket.Conn, message Message, roomID
}
}
+
+
// Reconnecting clients recover readiness from the participant snapshot.
+
broadcastParticipants(room)
}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 45598bf8..59a89dad 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,5 +1,5 @@
import { useContext } from 'react';
-import { Routes, Route, Navigate, Outlet } from 'react-router-dom';
+import { Routes, Route, Navigate, Outlet, useLocation } from 'react-router-dom';
import { AuthProvider, AuthContext } from './context/authContext';
import { ThemeProvider } from './context/theme-provider';
// Pages
@@ -28,11 +28,11 @@ import CommunityFeed from './Pages/CommunityFeed';
import AdminSignup from './Pages/Admin/AdminSignup';
import AdminDashboard from './Pages/Admin/AdminDashboard';
import ViewDebate from './Pages/ViewDebate';
-import SupportOpenSource from './Pages/SupportOpenSource';
// Protects routes based on authentication status
function ProtectedRoute() {
const authContext = useContext(AuthContext);
+ const location = useLocation();
if (!authContext) {
throw new Error('ProtectedRoute must be used within an AuthProvider');
}
@@ -40,7 +40,14 @@ function ProtectedRoute() {
if (isLoading) {
return
Loading...
;
}
- return isAuthenticated ? : ;
+ if (!isAuthenticated) {
+ sessionStorage.setItem(
+ 'returnUrl',
+ location.pathname + location.search
+ );
+ return ;
+ }
+ return ;
}
// Defines application routes
diff --git a/frontend/src/Pages/OnlineDebateRoom.tsx b/frontend/src/Pages/OnlineDebateRoom.tsx
index 8eefded4..3a95adfe 100644
--- a/frontend/src/Pages/OnlineDebateRoom.tsx
+++ b/frontend/src/Pages/OnlineDebateRoom.tsx
@@ -5,7 +5,7 @@ import React, {
useRef,
useState,
} from "react";
-import { useParams } from "react-router-dom";
+import { useParams, useSearchParams, useNavigate } from "react-router-dom";
import { Button } from "../components/ui/button";
import JudgmentPopup from "@/components/JudgementPopup";
@@ -78,7 +78,11 @@ interface UserDetails {
avatarUrl?: string;
displayName?: string;
email?: string;
+
+ role?: string;
+
role?: DebateRole;
+
ready?: boolean;
}
@@ -94,7 +98,7 @@ interface WSMessage {
candidate?: RTCIceCandidateInit;
message?: string;
userDetails?: UserDetails;
- roomParticipants?: UserDetails[];
+ roomParticipants?: Array;
// Enhanced chat fields
userId?: string;
username?: string;
@@ -148,6 +152,9 @@ const WS_BASE_URL = BASE_URL.replace(
const OnlineDebateRoom = (): JSX.Element => {
const { roomId } = useParams<{ roomId: string }>();
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const inviteToken = searchParams.get("invite");
const { user: currentUser } = useUser();
const currentUserId = currentUser?.id ?? null;
useDebateWS(roomId ?? null);
@@ -187,6 +194,14 @@ const OnlineDebateRoom = (): JSX.Element => {
const localStreamRef = useRef(null);
const localRoleRef = useRef(null);
const peerRoleRef = useRef(null);
+
+ const opponentUserIdRef = useRef(null);
+ const pendingPeerCandidatesRef = useRef([]);
+ const currentUserIdRef = useRef(currentUserId);
+ const establishDebateConnectionRef = useRef<() => Promise>(
+ async () => {}
+ );
+
const debatePhaseRef = useRef(DebatePhase.Setup);
const currentUserIdRef = useRef(currentUserId);
const currentUserRef = useRef(currentUser);
@@ -201,6 +216,7 @@ const OnlineDebateRoom = (): JSX.Element => {
const timerRef = useRef(null);
const judgePollRef = useRef(null);
const submissionStartedRef = useRef(false);
+ const joinAttemptedRef = useRef(null);
useEffect(() => {
return () => {
@@ -307,14 +323,108 @@ const OnlineDebateRoom = (): JSX.Element => {
}, [peerRole]);
useEffect(() => {
+
+ opponentUserIdRef.current = opponentUser?.id ?? null;
+ }, [opponentUser?.id]);
+
debatePhaseRef.current = debatePhase;
}, [debatePhase]);
+
useEffect(() => {
currentUserIdRef.current = currentUserId;
currentUserRef.current = currentUser;
}, [currentUser, currentUserId]);
+ const flushPeerCandidates = useCallback(async (pc: RTCPeerConnection) => {
+ const pending = pendingPeerCandidatesRef.current;
+ pendingPeerCandidatesRef.current = [];
+ for (const candidate of pending) {
+ try {
+ await pc.addIceCandidate(candidate);
+ } catch {
+ // Ignore stale ICE candidates.
+ }
+ }
+ }, []);
+
+ const shouldCreateDebateOffer = useCallback(() => {
+ const local = localRoleRef.current;
+ const peer = peerRoleRef.current;
+ if (local === "for") return true;
+ if (peer === "for") return false;
+ const userId = currentUserIdRef.current;
+ const opponentId = opponentUserIdRef.current;
+ if (userId && opponentId) {
+ return userId < opponentId;
+ }
+ return false;
+ }, []);
+
+ const establishDebateConnection = useCallback(async () => {
+ const pc = pcRef.current;
+ const ws = wsRef.current;
+ if (!pc || !ws || ws.readyState !== WebSocket.OPEN) return;
+ if (!localStreamRef.current) return;
+ if (!shouldCreateDebateOffer()) return;
+
+ if (pc.signalingState === "stable" && pc.currentRemoteDescription) {
+ return;
+ }
+
+ if (pc.signalingState === "have-local-offer" && pc.localDescription) {
+ ws.send(
+ JSON.stringify({ type: "offer", offer: pc.localDescription.toJSON() })
+ );
+ return;
+ }
+
+ try {
+ const offer = await pc.createOffer();
+ await pc.setLocalDescription(offer);
+ ws.send(JSON.stringify({ type: "offer", offer }));
+ } catch (error) {
+ console.error("Failed to establish debate connection:", error);
+ }
+ }, [shouldCreateDebateOffer]);
+
+ useEffect(() => {
+ establishDebateConnectionRef.current = establishDebateConnection;
+ }, [establishDebateConnection]);
+
+ const applyParticipantRoles = useCallback(
+ (
+ participants: Array
+ ) => {
+ const userId = currentUserIdRef.current;
+ if (!userId) return;
+
+ for (const participant of participants) {
+ const normalizedRole =
+ participant.role === "for" || participant.role === "against"
+ ? participant.role
+ : null;
+
+ if (participant.id === userId) {
+ if (normalizedRole) {
+ setLocalRole((current) => current ?? normalizedRole);
+ }
+ if (typeof participant.ready === "boolean") {
+ setLocalReady(participant.ready);
+ }
+ } else {
+ if (normalizedRole) {
+ setPeerRole(normalizedRole);
+ }
+ if (typeof participant.ready === "boolean") {
+ setPeerReady(participant.ready);
+ }
+ }
+ }
+ },
+ []
+ );
+
const startSpectatorOffer = useCallback(
async (baseConnectionId: string, requestId?: string) => {
const ws = wsRef.current;
@@ -487,6 +597,8 @@ const OnlineDebateRoom = (): JSX.Element => {
const [ratingSummary, setRatingSummary] = useState(
null
);
+ const [isChallengeRoom, setIsChallengeRoom] = useState(false);
+ const [joinError, setJoinError] = useState(null);
// Ordered list of debate phases
const phaseOrder = useMemo(
@@ -936,6 +1048,83 @@ const OnlineDebateRoom = (): JSX.Element => {
return false;
}, [roomId, currentUser, setRoomOwnerId]);
+ const handleRematch = useCallback(async () => {
+ if (!roomId) return;
+
+ try {
+ const token = getAuthToken();
+ const response = await fetch(`${BASE_URL}/rooms/${roomId}/rematch`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ });
+
+ let data: { id?: string; inviteToken?: string; error?: string } = {};
+ try {
+ data = await response.json();
+ } catch {
+ setJoinError("Failed to create rematch room");
+ return;
+ }
+
+ if (response.ok && data.id && data.inviteToken) {
+ navigate(`/debate-room/${data.id}?invite=${data.inviteToken}`);
+ return;
+ }
+
+ setJoinError(data.error || "Failed to create rematch room");
+ } catch {
+ setJoinError("Failed to create rematch room");
+ }
+ }, [roomId, navigate]);
+
+ useEffect(() => {
+ const joinChallengeRoom = async () => {
+ if (!roomId || !currentUserId) return;
+
+ const joinKey = `${roomId}:${inviteToken ?? ""}:${currentUserId}`;
+ if (joinAttemptedRef.current === joinKey) return;
+ joinAttemptedRef.current = joinKey;
+
+ try {
+ const token = getAuthToken();
+ const body: { inviteToken?: string } = {};
+ if (inviteToken) {
+ body.inviteToken = inviteToken;
+ }
+
+ const response = await fetch(`${BASE_URL}/rooms/${roomId}/join`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (response.ok) {
+ const room = await response.json();
+ if (room.inviteToken) {
+ setIsChallengeRoom(true);
+ }
+ if (room.topic) {
+ setTopic((current) => current || room.topic);
+ }
+ } else if (inviteToken) {
+ const data = await response.json();
+ setJoinError(data.error || "Failed to join challenge room");
+ }
+ } catch {
+ if (inviteToken) {
+ setJoinError("Failed to join challenge room");
+ }
+ }
+ };
+
+ joinChallengeRoom();
+ }, [roomId, currentUserId, inviteToken]);
+
// Function to fetch room participants
const fetchRoomParticipants = useCallback(
async (retryCount = 0, background = false) => {
@@ -1185,13 +1374,27 @@ const OnlineDebateRoom = (): JSX.Element => {
const data: WSMessage = JSON.parse(event.data);
switch (data.type) {
case "topicChange":
- if (data.topic !== undefined) setTopic(data.topic);
+ if (data.topic !== undefined) {
+ setTopic((current) => current || data.topic || "");
+ }
break;
case "roleSelection":
- if (data.role) setPeerRole(data.role);
+ if (data.role === "for" || data.role === "against") {
+ if (data.userId === currentUserIdRef.current) {
+ setLocalRole(data.role);
+ } else {
+ setPeerRole(data.role);
+ }
+ }
break;
case "ready":
- if (data.ready !== undefined) setPeerReady(data.ready);
+ if (data.ready !== undefined) {
+ if (data.userId === currentUserIdRef.current) {
+ setLocalReady(data.ready);
+ } else {
+ setPeerReady(data.ready);
+ }
+ }
break;
case "phaseChange":
if (data.phase) {
@@ -1269,6 +1472,7 @@ const OnlineDebateRoom = (): JSX.Element => {
data.roomParticipants
);
setRoomParticipants(data.roomParticipants);
+ applyParticipantRoles(data.roomParticipants);
// Update local and opponent user details when participants change
const activeUser = currentUserRef.current;
if (activeUser && data.roomParticipants.length >= 1) {
@@ -1360,10 +1564,22 @@ const OnlineDebateRoom = (): JSX.Element => {
break;
}
if (pcRef.current && data.offer) {
- await pcRef.current.setRemoteDescription(data.offer!);
- const answer = await pcRef.current.createAnswer();
- await pcRef.current.setLocalDescription(answer);
- wsRef.current?.send(JSON.stringify({ type: "answer", answer }));
+ try {
+ const pc = pcRef.current;
+ if (pc.signalingState === "have-local-offer") {
+ if (shouldCreateDebateOffer()) {
+ break;
+ }
+ await pc.setLocalDescription({ type: "rollback" });
+ }
+ await pc.setRemoteDescription(data.offer);
+ await flushPeerCandidates(pc);
+ const answer = await pc.createAnswer();
+ await pc.setLocalDescription(answer);
+ wsRef.current?.send(JSON.stringify({ type: "answer", answer }));
+ } catch (error) {
+ console.error("Failed to handle debate offer:", error);
+ }
}
break;
case "answer":
@@ -1403,7 +1619,13 @@ const OnlineDebateRoom = (): JSX.Element => {
) {
// Spectator answer meant for the other debater; ignore.
} else if (pcRef.current && data.answer) {
- await pcRef.current.setRemoteDescription(data.answer);
+ try {
+ const pc = pcRef.current;
+ await pc.setRemoteDescription(data.answer);
+ await flushPeerCandidates(pc);
+ } catch (error) {
+ console.error("Failed to handle debate answer:", error);
+ }
}
break;
case "candidate":
@@ -1436,7 +1658,16 @@ const OnlineDebateRoom = (): JSX.Element => {
}
}
} else if (pcRef.current && data.candidate) {
- await pcRef.current.addIceCandidate(data.candidate);
+ try {
+ const pc = pcRef.current;
+ if (pc.remoteDescription?.type) {
+ await pc.addIceCandidate(data.candidate);
+ } else {
+ pendingPeerCandidatesRef.current.push(data.candidate);
+ }
+ } catch (error) {
+ console.error("Failed to add ICE candidate:", error);
+ }
}
break;
}
@@ -1456,7 +1687,16 @@ const OnlineDebateRoom = (): JSX.Element => {
};
pc.ontrack = (event) => {
- setRemoteStream(event.streams[0]);
+ const [stream] = event.streams;
+ if (stream) {
+ setRemoteStream(stream);
+ }
+ };
+
+ pc.onconnectionstatechange = () => {
+ if (pc.connectionState === "failed") {
+ void establishDebateConnectionRef.current();
+ }
};
const getMedia = async () => {
@@ -1508,6 +1748,20 @@ const OnlineDebateRoom = (): JSX.Element => {
flushSpectatorOfferQueue();
}, [flushSpectatorOfferQueue]);
+ useEffect(() => {
+ if (roomParticipants.length < 2 || !localStream) return;
+ void establishDebateConnection();
+ }, [
+ roomParticipants.length,
+ localStream,
+ localRole,
+ peerRole,
+ opponentUser?.id,
+ localReady,
+ peerReady,
+ establishDebateConnection,
+ ]);
+
// Attach streams to video elements
useEffect(() => {
if (localVideoRef.current && localStream) {
@@ -2072,7 +2326,11 @@ const OnlineDebateRoom = (): JSX.Element => {
return;
}
setLocalRole(role);
- const message = JSON.stringify({ type: "roleSelection", role });
+ const message = JSON.stringify({
+ type: "roleSelection",
+ role,
+ userId: currentUserIdRef.current,
+ });
wsRef.current?.send(message);
};
@@ -2088,6 +2346,16 @@ const OnlineDebateRoom = (): JSX.Element => {
const newReadyState = !localReady;
socket.send(JSON.stringify({ type: "ready", ready: newReadyState }));
setLocalReady(newReadyState);
+
+ wsRef.current?.send(
+ JSON.stringify({
+ type: "ready",
+ ready: newReadyState,
+ userId: currentUserIdRef.current,
+ })
+ );
+
+
};
// Manage setup popup visibility
@@ -2113,18 +2381,9 @@ const OnlineDebateRoom = (): JSX.Element => {
console.debug(
`Countdown finished. Starting debate at ${DebatePhase.OpeningFor} for ${localRole}`
);
- if (localRole === "for") {
- pcRef.current
- ?.createOffer()
- .then((offer) =>
- pcRef.current!.setLocalDescription(offer).then(() => offer)
- )
- .then((offer) =>
- wsRef.current?.send(JSON.stringify({ type: "offer", offer }))
- );
- }
+ void establishDebateConnection();
}
- }, [countdown, localRole]);
+ }, [countdown, localRole, establishDebateConnection]);
// Clear input fields on phase change
useEffect(() => {
@@ -2173,6 +2432,22 @@ const OnlineDebateRoom = (): JSX.Element => {
);
}
+ if (joinError) {
+ return (
+
+
+
{joinError}
+
+
+
+ );
+ }
+
// Render UI
return (
@@ -2457,6 +2732,8 @@ const OnlineDebateRoom = (): JSX.Element => {
}
opponentAvatarUrl={opponentUser?.avatarUrl || null}
ratingSummary={ratingSummary}
+ showRematch={isChallengeRoom}
+ onRematch={handleRematch}
onClose={() => setShowJudgment(false)}
/>
)}
diff --git a/frontend/src/Pages/Profile.tsx b/frontend/src/Pages/Profile.tsx
index 1642fbd8..85cadb50 100644
--- a/frontend/src/Pages/Profile.tsx
+++ b/frontend/src/Pages/Profile.tsx
@@ -49,6 +49,7 @@ import {
Image as ImageIcon,
ChevronRight,
Flame,
+ Swords,
} from "lucide-react";
import { FaTrophy, FaMedal, FaAward } from "react-icons/fa";
import { format, isSameDay, subDays } from "date-fns";
@@ -79,6 +80,7 @@ import {
import { getAuthToken } from "@/utils/auth";
import { DateRange } from "react-day-picker";
import AvatarModal from "../components/AvatarModal";
+import ChallengeModal from "../components/ChallengeModal";
import SavedTranscripts from "../components/SavedTranscripts";
import ProfileHover from "../components/ProfileHover";
import { useUser } from "../hooks/useUser";
@@ -172,6 +174,7 @@ const Profile: React.FC = () => {
"7days" | "30days" | "all" | "custom"
>("all");
const [isAvatarModalOpen, setIsAvatarModalOpen] = useState(false);
+ const [showChallengeModal, setShowChallengeModal] = useState(false);
const [debateStatsLoading, setDebateStatsLoading] = useState(true);
const [usernameStatus, setUsernameStatus] = useState<
"idle" | "checking" | "available" | "taken"
@@ -800,6 +803,14 @@ const debounceTimer = useRef | null>(null);
Streak: {profile.currentStreak} days
)}
+
@@ -1175,6 +1186,9 @@ const debounceTimer = useRef | null>(null);
)}
+ {showChallengeModal && (
+ setShowChallengeModal(false)} />
+ )}
);
};
diff --git a/frontend/src/components/ChallengeModal.tsx b/frontend/src/components/ChallengeModal.tsx
new file mode 100644
index 00000000..c7e44a03
--- /dev/null
+++ b/frontend/src/components/ChallengeModal.tsx
@@ -0,0 +1,186 @@
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { X, Copy, Check } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { getAuthToken } from '@/utils/auth';
+import { useUser } from '@/hooks/useUser';
+
+interface ChallengeModalProps {
+ onClose: () => void;
+}
+
+type ChallengeRoom = {
+ id: string;
+ inviteToken: string;
+ topic: string;
+};
+
+const BASE_URL = import.meta.env.VITE_BASE_URL || 'http://localhost:1313';
+
+const ChallengeModal: React.FC = ({ onClose }) => {
+ const navigate = useNavigate();
+ const { user } = useUser();
+ const [opponentUsername, setOpponentUsername] = useState('');
+ const [topic, setTopic] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [challenge, setChallenge] = useState(null);
+ const [copied, setCopied] = useState(false);
+ const [copyError, setCopyError] = useState('');
+
+ const inviteLink = challenge
+ ? `${window.location.origin}/debate-room/${challenge.id}?invite=${challenge.inviteToken}`
+ : '';
+
+ const handleCreate = async () => {
+ if (!topic.trim()) {
+ setError('Please enter a debate topic');
+ return;
+ }
+
+ setLoading(true);
+ setError('');
+
+ const trimmedOpponent = opponentUsername.trim();
+ const currentName = user?.displayName?.trim() ?? '';
+ const resolvedOpponent =
+ trimmedOpponent &&
+ currentName &&
+ trimmedOpponent.localeCompare(currentName, undefined, {
+ sensitivity: 'accent',
+ }) === 0
+ ? ''
+ : trimmedOpponent;
+
+ try {
+ const token = getAuthToken();
+ const response = await fetch(`${BASE_URL}/rooms/challenge`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({
+ opponentUsername: resolvedOpponent,
+ topic: topic.trim(),
+ }),
+ });
+
+ const data = await response.json();
+ if (!response.ok) {
+ setError(data.error || 'Failed to create challenge');
+ return;
+ }
+
+ setChallenge({
+ id: data.id,
+ inviteToken: data.inviteToken,
+ topic: data.topic,
+ });
+ } catch {
+ setError('Failed to create challenge');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleCopy = async () => {
+ if (!inviteLink) return;
+ try {
+ await navigator.clipboard.writeText(inviteLink);
+ setCopyError('');
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ setCopyError('Could not copy link. Please copy it manually from the field above.');
+ }
+ };
+
+ const handleEnterRoom = () => {
+ if (!challenge) return;
+ navigate(`/debate-room/${challenge.id}?invite=${challenge.inviteToken}`);
+ onClose();
+ };
+
+ return (
+
+
+
+
+ {!challenge ? (
+ <>
+
Challenge a Friend
+
+
+
+
setOpponentUsername(e.target.value)}
+ placeholder='e.g. LogicLord (or leave blank)'
+ className='mt-1'
+ />
+
+ Leave blank to share the invite link with anyone.
+
+
+
+
+ setTopic(e.target.value)}
+ placeholder='e.g. Should AI replace teachers?'
+ className='mt-1'
+ />
+
+ {error && (
+
{error}
+ )}
+
+
+ >
+ ) : (
+ <>
+
Challenge Created
+
+ Share this link with your opponent. Topic: {challenge.topic}
+
+
+
+
+
+ {copyError && (
+
{copyError}
+ )}
+
+
+
+
+ >
+ )}
+
+
+ );
+};
+
+export default ChallengeModal;
diff --git a/frontend/src/components/JudgementPopup.tsx b/frontend/src/components/JudgementPopup.tsx
index d04a9822..fe41f11e 100644
--- a/frontend/src/components/JudgementPopup.tsx
+++ b/frontend/src/components/JudgementPopup.tsx
@@ -81,6 +81,8 @@ type JudgmentPopupProps = {
opponentDisplayName?: string | null;
opponentAvatarUrl?: string | null;
ratingSummary?: RatingSummary | null;
+ showRematch?: boolean;
+ onRematch?: () => void;
onClose: () => void;
};
@@ -122,6 +124,8 @@ const JudgmentPopup: React.FC = ({
opponentDisplayName,
opponentAvatarUrl,
ratingSummary,
+ showRematch,
+ onRematch,
onClose,
}) => {
const navigate = useNavigate();
@@ -734,6 +738,14 @@ const player2RatingSummary =
{/* Buttons */}
+ {showRematch && onRematch && (
+
+ )}