Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions src/app/room/classChat/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -33,19 +34,24 @@ 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<HTMLTextAreaElement>) => {
setContent(e.target.value);
if (serverError) onClearError?.();
};

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("");
};
Expand Down Expand Up @@ -120,7 +126,7 @@ export default function ChatInput({
</div>
<button
onClick={handleSubmit}
disabled={!canPost}
disabled={!canClick}
className="flex items-center justify-center gap-1.5 h-9 px-4 bg-stone-900 hover:bg-stone-800 text-stone-50 rounded-md text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Post
Expand Down
8 changes: 7 additions & 1 deletion src/app/room/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
94 changes: 63 additions & 31 deletions src/socket/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,17 +129,34 @@ 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) => {
console.log(`[Socket.IO] Client connected: ${socket.id} (user: ${socket.data.userId})`);

// 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.
Expand All @@ -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;
}

Expand All @@ -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,
Expand All @@ -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);
}
}
});

Expand All @@ -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);
}
});

Expand All @@ -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);
}
});
});
Expand Down
3 changes: 3 additions & 0 deletions src/socket/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down
Loading