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 && ( + + )}