From a9376aac855428262e38af6641a09e1cd3de736a Mon Sep 17 00:00:00 2001 From: Rishit Date: Sun, 9 Nov 2025 18:26:08 +0530 Subject: [PATCH 1/4] Refactor Team WebSocket handling and enhance user data management - Introduced WebRTC signaling methods for offer, answer, and ICE candidate handling in TeamWebsocket. - Improved user data fetching and caching in useUser hook, ensuring user profile is hydrated from localStorage. - Updated TeamBuilder and TeamDebateRoom components to utilize the new user data structure. - Enhanced error handling and logging for WebSocket connections and media access. - Cleaned up unused imports and optimized component rendering logic for better performance. --- backend/websocket/team_websocket.go | 359 ++++++++---- frontend/src/Pages/TeamBuilder.tsx | 18 +- frontend/src/Pages/TeamDebateRoom.tsx | 589 +++++++++++++++++--- frontend/src/components/TeamChatSidebar.tsx | 1 - frontend/src/components/TeamMatchmaking.tsx | 9 +- frontend/src/context/authContext.tsx | 35 +- frontend/src/hooks/useUser.ts | 134 +++-- frontend/tsconfig.app.tsbuildinfo | 2 +- 8 files changed, 899 insertions(+), 248 deletions(-) diff --git a/backend/websocket/team_websocket.go b/backend/websocket/team_websocket.go index 1a3348d..310e5cc 100644 --- a/backend/websocket/team_websocket.go +++ b/backend/websocket/team_websocket.go @@ -3,6 +3,7 @@ package websocket import ( "context" "encoding/json" + "errors" "log" "net/http" "sync" @@ -29,12 +30,12 @@ type TeamRoom struct { TurnManager *services.TeamTurnManager TokenBucket *services.TokenBucketService // Room state for synchronization - CurrentTopic string - CurrentPhase string - Team1Role string - Team2Role string - Team1Ready map[string]bool // userId -> ready status - Team2Ready map[string]bool // userId -> ready status + CurrentTopic string + CurrentPhase string + Team1Role string + Team2Role string + Team1Ready map[string]bool // userId -> ready status + Team2Ready map[string]bool // userId -> ready status } // TeamClient represents a connected team member @@ -52,7 +53,7 @@ type TeamClient struct { IsMuted bool Role string // "for" or "against" SpeechText string - Tokens int // Remaining speaking tokens + Tokens int // Remaining speaking tokens } // SafeWriteJSON safely writes JSON data to the team client's WebSocket connection @@ -75,12 +76,14 @@ type TeamMessage struct { Room string `json:"room,omitempty"` Username string `json:"username,omitempty"` UserID string `json:"userId,omitempty"` + FromUserID string `json:"fromUserId,omitempty"` + TargetUserID string `json:"targetUserId,omitempty"` Content string `json:"content,omitempty"` Extra json.RawMessage `json:"extra,omitempty"` IsTyping bool `json:"isTyping,omitempty"` IsSpeaking bool `json:"isSpeaking,omitempty"` PartialText string `json:"partialText,omitempty"` - Timestamp int64 `json:"timestamp,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` Mode string `json:"mode,omitempty"` Phase string `json:"phase,omitempty"` Topic string `json:"topic,omitempty"` @@ -93,6 +96,9 @@ type TeamMessage struct { TeamID string `json:"teamId,omitempty"` Tokens int `json:"tokens,omitempty"` CanSpeak bool `json:"canSpeak,omitempty"` + Offer map[string]any `json:"offer,omitempty"` + Answer map[string]any `json:"answer,omitempty"` + Candidate map[string]any `json:"candidate,omitempty"` } var teamRooms = make(map[string]*TeamRoom) @@ -161,7 +167,7 @@ func TeamWebsocketHandler(c *gin.Context) { // Check team 1 var team1 models.Team err = teamCollection.FindOne(context.Background(), bson.M{ - "_id": debate.Team1ID, + "_id": debate.Team1ID, "members.userId": userObjectID, }).Decode(&team1) if err == nil { @@ -171,7 +177,7 @@ func TeamWebsocketHandler(c *gin.Context) { // Check team 2 var team2 models.Team err = teamCollection.FindOne(context.Background(), bson.M{ - "_id": debate.Team2ID, + "_id": debate.Team2ID, "members.userId": userObjectID, }).Decode(&team2) if err == nil { @@ -199,18 +205,18 @@ func TeamWebsocketHandler(c *gin.Context) { tokenBucket.InitializeTeamBuckets(debate.Team2ID) teamRooms[roomKey] = &TeamRoom{ - Clients: make(map[*websocket.Conn]*TeamClient), - Team1ID: debate.Team1ID, - Team2ID: debate.Team2ID, - DebateID: debateObjectID, - TurnManager: turnManager, - TokenBucket: tokenBucket, + Clients: make(map[*websocket.Conn]*TeamClient), + Team1ID: debate.Team1ID, + Team2ID: debate.Team2ID, + DebateID: debateObjectID, + TurnManager: turnManager, + TokenBucket: tokenBucket, CurrentTopic: debate.Topic, CurrentPhase: "setup", - Team1Role: debate.Team1Stance, - Team2Role: debate.Team2Stance, - Team1Ready: make(map[string]bool), - Team2Ready: make(map[string]bool), + Team1Role: debate.Team1Stance, + Team2Role: debate.Team2Stance, + Team1Ready: make(map[string]bool), + Team2Ready: make(map[string]bool), } } room := teamRooms[roomKey] @@ -227,16 +233,16 @@ func TeamWebsocketHandler(c *gin.Context) { userTeamIDHex := userTeamID.Hex() team1IDHex := debate.Team1ID.Hex() team2IDHex := debate.Team2ID.Hex() - + if userTeamIDHex != team1IDHex && userTeamIDHex != team2IDHex { log.Printf("[TeamWebsocketHandler] ❌ ERROR: UserTeamID %s doesn't match Team1ID %s or Team2ID %s", userTeamIDHex, team1IDHex, team2IDHex) c.JSON(http.StatusInternalServerError, gin.H{"error": "Team assignment error"}) conn.Close() return } - + log.Printf("[TeamWebsocketHandler] ✓ User %s belongs to team %s (Team1=%s, Team2=%s)", userObjectID.Hex(), userTeamIDHex, team1IDHex, team2IDHex) - + // Create team client instance client := &TeamClient{ Conn: conn, @@ -273,7 +279,7 @@ func TeamWebsocketHandler(c *gin.Context) { currentPhase := room.CurrentPhase team1Role := room.Team1Role team2Role := room.Team2Role - + // Get ready counts for both teams and individual ready status team1ReadyCount := 0 team1ReadyStatus := make(map[string]bool) @@ -283,7 +289,7 @@ func TeamWebsocketHandler(c *gin.Context) { } team1ReadyStatus[userId] = ready } - + team2ReadyCount := 0 team2ReadyStatus := make(map[string]bool) for userId, ready := range room.Team2Ready { @@ -293,24 +299,24 @@ func TeamWebsocketHandler(c *gin.Context) { team2ReadyStatus[userId] = ready } room.Mutex.Unlock() - + // Send state sync message with individual ready status and team names client.SafeWriteJSON(map[string]interface{}{ - "type": "stateSync", - "topic": currentTopic, - "phase": currentPhase, - "team1Role": team1Role, - "team2Role": team2Role, - "team1Ready": team1ReadyCount, - "team2Ready": team2ReadyCount, + "type": "stateSync", + "topic": currentTopic, + "phase": currentPhase, + "team1Role": team1Role, + "team2Role": team2Role, + "team1Ready": team1ReadyCount, + "team2Ready": team2ReadyCount, "team1MembersCount": len(debate.Team1Members), "team2MembersCount": len(debate.Team2Members), - "team1ReadyStatus": team1ReadyStatus, // Individual ready status for Team1 - "team2ReadyStatus": team2ReadyStatus, // Individual ready status for Team2 - "team1Name": debate.Team1Name, // Team names - "team2Name": debate.Team2Name, + "team1ReadyStatus": team1ReadyStatus, // Individual ready status for Team1 + "team2ReadyStatus": team2ReadyStatus, // Individual ready status for Team2 + "team1Name": debate.Team1Name, // Team names + "team2Name": debate.Team2Name, }) - + // Send team member lists client.SafeWriteJSON(map[string]interface{}{ "type": "teamMembers", @@ -323,6 +329,7 @@ func TeamWebsocketHandler(c *gin.Context) { messageType, msg, err := conn.ReadMessage() if err != nil { // Remove client from room + userID := client.UserID.Hex() room.Mutex.Lock() delete(room.Clients, conn) // If room is empty, delete it @@ -332,6 +339,12 @@ func TeamWebsocketHandler(c *gin.Context) { teamRoomsMutex.Unlock() } room.Mutex.Unlock() + + // Notify remaining clients that this user has left + broadcastExcept(room, conn, map[string]any{ + "type": "leave", + "userId": userID, + }) break } @@ -342,6 +355,12 @@ func TeamWebsocketHandler(c *gin.Context) { } // Update client activity + message.FromUserID = client.UserID.Hex() + message.UserID = client.UserID.Hex() + if message.TeamID == "" { + message.TeamID = client.TeamID.Hex() + } + room.Mutex.Lock() if client, exists := room.Clients[conn]; exists { client.LastActivity = time.Now() @@ -376,6 +395,14 @@ func TeamWebsocketHandler(c *gin.Context) { handleTeamTurnRequest(room, conn, message, client, roomKey) case "endTurn": handleTeamTurnEnd(room, conn, message, client, roomKey) + case "offer": + handleTeamWebRTCOffer(room, client, message) + case "answer": + handleTeamWebRTCAnswer(room, client, message) + case "candidate": + handleTeamWebRTCCandidate(room, client, message) + case "leave": + handleTeamLeave(room, client, roomKey) default: // Broadcast the message to all other clients in the room for _, r := range snapshotTeamRecipients(room, conn) { @@ -400,11 +427,57 @@ func snapshotTeamRecipients(room *TeamRoom, exclude *websocket.Conn) []*TeamClie return out } +// findClientByUserID returns the TeamClient matching the provided user ID +func findClientByUserID(room *TeamRoom, userID string) *TeamClient { + room.Mutex.Lock() + defer room.Mutex.Unlock() + for _, client := range room.Clients { + if client.UserID.Hex() == userID { + return client + } + } + return nil +} + +// sendMessageToUser sends a payload as JSON to the specified user if connected +func sendMessageToUser(room *TeamRoom, userID string, payload any) error { + target := findClientByUserID(room, userID) + if target == nil { + return errors.New("target user not connected") + } + return target.SafeWriteJSON(payload) +} + +// broadcastExcept sends a payload to every client except the provided connection +func broadcastExcept(room *TeamRoom, exclude *websocket.Conn, payload any) { + room.Mutex.Lock() + defer room.Mutex.Unlock() + for conn, client := range room.Clients { + if conn == exclude { + continue + } + if err := client.SafeWriteJSON(payload); err != nil { + log.Printf("Team WebSocket write error: %v", err) + } + } +} + +// broadcastAll sends identical payload to every connected client +func broadcastAll(room *TeamRoom, payload any) { + room.Mutex.Lock() + defer room.Mutex.Unlock() + for _, client := range room.Clients { + if err := client.SafeWriteJSON(payload); err != nil { + log.Printf("Team WebSocket write error: %v", err) + } + } +} + // handleTeamJoin handles team join messages func handleTeamJoin(room *TeamRoom, conn *websocket.Conn, message TeamMessage, client *TeamClient, roomKey string) { // Send team status to all clients teamStatus := room.TokenBucket.GetTeamSpeakingStatus(client.TeamID, room.TurnManager) - + // Broadcast to all clients in the room for _, r := range room.Clients { response := map[string]interface{}{ @@ -547,7 +620,7 @@ func handleTeamPhaseChange(room *TeamRoom, conn *websocket.Conn, message TeamMes log.Printf("[handleTeamPhaseChange] Received phase change message but Phase is empty") } room.Mutex.Unlock() - + // Broadcast phase change to ALL clients (including sender for sync) phaseMessage := TeamMessage{ Type: "phaseChange", @@ -570,7 +643,7 @@ func handleTeamTopicChange(room *TeamRoom, conn *websocket.Conn, message TeamMes room.CurrentTopic = message.Topic } room.Mutex.Unlock() - + // Broadcast topic change to ALL clients (including sender for sync) for _, r := range room.Clients { if err := r.SafeWriteJSON(message); err != nil { @@ -585,12 +658,12 @@ func handleTeamRoleSelection(room *TeamRoom, conn *websocket.Conn, message TeamM room.Mutex.Lock() if client, exists := room.Clients[conn]; exists { client.Role = message.Role - + // Use Hex() comparison for reliability (same as ready status) clientTeamIDHex := client.TeamID.Hex() team1IDHex := room.Team1ID.Hex() team2IDHex := room.Team2ID.Hex() - + // Update team role based on which team the client belongs to if clientTeamIDHex == team1IDHex { room.Team1Role = message.Role @@ -601,7 +674,7 @@ func handleTeamRoleSelection(room *TeamRoom, conn *websocket.Conn, message TeamM } else { log.Printf("[handleTeamRoleSelection] ERROR: Client TeamID %s doesn't match Team1ID %s or Team2ID %s", clientTeamIDHex, team1IDHex, team2IDHex) } - + // Broadcast role selection to ALL clients (including sender for sync) roleMessage := map[string]interface{}{ "type": "roleSelection", @@ -610,7 +683,7 @@ func handleTeamRoleSelection(room *TeamRoom, conn *websocket.Conn, message TeamM "teamId": client.TeamID.Hex(), } room.Mutex.Unlock() - + for _, r := range room.Clients { if err := r.SafeWriteJSON(roleMessage); err != nil { log.Printf("Team WebSocket write error in room %s: %v", roomKey, err) @@ -631,26 +704,26 @@ func handleTeamReadyStatus(room *TeamRoom, conn *websocket.Conn, message TeamMes log.Printf("[handleTeamReadyStatus] ERROR: Client not found for connection") return } - + // Store client info before unlocking userID := client.UserID.Hex() clientTeamIDHex := client.TeamID.Hex() team1IDHex := room.Team1ID.Hex() team2IDHex := room.Team2ID.Hex() - + log.Printf("[handleTeamReadyStatus] User %s (TeamID: %s) setting ready=%v", userID, clientTeamIDHex, message.Ready) log.Printf("[handleTeamReadyStatus] Room Team1ID: %s, Team2ID: %s", team1IDHex, team2IDHex) - + if message.Ready == nil { room.Mutex.Unlock() log.Printf("[handleTeamReadyStatus] ERROR: message.Ready is nil") return } - + // CRITICAL: Assign ready status to the CORRECT team ONLY // Remove from wrong team first to prevent double assignment var assignedToTeam string - + // Remove user from the OTHER team's ready map first (cleanup) if clientTeamIDHex != team1IDHex { delete(room.Team1Ready, userID) @@ -658,7 +731,7 @@ func handleTeamReadyStatus(room *TeamRoom, conn *websocket.Conn, message TeamMes if clientTeamIDHex != team2IDHex { delete(room.Team2Ready, userID) } - + // Now assign to the CORRECT team if clientTeamIDHex == team1IDHex { // User belongs to Team 1 - assign ONLY to Team1Ready @@ -676,9 +749,9 @@ func handleTeamReadyStatus(room *TeamRoom, conn *websocket.Conn, message TeamMes room.Mutex.Unlock() return } - + client.LastActivity = time.Now() - + // Keep mutex locked and calculate all counts accurately // Count ready members for each team currentTeam1ReadyCount := 0 @@ -693,7 +766,7 @@ func handleTeamReadyStatus(room *TeamRoom, conn *websocket.Conn, message TeamMes currentTeam2ReadyCount++ } } - + // Count actual team members connected currentTeam1MembersCount := 0 currentTeam2MembersCount := 0 @@ -705,30 +778,30 @@ func handleTeamReadyStatus(room *TeamRoom, conn *websocket.Conn, message TeamMes currentTeam2MembersCount++ } } - - log.Printf("[handleTeamReadyStatus] Current counts - Team1Ready=%d/%d, Team2Ready=%d/%d", + + log.Printf("[handleTeamReadyStatus] Current counts - Team1Ready=%d/%d, Team2Ready=%d/%d", currentTeam1ReadyCount, currentTeam1MembersCount, currentTeam2ReadyCount, currentTeam2MembersCount) - + // Broadcast ready status with accurate counts to ALL clients readyMessage := map[string]interface{}{ - "type": "ready", - "ready": message.Ready, - "userId": userID, - "teamId": clientTeamIDHex, - "assignedToTeam": assignedToTeam, - "team1Ready": currentTeam1ReadyCount, - "team2Ready": currentTeam2ReadyCount, + "type": "ready", + "ready": message.Ready, + "userId": userID, + "teamId": clientTeamIDHex, + "assignedToTeam": assignedToTeam, + "team1Ready": currentTeam1ReadyCount, + "team2Ready": currentTeam2ReadyCount, "team1MembersCount": currentTeam1MembersCount, // Use accurate counts "team2MembersCount": currentTeam2MembersCount, // Use accurate counts } - - log.Printf("[handleTeamReadyStatus] Broadcasting ready status: User %s assigned to %s, Team1Ready=%d/%d, Team2Ready=%d/%d", + + log.Printf("[handleTeamReadyStatus] Broadcasting ready status: User %s assigned to %s, Team1Ready=%d/%d, Team2Ready=%d/%d", userID, assignedToTeam, currentTeam1ReadyCount, currentTeam1MembersCount, currentTeam2ReadyCount, currentTeam2MembersCount) - + // Log the actual message being sent to verify counts are included readyMessageJSON, _ := json.Marshal(readyMessage) log.Printf("[handleTeamReadyStatus] Ready message JSON: %s", string(readyMessageJSON)) - + for _, r := range room.Clients { if err := r.SafeWriteJSON(readyMessage); err != nil { log.Printf("Team WebSocket write error in room %s: %v", roomKey, err) @@ -736,28 +809,28 @@ func handleTeamReadyStatus(room *TeamRoom, conn *websocket.Conn, message TeamMes log.Printf("[handleTeamReadyStatus] ✓ Ready message sent successfully") } } - + // Check if all teams are ready and phase is still setup allTeam1Ready := currentTeam1ReadyCount == currentTeam1MembersCount && currentTeam1MembersCount > 0 allTeam2Ready := currentTeam2ReadyCount == currentTeam2MembersCount && currentTeam2MembersCount > 0 allReady := allTeam1Ready && allTeam2Ready - - log.Printf("[handleTeamReadyStatus] Ready check: Team1=%d/%d ready=%v, Team2=%d/%d ready=%v, AllReady=%v, Phase=%s", + + log.Printf("[handleTeamReadyStatus] Ready check: Team1=%d/%d ready=%v, Team2=%d/%d ready=%v, AllReady=%v, Phase=%s", currentTeam1ReadyCount, currentTeam1MembersCount, allTeam1Ready, currentTeam2ReadyCount, currentTeam2MembersCount, allTeam2Ready, allReady, room.CurrentPhase) - + // Check if we should start countdown - use a flag to prevent multiple triggers shouldStartCountdown := allReady && room.CurrentPhase == "setup" - + // Check if countdown already started (phase is still setup but we have a flag in room) // We'll use a simple check: if phase is setup and all ready, start countdown if shouldStartCountdown { log.Printf("[handleTeamReadyStatus] ✓ All teams ready and phase is setup - starting countdown") - + // Broadcast countdown start to ALL clients immediately countdownMessage := map[string]interface{}{ - "type": "countdownStart", + "type": "countdownStart", "countdown": 3, } for _, r := range room.Clients { @@ -768,27 +841,27 @@ func handleTeamReadyStatus(room *TeamRoom, conn *websocket.Conn, message TeamMes } } log.Printf("[handleTeamReadyStatus] All teams ready! Starting countdown for %d clients", len(room.Clients)) - + // Update phase immediately to prevent multiple triggers room.CurrentPhase = "countdown" - + // Start countdown and phase change after 3 seconds in a goroutine go func() { time.Sleep(3 * time.Second) - + teamRoomsMutex.Lock() room, stillExists := teamRooms[roomKey] teamRoomsMutex.Unlock() - + if !stillExists { log.Printf("[handleTeamReadyStatus] Room %s no longer exists, aborting phase change", roomKey) return } - + room.Mutex.Lock() if room.CurrentPhase == "countdown" || room.CurrentPhase == "setup" { room.CurrentPhase = "openingFor" - + // Broadcast phase change to ALL clients using proper TeamMessage format phaseMessage := TeamMessage{ Type: "phaseChange", @@ -810,7 +883,7 @@ func handleTeamReadyStatus(room *TeamRoom, conn *websocket.Conn, message TeamMes } else { log.Printf("[handleTeamReadyStatus] Not starting countdown: allReady=%v, phase=%s", allReady, room.CurrentPhase) } - + room.Mutex.Unlock() } @@ -818,7 +891,7 @@ func handleTeamReadyStatus(room *TeamRoom, conn *websocket.Conn, message TeamMes func handleTeamTurnRequest(room *TeamRoom, conn *websocket.Conn, message TeamMessage, client *TeamClient, roomKey string) { // Check if user can speak canSpeak := room.TokenBucket.CanUserSpeak(client.TeamID, client.UserID, room.TurnManager) - + if canSpeak { // Update client tokens room.Mutex.Lock() @@ -827,11 +900,11 @@ func handleTeamTurnRequest(room *TeamRoom, conn *websocket.Conn, message TeamMes // Send turn granted response response := map[string]interface{}{ - "type": "turnGranted", - "userId": client.UserID.Hex(), - "username": client.Username, - "tokens": client.Tokens, - "canSpeak": true, + "type": "turnGranted", + "userId": client.UserID.Hex(), + "username": client.Username, + "tokens": client.Tokens, + "canSpeak": true, } client.SafeWriteJSON(response) @@ -865,10 +938,10 @@ func handleTeamTurnRequest(room *TeamRoom, conn *websocket.Conn, message TeamMes func handleTeamTurnEnd(room *TeamRoom, conn *websocket.Conn, message TeamMessage, client *TeamClient, roomKey string) { // Advance to next turn nextUserID := room.TurnManager.NextTurn(client.TeamID) - + // Update team status teamStatus := room.TokenBucket.GetTeamSpeakingStatus(client.TeamID, room.TurnManager) - + // Broadcast turn change to all clients in the team for _, r := range room.Clients { if r.TeamID == client.TeamID { @@ -884,21 +957,20 @@ func handleTeamTurnEnd(room *TeamRoom, conn *websocket.Conn, message TeamMessage } } - // handleCheckStart checks if all teams are ready and starts debate func handleCheckStart(room *TeamRoom, conn *websocket.Conn, roomKey string) { room.Mutex.Lock() defer room.Mutex.Unlock() - + if room.CurrentPhase != "setup" { log.Printf("[handleCheckStart] Phase is %s, not setup - ignoring", room.CurrentPhase) return } - + // Get team IDs team1IDHex := room.Team1ID.Hex() team2IDHex := room.Team2ID.Hex() - + // Count ready members for each team team1ReadyCount := 0 for _, ready := range room.Team1Ready { @@ -912,7 +984,7 @@ func handleCheckStart(room *TeamRoom, conn *websocket.Conn, roomKey string) { team2ReadyCount++ } } - + // Count actual team members connected team1MembersCount := 0 team2MembersCount := 0 @@ -924,25 +996,25 @@ func handleCheckStart(room *TeamRoom, conn *websocket.Conn, roomKey string) { team2MembersCount++ } } - + allTeam1Ready := team1ReadyCount == team1MembersCount && team1MembersCount > 0 allTeam2Ready := team2ReadyCount == team2MembersCount && team2MembersCount > 0 allReady := allTeam1Ready && allTeam2Ready - - log.Printf("[handleCheckStart] Check: Team1=%d/%d ready=%v, Team2=%d/%d ready=%v, AllReady=%v", + + log.Printf("[handleCheckStart] Check: Team1=%d/%d ready=%v, Team2=%d/%d ready=%v, AllReady=%v", team1ReadyCount, team1MembersCount, allTeam1Ready, team2ReadyCount, team2MembersCount, allTeam2Ready, allReady) - + if allReady && room.CurrentPhase == "setup" { log.Printf("[handleCheckStart] ✓✓✓ ALL TEAMS READY! Starting countdown...") - + // Update phase to prevent multiple triggers room.CurrentPhase = "countdown" - + // Broadcast countdown start to ALL clients immediately countdownMessage := map[string]interface{}{ - "type": "countdownStart", + "type": "countdownStart", "countdown": 3, } for _, r := range room.Clients { @@ -952,24 +1024,24 @@ func handleCheckStart(room *TeamRoom, conn *websocket.Conn, roomKey string) { log.Printf("[handleCheckStart] ✓ Countdown message sent") } } - + // Start countdown and phase change after 3 seconds go func() { time.Sleep(3 * time.Second) - + teamRoomsMutex.Lock() room, stillExists := teamRooms[roomKey] teamRoomsMutex.Unlock() - + if !stillExists { log.Printf("[handleCheckStart] Room %s no longer exists", roomKey) return } - + room.Mutex.Lock() if room.CurrentPhase == "countdown" || room.CurrentPhase == "setup" { room.CurrentPhase = "openingFor" - + // Broadcast phase change to ALL clients phaseMessage := TeamMessage{ Type: "phaseChange", @@ -987,7 +1059,78 @@ func handleCheckStart(room *TeamRoom, conn *websocket.Conn, roomKey string) { room.Mutex.Unlock() }() } else { - log.Printf("[handleCheckStart] Not all ready: Team1=%d/%d, Team2=%d/%d", + log.Printf("[handleCheckStart] Not all ready: Team1=%d/%d, Team2=%d/%d", team1ReadyCount, team1MembersCount, team2ReadyCount, team2MembersCount) } } + +// handleTeamWebRTCOffer relays an SDP offer to the intended target user +func handleTeamWebRTCOffer(room *TeamRoom, client *TeamClient, message TeamMessage) { + if message.TargetUserID == "" || message.Offer == nil { + log.Printf("[handleTeamWebRTCOffer] Missing offer or target for user %s", client.UserID.Hex()) + return + } + + payload := map[string]any{ + "type": "offer", + "fromUserId": client.UserID.Hex(), + "targetUserId": message.TargetUserID, + "teamId": client.TeamID.Hex(), + "offer": message.Offer, + } + + if err := sendMessageToUser(room, message.TargetUserID, payload); err != nil { + log.Printf("[handleTeamWebRTCOffer] Failed forwarding offer from %s to %s: %v", client.UserID.Hex(), message.TargetUserID, err) + } +} + +// handleTeamWebRTCAnswer relays an SDP answer to the offer initiator +func handleTeamWebRTCAnswer(room *TeamRoom, client *TeamClient, message TeamMessage) { + if message.TargetUserID == "" || message.Answer == nil { + log.Printf("[handleTeamWebRTCAnswer] Missing answer or target for user %s", client.UserID.Hex()) + return + } + + payload := map[string]any{ + "type": "answer", + "fromUserId": client.UserID.Hex(), + "targetUserId": message.TargetUserID, + "teamId": client.TeamID.Hex(), + "answer": message.Answer, + } + + if err := sendMessageToUser(room, message.TargetUserID, payload); err != nil { + log.Printf("[handleTeamWebRTCAnswer] Failed forwarding answer from %s to %s: %v", client.UserID.Hex(), message.TargetUserID, err) + } +} + +// handleTeamWebRTCCandidate relays ICE candidates during WebRTC negotiation +func handleTeamWebRTCCandidate(room *TeamRoom, client *TeamClient, message TeamMessage) { + if message.TargetUserID == "" || message.Candidate == nil { + log.Printf("[handleTeamWebRTCCandidate] Missing candidate or target for user %s", client.UserID.Hex()) + return + } + + payload := map[string]any{ + "type": "candidate", + "fromUserId": client.UserID.Hex(), + "targetUserId": message.TargetUserID, + "teamId": client.TeamID.Hex(), + "candidate": message.Candidate, + } + + if err := sendMessageToUser(room, message.TargetUserID, payload); err != nil { + log.Printf("[handleTeamWebRTCCandidate] Failed forwarding candidate from %s to %s: %v", client.UserID.Hex(), message.TargetUserID, err) + } +} + +// handleTeamLeave notifies all clients that a participant has left voluntarily +func handleTeamLeave(room *TeamRoom, client *TeamClient, roomKey string) { + payload := map[string]any{ + "type": "leave", + "userId": client.UserID.Hex(), + "teamId": client.TeamID.Hex(), + } + broadcastAll(room, payload) + log.Printf("[handleTeamLeave] User %s left room %s", client.UserID.Hex(), roomKey) +} diff --git a/frontend/src/Pages/TeamBuilder.tsx b/frontend/src/Pages/TeamBuilder.tsx index 2a21ba0..c0a6c01 100644 --- a/frontend/src/Pages/TeamBuilder.tsx +++ b/frontend/src/Pages/TeamBuilder.tsx @@ -1,6 +1,4 @@ import React, { useState, useCallback } from "react"; -import { useAtom } from "jotai"; -import { userAtom } from "@/state/userAtom"; import { createTeam, getAvailableTeams, @@ -19,6 +17,7 @@ import { Input } from "@/components/ui/input"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; +import { useUser } from "@/hooks/useUser"; import { Dialog, DialogContent, @@ -81,7 +80,7 @@ interface SelectedMember { } const TeamBuilder: React.FC = () => { - const [user] = useAtom(userAtom); + const { user } = useUser(); const [teamName, setTeamName] = useState(""); const [maxSize, setMaxSize] = useState(4); const [isCreating, setIsCreating] = useState(false); @@ -637,7 +636,18 @@ const TeamBuilder: React.FC = () => { {/* Team Matchmaking */}
- +
diff --git a/frontend/src/Pages/TeamDebateRoom.tsx b/frontend/src/Pages/TeamDebateRoom.tsx index ea5f1c7..8e05cee 100644 --- a/frontend/src/Pages/TeamDebateRoom.tsx +++ b/frontend/src/Pages/TeamDebateRoom.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; -import { useParams, useNavigate } from "react-router-dom"; +import { useParams } from "react-router-dom"; import { useAtom } from "jotai"; import { userAtom } from "@/state/userAtom"; import { useUser } from "@/hooks/useUser"; @@ -86,6 +86,8 @@ interface WSMessage { userId?: string; username?: string; timestamp?: number; + fromUserId?: string; + targetUserId?: string; mode?: "type" | "speak"; isTyping?: boolean; isSpeaking?: boolean; @@ -128,7 +130,6 @@ const extractJSON = (response: string): string => { const TeamDebateRoom: React.FC = () => { const { debateId } = useParams<{ debateId: string }>(); - const navigate = useNavigate(); const [user] = useAtom(userAtom); const { user: userFromHook, isLoading: isUserLoading, isAuthenticated } = useUser(); @@ -164,7 +165,6 @@ const TeamDebateRoom: React.FC = () => { const [myTeamName, setMyTeamName] = useState(""); const [opponentTeamName, setOpponentTeamName] = useState(""); const [myTeamId, setMyTeamId] = useState(null); - const [opponentTeamId, setOpponentTeamId] = useState(null); const [isTeam1, setIsTeam1] = useState(false); const [team1ReadyCount, setTeam1ReadyCount] = useState(0); const [team2ReadyCount, setTeam2ReadyCount] = useState(0); @@ -173,6 +173,7 @@ const TeamDebateRoom: React.FC = () => { // Track individual ready status for each player const [playerReadyStatus, setPlayerReadyStatus] = useState>(new Map()); + const [hasDeterminedTeam, setHasDeterminedTeam] = useState(false); // Refs for WebSocket, PeerConnections, and media elements const wsRef = useRef(null); @@ -202,6 +203,7 @@ const TeamDebateRoom: React.FC = () => { const [speechTranscripts, setSpeechTranscripts] = useState<{ [key: string]: string; }>({}); + const [speechRecognitionDisabled, setSpeechRecognitionDisabled] = useState(false); // Popup and countdown state const [showSetupPopup, setShowSetupPopup] = useState(true); @@ -231,6 +233,276 @@ const TeamDebateRoom: React.FC = () => { "Is online learning as effective as traditional education?", ]; + const toggleCamera = useCallback(async () => { + const shouldEnable = !isCameraOn; + + // Acquire a stream if we're turning the camera back on after it was released + if (shouldEnable && !localStreamRef.current) { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { width: 1280, height: 720 }, + audio: true, + }); + localStreamRef.current = stream; + setLocalStream(stream); + + const localVideoEl = localVideoRefs.current.get(currentUser?.id || ""); + if (localVideoEl) { + localVideoEl.srcObject = stream; + } + } catch (error) { + console.error("toggleCamera: failed to enable camera", error); + setMediaError( + "Unable to access the camera. Please check permissions and try again." + ); + return; + } + } + + const stream = localStreamRef.current; + if (!stream) { + console.warn("toggleCamera called without an active local stream."); + return; + } + + stream.getVideoTracks().forEach((track) => { + track.enabled = shouldEnable; + }); + + setIsCameraOn(shouldEnable); + if (shouldEnable) { + setMediaError(null); + } + }, [currentUser?.id, isCameraOn, setIsCameraOn]); + + const currentUserIdRef = useRef(currentUser?.id); + const isTeam1Ref = useRef(isTeam1); + const debatePhaseRef = useRef(debatePhase); + + useEffect(() => { + currentUserIdRef.current = currentUser?.id; + }, [currentUser?.id]); + + useEffect(() => { + isTeam1Ref.current = isTeam1; + }, [isTeam1]); + + useEffect(() => { + debatePhaseRef.current = debatePhase; + }, [debatePhase]); + + const pendingCandidatesRef = useRef>(new Map()); + const initiatedOffersRef = useRef>(new Set()); + + const sendSignalMessage = useCallback((message: Record) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify(message)); + } else { + console.warn("WebSocket not ready for signalling message:", message); + } + }, []); + + const attachStreamToVideo = useCallback((userId: string, stream: MediaStream) => { + const videoEl = remoteVideoRefs.current.get(userId); + if (videoEl && videoEl.srcObject !== stream) { + videoEl.srcObject = stream; + if (typeof videoEl.play === "function") { + videoEl + .play() + .catch((err) => + console.warn("Unable to autoplay remote video element", err) + ); + } + } + }, []); + + const closePeerConnection = useCallback( + (remoteUserId: string) => { + const pc = pcRefs.current.get(remoteUserId); + if (pc) { + try { + pc.ontrack = null; + pc.onicecandidate = null; + pc.oniceconnectionstatechange = null; + pc.close(); + } catch (error) { + console.warn("Error closing peer connection", error); + } + pcRefs.current.delete(remoteUserId); + } + pendingCandidatesRef.current.delete(remoteUserId); + initiatedOffersRef.current.delete(remoteUserId); + + setRemoteStreams((prev) => { + if (!prev.has(remoteUserId)) return prev; + const updated = new Map(prev); + updated.delete(remoteUserId); + return updated; + }); + remoteVideoRefs.current.delete(remoteUserId); + }, + [setRemoteStreams] + ); + + const createPeerConnection = useCallback( + (remoteUserId: string): RTCPeerConnection | undefined => { + if (pcRefs.current.has(remoteUserId)) { + return pcRefs.current.get(remoteUserId); + } + + if (!localStreamRef.current) { + console.warn("Attempted to create peer connection without local media stream"); + return undefined; + } + + const pc = new RTCPeerConnection({ + iceServers: [{ urls: "stun:stun.l.google.com:19302" }], + }); + + pcRefs.current.set(remoteUserId, pc); + + pc.onicecandidate = (event) => { + if (event.candidate && currentUserIdRef.current) { + const serialisedCandidate = { + candidate: event.candidate.candidate, + sdpMid: event.candidate.sdpMid, + sdpMLineIndex: event.candidate.sdpMLineIndex, + }; + sendSignalMessage({ + type: "candidate", + fromUserId: currentUserIdRef.current, + targetUserId: remoteUserId, + candidate: serialisedCandidate, + }); + } + }; + + pc.ontrack = (event) => { + const [stream] = event.streams; + if (!stream) return; + setRemoteStreams((prev) => { + const updated = new Map(prev); + updated.set(remoteUserId, stream); + return updated; + }); + attachStreamToVideo(remoteUserId, stream); + }; + + pc.oniceconnectionstatechange = () => { + const state = pc.iceConnectionState; + if (state === "failed" || state === "disconnected" || state === "closed") { + closePeerConnection(remoteUserId); + } + }; + + localStreamRef.current.getTracks().forEach((track) => { + pc.addTrack(track, localStreamRef.current as MediaStream); + }); + + return pc; + }, + [attachStreamToVideo, closePeerConnection, sendSignalMessage] + ); + + const flushPendingCandidates = useCallback(async (remoteUserId: string) => { + const pc = pcRefs.current.get(remoteUserId); + const pending = pendingCandidatesRef.current.get(remoteUserId); + if (!pc || !pending || !pc.remoteDescription) return; + + for (const candidate of pending) { + try { + await pc.addIceCandidate(new RTCIceCandidate(candidate)); + } catch (error) { + console.error("Failed to apply pending ICE candidate", error); + } + } + pendingCandidatesRef.current.delete(remoteUserId); + }, []); + + const initiateOffer = useCallback( + async (remoteUserId: string) => { + const currentUserId = currentUserIdRef.current; + if (!currentUserId) return; + + let pc = pcRefs.current.get(remoteUserId); + if (!pc) { + pc = createPeerConnection(remoteUserId); + } + if (!pc) return; + + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + sendSignalMessage({ + type: "offer", + fromUserId: currentUserId, + targetUserId: remoteUserId, + offer: { + type: offer.type, + sdp: offer.sdp, + }, + }); + } catch (error) { + console.error("Failed to create/send WebRTC offer", error); + } + }, + [createPeerConnection, sendSignalMessage] + ); + + const ensurePeerConnection = useCallback( + (remoteUserId: string) => { + const currentUserId = currentUserIdRef.current; + if (!currentUserId || !remoteUserId || remoteUserId === currentUserId) return; + if (!localStreamRef.current) return; + + if (!pcRefs.current.has(remoteUserId)) { + createPeerConnection(remoteUserId); + } + + if (currentUserId < remoteUserId && !initiatedOffersRef.current.has(remoteUserId)) { + initiatedOffersRef.current.add(remoteUserId); + initiateOffer(remoteUserId); + } + }, + [createPeerConnection, initiateOffer] + ); + + useEffect(() => { + remoteStreams.forEach((stream, userId) => { + attachStreamToVideo(userId, stream); + }); + }, [remoteStreams, attachStreamToVideo]); + + useEffect(() => { + if (!hasDeterminedTeam || !localStreamRef.current) return; + const currentUserId = currentUserIdRef.current; + if (!currentUserId) return; + + const uniqueMemberIds = Array.from( + new Set( + [...myTeamMembers, ...opponentTeamMembers] + .map((member) => member.userId) + .filter((id): id is string => Boolean(id)) + ) + ); + + uniqueMemberIds.forEach((memberId) => { + ensurePeerConnection(memberId); + }); + + pcRefs.current.forEach((_pc, userId) => { + if (!uniqueMemberIds.includes(userId)) { + closePeerConnection(userId); + } + }); + }, [ + myTeamMembers, + opponentTeamMembers, + hasDeterminedTeam, + ensurePeerConnection, + closePeerConnection, + localStream, + ]); // Ordered list of debate phases const phaseOrder: DebatePhase[] = [ DebatePhase.OpeningFor, @@ -303,7 +575,6 @@ const TeamDebateRoom: React.FC = () => { if (userTeam1) { setIsTeam1(true); setMyTeamId(debateData.team1Id); - setOpponentTeamId(debateData.team2Id); setMyTeamName(debateData.team1Name || "Team 1"); setOpponentTeamName(debateData.team2Name || "Team 2"); setMyTeamMembers(debateData.team1Members || []); @@ -316,7 +587,6 @@ const TeamDebateRoom: React.FC = () => { } else if (userTeam2) { setIsTeam1(false); setMyTeamId(debateData.team2Id); - setOpponentTeamId(debateData.team1Id); setMyTeamName(debateData.team2Name || "Team 2"); setOpponentTeamName(debateData.team1Name || "Team 1"); setMyTeamMembers(debateData.team2Members || []); @@ -330,9 +600,11 @@ const TeamDebateRoom: React.FC = () => { console.error("[TeamDebateRoom] ERROR: User is not in either team!"); } + setHasDeterminedTeam(true); setIsLoading(false); } catch (error) { console.error("Failed to fetch debate:", error); + setHasDeterminedTeam(true); setIsLoading(false); } }; @@ -381,34 +653,77 @@ const TeamDebateRoom: React.FC = () => { // User ID will be extracted from token on backend useEffect(() => { const token = getAuthToken(); - if (!token || !debateId) { - console.log("[TeamDebateRoom] Waiting for token or debateId before connecting WebSocket...", { + if (!token || !debateId || !hasDeterminedTeam) { + console.log("[TeamDebateRoom] Waiting for prerequisites before connecting WebSocket...", { hasToken: !!token, - debateId + debateId, + hasDeterminedTeam, }); return; } console.log("[TeamDebateRoom] Initializing WebSocket connection...", { debateId, - userId: currentUser?.id + userId: currentUserIdRef.current, + hasDeterminedTeam, }); + let cancelled = false; + + const ensureMediaStream = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { width: 1280, height: 720 }, + audio: true, + }); + + if (cancelled) { + stream.getTracks().forEach((track) => track.stop()); + return; + } + + localStreamRef.current = stream; + setLocalStream(stream); + + const localVideo = localVideoRefs.current.get( + currentUserIdRef.current || "" + ); + if (localVideo) { + localVideo.srcObject = stream; + } + } catch (err) { + if (cancelled) return; + setMediaError( + "Failed to access camera/microphone. Please check permissions." + ); + console.error("Media error:", err); + } + }; + const ws = new WebSocket( `ws://localhost:1313/ws/team?debateId=${debateId}&token=${token}` ); wsRef.current = ws; ws.onopen = () => { + if (cancelled) { + ws.close(); + return; + } + console.log("Team debate WebSocket connected"); ws.send(JSON.stringify({ type: "join" })); - getMedia(); + ensureMediaStream(); }; ws.onmessage = async (event) => { const data: WSMessage = JSON.parse(event.data); console.log("Received WebSocket message:", data); + const isTeam1Latest = isTeam1Ref.current; + const currentUserId = currentUserIdRef.current; + const currentPhase = debatePhaseRef.current; + switch (data.type) { case "stateSync": // Sync all state when joining or receiving state update @@ -420,7 +735,7 @@ const TeamDebateRoom: React.FC = () => { if (debateStartedRef.current && backendPhase === DebatePhase.Setup) { console.log('⚠️ stateSync tried to reset phase to Setup, but debate has started - ignoring'); } else { - console.log(`stateSync: updating phase from ${debatePhase} to ${backendPhase}`); + console.log(`stateSync: updating phase from ${currentPhase} to ${backendPhase}`); setDebatePhase(backendPhase); // If backend says phase is not Setup, mark debate as started if (backendPhase !== DebatePhase.Setup) { @@ -431,7 +746,7 @@ const TeamDebateRoom: React.FC = () => { // Set roles based on which team the user is on // If user is Team1, their role is team1Role, opponent role is team2Role // If user is Team2, their role is team2Role, opponent role is team1Role - if (isTeam1) { + if (isTeam1Latest) { if (data.team1Role) { setLocalRole(data.team1Role as DebateRole); } @@ -453,14 +768,14 @@ const TeamDebateRoom: React.FC = () => { // Update team names if provided (for late joiners) if ((data as any).team1Name) { - if (isTeam1) { + if (isTeam1Latest) { setMyTeamName((data as any).team1Name); } else { setOpponentTeamName((data as any).team1Name); } } if ((data as any).team2Name) { - if (isTeam1) { + if (isTeam1Latest) { setOpponentTeamName((data as any).team2Name); } else { setMyTeamName((data as any).team2Name); @@ -491,32 +806,38 @@ const TeamDebateRoom: React.FC = () => { // Check if opponent team members are all ready (but don't override localReady) // localReady should only be set when the user clicks the ready button - const opponentReady = isTeam1 ? data.team2Ready : data.team1Ready; - const opponentCount = isTeam1 ? data.team2MembersCount : data.team1MembersCount; - setPeerReady(opponentReady === opponentCount && opponentCount > 0); + const opponentReady = isTeam1Latest ? data.team2Ready : data.team1Ready; + const opponentCount = isTeam1Latest + ? data.team2MembersCount + : data.team1MembersCount; + const resolvedOpponentReady = opponentReady ?? 0; + const resolvedOpponentCount = opponentCount ?? 0; + setPeerReady( + resolvedOpponentCount > 0 && resolvedOpponentReady === resolvedOpponentCount + ); // Update localReady if we have the user's ready status in stateSync - if (currentUser?.id) { + if (currentUserId) { const team1Status = (data as any).team1ReadyStatus as Record | undefined; const team2Status = (data as any).team2ReadyStatus as Record | undefined; - if (isTeam1 && team1Status && team1Status[currentUser.id] !== undefined) { - setLocalReady(team1Status[currentUser.id]); - } else if (!isTeam1 && team2Status && team2Status[currentUser.id] !== undefined) { - setLocalReady(team2Status[currentUser.id]); + if (isTeam1Latest && team1Status && team1Status[currentUserId] !== undefined) { + setLocalReady(team1Status[currentUserId]); + } else if (!isTeam1Latest && team2Status && team2Status[currentUserId] !== undefined) { + setLocalReady(team2Status[currentUserId]); } } break; case "teamMembers": if (data.team1Members) { - if (isTeam1) { + if (isTeam1Latest) { setMyTeamMembers(data.team1Members); } else { setOpponentTeamMembers(data.team1Members); } } if (data.team2Members) { - if (isTeam1) { + if (isTeam1Latest) { setOpponentTeamMembers(data.team2Members); } else { setMyTeamMembers(data.team2Members); @@ -524,9 +845,9 @@ const TeamDebateRoom: React.FC = () => { } // Initialize ready status for new members (default to false) if (data.team1Members) { - setPlayerReadyStatus(prev => { + setPlayerReadyStatus((prev) => { const updated = new Map(prev); - data.team1Members.forEach((member: TeamMember) => { + data.team1Members?.forEach((member: TeamMember) => { if (!updated.has(member.userId)) { updated.set(member.userId, false); } @@ -535,9 +856,9 @@ const TeamDebateRoom: React.FC = () => { }); } if (data.team2Members) { - setPlayerReadyStatus(prev => { + setPlayerReadyStatus((prev) => { const updated = new Map(prev); - data.team2Members.forEach((member: TeamMember) => { + data.team2Members?.forEach((member: TeamMember) => { if (!updated.has(member.userId)) { updated.set(member.userId, false); } @@ -582,11 +903,11 @@ const TeamDebateRoom: React.FC = () => { case "ready": console.log("=== READY MESSAGE RECEIVED ==="); console.log("Received ready message:", data); - console.log("Current user:", currentUser?.id); + console.log("Current user:", currentUserId); console.log("Message userId:", data.userId); console.log("Message teamId:", data.teamId); console.log("Message assignedToTeam:", (data as any).assignedToTeam); - console.log("isTeam1:", isTeam1); + console.log("isTeam1:", isTeam1Latest); console.log("myTeamId:", myTeamId); console.log("Team1Ready:", data.team1Ready, "Team2Ready:", data.team2Ready); console.log("Team1MembersCount:", data.team1MembersCount, "Team2MembersCount:", data.team2MembersCount); @@ -597,10 +918,10 @@ const TeamDebateRoom: React.FC = () => { const assignedTeam = (data as any).assignedToTeam; // Update the ready status for the specific user who clicked - if (data.userId === currentUser?.id && data.ready !== undefined) { + if (data.userId === currentUserId && data.ready !== undefined) { // Verify team assignment matches - if (assignedTeam && assignedTeam !== (isTeam1 ? "Team1" : "Team2")) { - console.error(`❌ CRITICAL ERROR: Ready status assigned to wrong team! User ${data.userId} is ${isTeam1 ? "Team1" : "Team2"} but assigned to ${assignedTeam}`); + if (assignedTeam && assignedTeam !== (isTeam1Latest ? "Team1" : "Team2")) { + console.error(`❌ CRITICAL ERROR: Ready status assigned to wrong team! User ${data.userId} is ${isTeam1Latest ? "Team1" : "Team2"} but assigned to ${assignedTeam}`); } else if (messageTeamId && expectedTeamId && messageTeamId !== expectedTeamId) { console.error(`❌ WARNING: TeamId mismatch! Expected ${expectedTeamId}, got ${messageTeamId}`); } else { @@ -646,12 +967,20 @@ const TeamDebateRoom: React.FC = () => { // CRITICAL: Each user should see their own team correctly // Use (data as any) to access fields that might not be in TypeScript interface const dataAny = data as any; - const myTeamReadyCount = isTeam1 ? (data.team1Ready ?? dataAny.team1Ready) : (data.team2Ready ?? dataAny.team2Ready); - const myTeamTotal = isTeam1 ? (data.team1MembersCount ?? dataAny.team1MembersCount) : (data.team2MembersCount ?? dataAny.team2MembersCount); - const oppReadyCount = isTeam1 ? (data.team2Ready ?? dataAny.team2Ready) : (data.team1Ready ?? dataAny.team1Ready); - const oppTeamTotal = isTeam1 ? (data.team2MembersCount ?? dataAny.team2MembersCount) : (data.team1MembersCount ?? dataAny.team1MembersCount); + const myTeamReadyCount = isTeam1Latest + ? data.team1Ready ?? dataAny.team1Ready + : data.team2Ready ?? dataAny.team2Ready; + const myTeamTotal = isTeam1Latest + ? data.team1MembersCount ?? dataAny.team1MembersCount + : data.team2MembersCount ?? dataAny.team2MembersCount; + const oppReadyCount = isTeam1Latest + ? data.team2Ready ?? dataAny.team2Ready + : data.team1Ready ?? dataAny.team1Ready; + const oppTeamTotal = isTeam1Latest + ? data.team2MembersCount ?? dataAny.team2MembersCount + : data.team1MembersCount ?? dataAny.team1MembersCount; - console.log(`[Display] isTeam1=${isTeam1}, myTeamName=${myTeamName}`); + console.log(`[Display] isTeam1=${isTeam1Latest}, myTeamName=${myTeamName}`); console.log(`[Display] My Team (${myTeamName}) Ready: ${myTeamReadyCount}/${myTeamTotal}`); console.log(`[Display] Opponent Team (${opponentTeamName}) Ready: ${oppReadyCount}/${oppTeamTotal}`); console.log(`[Display] Backend counts - Team1Ready=${data.team1Ready}, Team2Ready=${data.team2Ready}`); @@ -659,10 +988,10 @@ const TeamDebateRoom: React.FC = () => { console.log(`[Display] Full ready message data:`, JSON.stringify(data)); // Validation: Ensure we're showing the right team - if (data.userId === currentUser?.id && assignedTeam) { - const expectedTeamForUser = isTeam1 ? "Team1" : "Team2"; + if (data.userId === currentUserId && assignedTeam) { + const expectedTeamForUser = isTeam1Latest ? "Team1" : "Team2"; if (assignedTeam !== expectedTeamForUser) { - console.error(`❌ CRITICAL: User ${currentUser?.id} is ${isTeam1 ? "Team1" : "Team2"} but ready assigned to ${assignedTeam}!`); + console.error(`❌ CRITICAL: User ${currentUserId} is ${isTeam1Latest ? "Team1" : "Team2"} but ready assigned to ${assignedTeam}!`); } else { console.log(`✓ Validation passed: User is ${expectedTeamForUser} and ready assigned to ${assignedTeam}`); } @@ -676,7 +1005,7 @@ const TeamDebateRoom: React.FC = () => { case "phaseChange": if (data.phase) { const newPhase = data.phase as DebatePhase; - console.log(`✓✓✓ RECEIVED PHASE CHANGE: ${newPhase} (previous: ${debatePhase})`); + console.log(`✓✓✓ RECEIVED PHASE CHANGE: ${newPhase} (previous: ${currentPhase})`); console.log(`Phase change data:`, data); // Ensure we accept the phase change @@ -697,7 +1026,7 @@ const TeamDebateRoom: React.FC = () => { break; case "speechText": if (data.userId && data.speechText) { - const targetPhase = data.phase || debatePhase; + const targetPhase = data.phase || currentPhase; setSpeechTranscripts((prev) => ({ ...prev, [targetPhase]: @@ -709,7 +1038,7 @@ const TeamDebateRoom: React.FC = () => { if ( data.userId && data.liveTranscript && - data.userId !== currentUser?.id + data.userId !== currentUserId ) { setCurrentTranscript(data.liveTranscript); } @@ -717,14 +1046,14 @@ const TeamDebateRoom: React.FC = () => { case "teamStatus": // Update team member status if (data.team1Members) { - if (isTeam1) { + if (isTeam1Latest) { setMyTeamMembers(data.team1Members); } else { setOpponentTeamMembers(data.team1Members); } } if (data.team2Members) { - if (isTeam1) { + if (isTeam1Latest) { setOpponentTeamMembers(data.team2Members); } else { setMyTeamMembers(data.team2Members); @@ -732,13 +1061,98 @@ const TeamDebateRoom: React.FC = () => { } break; case "offer": - // Handle WebRTC offer + if ( + data.targetUserId !== currentUserId || + !data.fromUserId || + !data.offer + ) { + break; + } + { + let pc = pcRefs.current.get(data.fromUserId); + if (!pc) { + pc = createPeerConnection(data.fromUserId); + } + if (!pc) break; + try { + await pc.setRemoteDescription( + new RTCSessionDescription(data.offer) + ); + await flushPendingCandidates(data.fromUserId); + + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + if (currentUserId) { + sendSignalMessage({ + type: "answer", + fromUserId: currentUserId, + targetUserId: data.fromUserId, + answer: { + type: answer.type, + sdp: answer.sdp, + }, + }); + } + } catch (error) { + console.error("Failed to process WebRTC offer", error); + } + } break; case "answer": - // Handle WebRTC answer + if ( + data.targetUserId !== currentUserId || + !data.fromUserId || + !data.answer + ) { + break; + } + { + const pc = pcRefs.current.get(data.fromUserId); + if (!pc) break; + try { + await pc.setRemoteDescription( + new RTCSessionDescription(data.answer) + ); + await flushPendingCandidates(data.fromUserId); + } catch (error) { + console.error("Failed to process WebRTC answer", error); + } + } break; case "candidate": - // Handle WebRTC ICE candidate + if ( + data.targetUserId !== currentUserId || + !data.fromUserId || + !data.candidate + ) { + break; + } + { + let pc = pcRefs.current.get(data.fromUserId); + if (!pc) { + pc = createPeerConnection(data.fromUserId); + } + if (!pc) break; + + const candidateInit = data.candidate as RTCIceCandidateInit; + try { + if (pc.remoteDescription) { + await pc.addIceCandidate(new RTCIceCandidate(candidateInit)); + } else { + const queue = + pendingCandidatesRef.current.get(data.fromUserId) || []; + queue.push(candidateInit); + pendingCandidatesRef.current.set(data.fromUserId, queue); + } + } catch (error) { + console.error("Failed to add ICE candidate", error); + } + } + break; + case "leave": + if (data.userId) { + closePeerConnection(data.userId); + } break; } }; @@ -746,41 +1160,34 @@ const TeamDebateRoom: React.FC = () => { ws.onerror = (err) => console.error("WebSocket error:", err); ws.onclose = () => console.log("WebSocket closed"); - const getMedia = async () => { - try { - const stream = await navigator.mediaDevices.getUserMedia({ - video: { width: 1280, height: 720 }, - audio: true, - }); - setLocalStream(stream); - localStreamRef.current = stream; - - // Attach local stream to video element - const localVideo = localVideoRefs.current.get(currentUser?.id || ""); - if (localVideo) { - localVideo.srcObject = stream; - } - } catch (err) { - setMediaError( - "Failed to access camera/microphone. Please check permissions." - ); - console.error("Media error:", err); - } - }; - return () => { + cancelled = true; if (localStreamRef.current) { localStreamRef.current.getTracks().forEach((track) => track.stop()); } if (wsRef.current) { wsRef.current.close(); + wsRef.current = null; } pcRefs.current.forEach((pc) => pc.close()); }; - }, [debateId, isTeam1, debatePhase, currentUser?.id, debate]); // Include currentUser?.id and debate in dependencies + }, [ + debateId, + hasDeterminedTeam, + createPeerConnection, + flushPendingCandidates, + sendSignalMessage, + closePeerConnection, + myTeamId, + ]); // Initialize Speech Recognition useEffect(() => { + if (speechRecognitionDisabled) { + recognitionRef.current = null; + return; + } + const initializeSpeechRecognition = () => { if ( "SpeechRecognition" in window || @@ -861,6 +1268,7 @@ const TeamDebateRoom: React.FC = () => { isMyTurn && debatePhase !== DebatePhase.Setup && debatePhase !== DebatePhase.Finished + && !speechRecognitionDisabled ) { setTimeout(() => { if (recognitionRef.current) { @@ -877,8 +1285,25 @@ const TeamDebateRoom: React.FC = () => { recognition.onerror = (event: Event) => { setIsListening(false); console.error("Speech recognition error:", event); + + const errorEvent = event as Event & { error?: string }; + if (errorEvent.error === "not-allowed") { + setSpeechRecognitionDisabled(true); + setSpeechError( + "Speech recognition is blocked. Please grant microphone permission or disable speech-to-text." + ); + try { + recognition.stop(); + } catch { + // ignore + } + recognitionRef.current = null; + } else { + setSpeechError("Speech recognition error occurred."); + } }; } else { + setSpeechRecognitionDisabled(true); setSpeechError("Speech recognition not supported in this browser"); } }; @@ -890,12 +1315,13 @@ const TeamDebateRoom: React.FC = () => { recognitionRef.current.stop(); } }; - }, [debatePhase, isMyTurn, currentUser?.id, currentUser?.displayName]); + }, [debatePhase, isMyTurn, currentUser?.id, currentUser?.displayName, speechRecognitionDisabled]); // Start/stop speech recognition based on turn const startSpeechRecognition = useCallback(() => { if ( !recognitionRef.current || + speechRecognitionDisabled || isListening || debatePhase === DebatePhase.Setup || debatePhase === DebatePhase.Finished @@ -908,7 +1334,7 @@ const TeamDebateRoom: React.FC = () => { } catch (error) { console.error("Error starting speech recognition:", error); } - }, [isListening, debatePhase]); + }, [isListening, debatePhase, speechRecognitionDisabled]); const stopSpeechRecognition = useCallback(() => { if (recognitionRef.current && isListening) { @@ -1536,6 +1962,10 @@ const TeamDebateRoom: React.FC = () => { } } else { remoteVideoRefs.current.set(member.userId, el); + const existingStream = remoteStreams.get(member.userId); + if (existingStream) { + attachStreamToVideo(member.userId, existingStream); + } } } }} @@ -1627,6 +2057,10 @@ const TeamDebateRoom: React.FC = () => { ref={(el) => { if (el) { remoteVideoRefs.current.set(member.userId, el); + const existingStream = remoteStreams.get(member.userId); + if (existingStream) { + attachStreamToVideo(member.userId, existingStream); + } } }} autoPlay @@ -1655,12 +2089,17 @@ const TeamDebateRoom: React.FC = () => {
)} - {/* Media Error Display */} + {/* Media / Speech Error Display */} {mediaError && (

{mediaError}

)} + {speechError && ( +

+ {speechError} +

+ )}