From f2aead10a03343b38844de9b54cd770108e9922d Mon Sep 17 00:00:00 2001 From: Guneev Pannu Date: Wed, 9 Sep 2026 16:27:13 -0400 Subject: [PATCH 1/2] Fix for room stall bug (Counter stuck at 0, can't send any chat messages) --- src/app/room/page.tsx | 8 +++- src/socket/index.ts | 94 +++++++++++++++++++++++++++++-------------- src/socket/types.ts | 3 ++ 3 files changed, 73 insertions(+), 32 deletions(-) diff --git a/src/app/room/page.tsx b/src/app/room/page.tsx index e5b1ddd..24c8451 100644 --- a/src/app/room/page.tsx +++ b/src/app/room/page.tsx @@ -7,6 +7,7 @@ import { Download, Square, X } from "lucide-react"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { useMediaQuery } from "@/hooks/use-media-query"; +import { toast } from "sonner"; import ClassChat from "./classChat"; import SlideViewer from "./slideViewer"; import type { ClientToServerEvents, ServerToClientEvents } from "@/socket/types"; @@ -306,7 +307,12 @@ function RoomInner() { s.on("connect", () => { if (sessionIdRef.current) { s.emit("session:join", { sessionId: sessionIdRef.current }, (error?: string) => { - if (!error) setSocket(s); + if (!error) { + setSocket(s); + } else { + setSocket(null); + toast.error(error); + } }); } else { setSocket(s); diff --git a/src/socket/index.ts b/src/socket/index.ts index 27dcc7c..e4e84a5 100644 --- a/src/socket/index.ts +++ b/src/socket/index.ts @@ -129,9 +129,17 @@ export async function initSocketIO( // ----------------------------------------------------------------------- async function broadcastViewerCount(sessionId: string) { - const sockets = await io!.in(`session:${sessionId}`).fetchSockets(); - const count = sockets.filter((s) => s.data.role !== "PROFESSOR").length; - io!.to(`session:${sessionId}`).emit("viewer:count", { count }); + try { + const sockets = await io!.in(`session:${sessionId}`).fetchSockets(); + // courseRole (enrollment) wins over cookie role + const count = sockets.filter( + (s) => (s.data.courseRole ?? s.data.role) !== "PROFESSOR" + ).length; + io!.to(`session:${sessionId}`).emit("viewer:count", { count }); + } catch (err) { + // Never let Redis/adapter failures block session:join ack (chat + UI gate on it). + console.error(`[Socket.IO] broadcastViewerCount failed for ${sessionId}:`, err); + } } io.on("connection", (socket) => { @@ -139,7 +147,16 @@ export async function initSocketIO( // Session room management socket.on("session:join", async (payload, ack) => { - if (payload?.sessionId && typeof payload.sessionId === "string") { + const reply = (error?: string) => { + if (typeof ack === "function") ack(error); + }; + + if (!payload?.sessionId || typeof payload.sessionId !== "string") { + reply("Invalid session."); + return; + } + + try { const userId = socket.data.userId; // Verify the session exists and the user is enrolled in the course. @@ -150,7 +167,7 @@ export async function initSocketIO( if (!sessionRecord) { socket.emit("question:error", { message: "Session not found." }); - if (typeof ack === "function") ack("Session not found."); + reply("Session not found."); return; } @@ -175,17 +192,20 @@ export async function initSocketIO( reason: "not enrolled", }); socket.emit("question:error", { message: "You are not enrolled in this session." }); - if (typeof ack === "function") ack("Not enrolled."); + reply("Not enrolled."); return; } if (socket.data.currentSessionId && socket.data.currentSessionId !== payload.sessionId) { - socket.leave(`session:${socket.data.currentSessionId}`); - await broadcastViewerCount(socket.data.currentSessionId); + const prev = socket.data.currentSessionId; + socket.leave(`session:${prev}`); + socket.data.courseRole = undefined; + void broadcastViewerCount(prev); } socket.join(`session:${payload.sessionId}`); socket.data.currentSessionId = payload.sessionId; + socket.data.courseRole = enrollment.role; console.log(`[Socket.IO] ${socket.id} joined session:${payload.sessionId}`); // Join the instructor room if the user is a TA or PROFESSOR in this course, @@ -194,32 +214,43 @@ export async function initSocketIO( socket.join(`session:${payload.sessionId}:instructors`); } - await broadcastViewerCount(payload.sessionId); - if (typeof ack === "function") ack(); + // Ack before count broadcast so Redis/adapter stalls never leave the client + // with socket===null (Post disabled + people counter stuck at 0). + reply(); + void broadcastViewerCount(payload.sessionId); + } catch (err) { + console.error("[Socket.IO] session:join failed:", err); + reply("Join failed."); } }); socket.on("viewer:sync", async (payload) => { if (payload?.sessionId && typeof payload.sessionId === "string") { - const userId = socket.data.userId; - if (!userId) return; - - // Enrollment check — must be enrolled in the session's course - const syncSession = await prisma.session.findUnique({ - where: { id: payload.sessionId }, - select: { courseId: true }, - }); - if (!syncSession) return; - - const syncEnrollment = await prisma.courseEnrollment.findUnique({ - where: { userId_courseId: { userId, courseId: syncSession.courseId } }, - select: { role: true }, - }); - if (!syncEnrollment) return; + try { + const userId = socket.data.userId; + if (!userId) return; + + // Enrollment check — must be enrolled in the session's course + const syncSession = await prisma.session.findUnique({ + where: { id: payload.sessionId }, + select: { courseId: true }, + }); + if (!syncSession) return; - const sockets = await io!.in(`session:${payload.sessionId}`).fetchSockets(); - const count = sockets.filter((s) => s.data.role !== "PROFESSOR").length; - socket.emit("viewer:count", { count }); + const syncEnrollment = await prisma.courseEnrollment.findUnique({ + where: { userId_courseId: { userId, courseId: syncSession.courseId } }, + select: { role: true }, + }); + if (!syncEnrollment) return; + + const sockets = await io!.in(`session:${payload.sessionId}`).fetchSockets(); + const count = sockets.filter( + (s) => (s.data.courseRole ?? s.data.role) !== "PROFESSOR" + ).length; + socket.emit("viewer:count", { count }); + } catch (err) { + console.error("[Socket.IO] viewer:sync failed:", err); + } } }); @@ -228,10 +259,11 @@ export async function initSocketIO( socket.leave(`session:${payload.sessionId}`); if (socket.data.currentSessionId === payload.sessionId) { socket.data.currentSessionId = undefined; + socket.data.courseRole = undefined; } console.log(`[Socket.IO] ${socket.id} left session:${payload.sessionId}`); - await broadcastViewerCount(payload.sessionId); + void broadcastViewerCount(payload.sessionId); } }); @@ -250,11 +282,11 @@ export async function initSocketIO( handleAnswerModeChange(socket, io!); handleAnswerModeSync(socket); - socket.on("disconnect", async (reason) => { + socket.on("disconnect", (reason) => { console.log(`[Socket.IO] Client disconnected: ${socket.id} (reason: ${reason})`); const sessionId = socket.data.currentSessionId; if (sessionId) { - await broadcastViewerCount(sessionId); + void broadcastViewerCount(sessionId); } }); }); diff --git a/src/socket/types.ts b/src/socket/types.ts index 3a8d1fc..87c7f0b 100644 --- a/src/socket/types.ts +++ b/src/socket/types.ts @@ -1,6 +1,9 @@ export interface SocketData { userId: string; + /** Global User.role from the session cookie — not course-scoped. */ role: string; + /** CourseEnrollment.role for the current session's course; set on session:join. */ + courseRole?: string; connectedAt: Date; currentSessionId?: string; } From 2ad74ae70a5aedcf7017512b963d929c53718168 Mon Sep 17 00:00:00 2001 From: Guneev Pannu Date: Wed, 9 Sep 2026 16:30:49 -0400 Subject: [PATCH 2/2] Give a useful error on short messages instead of silently failing --- src/app/room/classChat/ChatInput.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/app/room/classChat/ChatInput.tsx b/src/app/room/classChat/ChatInput.tsx index 6ad283f..6295d5e 100644 --- a/src/app/room/classChat/ChatInput.tsx +++ b/src/app/room/classChat/ChatInput.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { toast } from "sonner"; import { Textarea } from "@/components/ui/textarea"; import { Ghost, User, Send, Presentation } from "lucide-react"; import { useSlideContext } from "../RoomContext"; @@ -33,11 +34,8 @@ export default function ChatInput({ const slideContextAvailable = slideContext.slidePageIndex !== null && slideContext.slideSetId !== null; - // Out-of-bounds length just greys out Post, the same as an empty box. The - // server enforces the same bounds — see validateQuestionContent. const trimmed = content.trim(); - const canPost = - !disabled && trimmed.length >= QUESTION_MIN_LENGTH && trimmed.length <= QUESTION_MAX_LENGTH; + const canClick = !disabled && trimmed.length > 0; const handleChange = (e: React.ChangeEvent) => { setContent(e.target.value); @@ -45,7 +43,15 @@ export default function ChatInput({ }; const handleSubmit = () => { - if (!canPost) return; + if (!canClick) return; + if (trimmed.length < QUESTION_MIN_LENGTH) { + toast.error(`Question must be at least ${QUESTION_MIN_LENGTH} characters.`); + return; + } + if (trimmed.length > QUESTION_MAX_LENGTH) { + toast.error(`Question must be at most ${QUESTION_MAX_LENGTH} characters.`); + return; + } onSubmit(trimmed, isAnonymous, includeSlideContext && slideContextAvailable); setContent(""); }; @@ -120,7 +126,7 @@ export default function ChatInput({