From 970105b4eead91f221ba0ad6574e0bd61166dd14 Mon Sep 17 00:00:00 2001 From: Guneev Pannu Date: Sun, 12 Jul 2026 11:59:44 -0400 Subject: [PATCH 01/10] Added slide context to schema and to all routes. There is now slide context --- pnpm-workspace.yaml | 8 ++ .../migration.sql | 12 +++ prisma/schema.prisma | 37 ++++---- .../sessions/[sessionId]/questions/route.ts | 24 ++++++ src/app/room/RoomContext.ts | 12 +++ src/app/room/classChat/index.tsx | 27 +++++- src/app/room/classChat/post/QuestionPost.tsx | 9 +- src/app/room/page.tsx | 17 +++- src/app/room/slideViewer.tsx | 45 +++++++++- src/lib/questionValidation.ts | 86 +++++++++++++++++++ src/server.ts | 7 +- src/socket/handlers/questionHandlers.ts | 24 ++++++ src/socket/types.ts | 4 + src/utils/types.ts | 2 + 14 files changed, 289 insertions(+), 25 deletions(-) create mode 100644 prisma/migrations/20260712000000_add_question_slide_context/migration.sql diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cde4117..b84c0e0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,14 @@ packages: - . +allowBuilds: + '@prisma/client': true + '@prisma/engines': true + esbuild: true + prisma: true + sharp: true + unrs-resolver: true + nodeLinker: hoisted onlyBuiltDependencies: diff --git a/prisma/migrations/20260712000000_add_question_slide_context/migration.sql b/prisma/migrations/20260712000000_add_question_slide_context/migration.sql new file mode 100644 index 0000000..72cf261 --- /dev/null +++ b/prisma/migrations/20260712000000_add_question_slide_context/migration.sql @@ -0,0 +1,12 @@ +-- AlterTable +ALTER TABLE "Session" ADD COLUMN "lastActivityAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Question" ADD COLUMN "slidePageIndex" INTEGER; +ALTER TABLE "Question" ADD COLUMN "slideSetId" TEXT; + +-- CreateIndex +CREATE INDEX "Question_slideSetId_idx" ON "Question"("slideSetId"); + +-- AddForeignKey +ALTER TABLE "Question" ADD CONSTRAINT "Question_slideSetId_fkey" FOREIGN KEY ("slideSetId") REFERENCES "SlideSet"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e83e89c..5d33f74 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -122,34 +122,39 @@ model SlideSet { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - session Session @relation(fields: [sessionId], references: [id]) - uploader User @relation("SlideSetUploader", fields: [uploadedBy], references: [id]) + session Session @relation(fields: [sessionId], references: [id]) + uploader User @relation("SlideSetUploader", fields: [uploadedBy], references: [id]) + questions Question[] @@index([sessionId]) @@index([uploadedBy]) } model Question { - id String @id @default(cuid()) - sessionId String - authorId String? - content String - isAnonymous Boolean @default(false) - visibility Visibility @default(PUBLIC) - status QuestionStatus @default(OPEN) - upvoteCount Int @default(0) - createdAt DateTime @default(now()) - - session Session @relation(fields: [sessionId], references: [id]) - author User? @relation(fields: [authorId], references: [id]) - answers Answer[] - upvotes QuestionUpvote[] + id String @id @default(cuid()) + sessionId String + authorId String? + content String + isAnonymous Boolean @default(false) + visibility Visibility @default(PUBLIC) + status QuestionStatus @default(OPEN) + upvoteCount Int @default(0) + slidePageIndex Int? + slideSetId String? + createdAt DateTime @default(now()) + + session Session @relation(fields: [sessionId], references: [id]) + author User? @relation(fields: [authorId], references: [id]) + slideSet SlideSet? @relation(fields: [slideSetId], references: [id], onDelete: SetNull) + answers Answer[] + upvotes QuestionUpvote[] @@index([sessionId]) @@index([authorId]) @@index([status]) @@index([visibility]) @@index([createdAt]) + @@index([slideSetId]) } model Answer { diff --git a/src/app/api/sessions/[sessionId]/questions/route.ts b/src/app/api/sessions/[sessionId]/questions/route.ts index 56277a3..56a6345 100644 --- a/src/app/api/sessions/[sessionId]/questions/route.ts +++ b/src/app/api/sessions/[sessionId]/questions/route.ts @@ -11,6 +11,7 @@ import { validateQuestionContent, validateVisibility, validateSessionForQuestions, + validateQuestionSlideContext, } from "@/lib/questionValidation"; import { getSessionMembership } from "@/lib/sessionService"; @@ -26,6 +27,8 @@ interface QuestionCreateBody { content: string; visibility?: "PUBLIC" | "INSTRUCTOR_ONLY"; isAnonymous?: boolean; + slidePageIndex?: number; + slideSetId?: string; } // --------------------------------------------------------------------------- @@ -116,6 +119,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) { hasAcceptedAnswer: q.answers.length > 0, acceptedAnswerId: q.answers[0]?.id ?? null, createdAt: q.createdAt, + slidePageIndex: q.slidePageIndex, + slideSetId: q.slideSetId, author: q.isAnonymous && !canRevealAnonymous ? null : q.author, })); @@ -157,6 +162,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) { * - authorId: string (required) * - visibility: "PUBLIC" | "INSTRUCTOR_ONLY" (optional, defaults to PUBLIC) * - isAnonymous: boolean (optional, defaults to false) + * - slidePageIndex: number (optional, 0-based page the asker was viewing) + * - slideSetId: string (optional, must belong to the session) * * Validations: * 1. Content length bounds @@ -209,6 +216,19 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return NextResponse.json({ error: sessionValidation.error }, { status: statusCode }); } + // 6b. Validate slide context — reject with 400 when provided but invalid + const slideValidation = await validateQuestionSlideContext( + sessionId, + body.slidePageIndex, + body.slideSetId + ); + const hasSlideInput = body.slidePageIndex != null || body.slideSetId != null; + if (!slideValidation.valid && hasSlideInput) { + return NextResponse.json({ error: slideValidation.error }, { status: 400 }); + } + const slidePageIndex = slideValidation.slidePageIndex; + const slideSetId = slideValidation.slideSetId; + // 7. Create the question and record activity on the session atomically const [question] = await prisma.$transaction([ prisma.question.create({ @@ -218,6 +238,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) { content: body.content.trim(), visibility: body.visibility ?? "PUBLIC", isAnonymous: body.isAnonymous ?? false, + slidePageIndex, + slideSetId, }, include: { author: { @@ -244,6 +266,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) { isAnonymous: question.isAnonymous, upvoteCount: question.upvoteCount, createdAt: question.createdAt, + slidePageIndex: question.slidePageIndex, + slideSetId: question.slideSetId, author: question.isAnonymous ? null : question.author, }, { status: 201 } diff --git a/src/app/room/RoomContext.ts b/src/app/room/RoomContext.ts index a06c9a7..266557f 100644 --- a/src/app/room/RoomContext.ts +++ b/src/app/room/RoomContext.ts @@ -1,22 +1,34 @@ import { createContext, useContext } from "react"; +import type { MutableRefObject } from "react"; import type { Socket } from "socket.io-client"; import type { ClientToServerEvents, ServerToClientEvents } from "@/socket/types"; import type { Role } from "@/utils/types"; +export interface SlideContextSnapshot { + slidePageIndex: number | null; + slideSetId: string | null; +} + export interface RoomContextValue { socket: Socket | null; sessionId: string; userId: string; role: Role; sessionTitle: string; + slideContextRef: MutableRefObject; } +const defaultSlideContextRef: MutableRefObject = { + current: { slidePageIndex: null, slideSetId: null }, +}; + export const RoomContext = createContext({ socket: null, sessionId: "", userId: "", role: "STUDENT", sessionTitle: "", + slideContextRef: defaultSlideContextRef, }); export function useRoom() { diff --git a/src/app/room/classChat/index.tsx b/src/app/room/classChat/index.tsx index 1552f93..da3341f 100644 --- a/src/app/room/classChat/index.tsx +++ b/src/app/room/classChat/index.tsx @@ -23,6 +23,8 @@ interface APIQuestion { upvoteCount: number; answerCount: number; createdAt: string; + slidePageIndex?: number | null; + slideSetId?: string | null; author: { id: string; utorid: string; name: string; role: Role } | null; } @@ -92,6 +94,8 @@ function apiQuestionToPost(q: APIQuestion, answers: APIAnswer[]): Question { isAnonymous: q.isAnonymous, replies: answers.map((a) => apiAnswerToPost(a)), visibility: q.visibility, + slidePageIndex: q.slidePageIndex ?? null, + slideSetId: q.slideSetId ?? null, }; } @@ -105,7 +109,7 @@ interface ClassChatProps { } export default function ClassChat({ chatHistoryRef }: ClassChatProps) { - const { socket, sessionId, userId, role } = useRoom(); + const { socket, sessionId, userId, role, slideContextRef } = useRoom(); const [commentView, setCommentView] = useState<"all" | "unresolved" | "resolved">("all"); const [questions, setQuestions] = useState([]); @@ -185,6 +189,8 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { authorId?: string | null; authorName?: string | null; authorUtorid?: string | null; + slidePageIndex?: number | null; + slideSetId?: string | null; }) => { const user = payload.isAnonymous || !payload.authorName @@ -208,6 +214,8 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { isAnonymous: payload.isAnonymous, replies: [], visibility: payload.visibility as "PUBLIC" | "INSTRUCTOR_ONLY", + slidePageIndex: payload.slidePageIndex ?? null, + slideSetId: payload.slideSetId ?? null, }; setQuestions((prev) => [...prev, newQuestion]); @@ -442,7 +450,22 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { const handleSubmitQuestion = (content: string, isAnonymous: boolean) => { if (!socket) return; setQuestionError(null); - socket.emit("question:create", { sessionId, content, isAnonymous }); + + const { slidePageIndex, slideSetId } = slideContextRef.current; + const payload: { + sessionId: string; + content: string; + isAnonymous: boolean; + slidePageIndex?: number; + slideSetId?: string; + } = { sessionId, content, isAnonymous }; + + if (slidePageIndex !== null && slideSetId !== null) { + payload.slidePageIndex = slidePageIndex; + payload.slideSetId = slideSetId; + } + + socket.emit("question:create", payload); }; const handleUpvote = (questionId: string) => { diff --git a/src/app/room/classChat/post/QuestionPost.tsx b/src/app/room/classChat/post/QuestionPost.tsx index c9633e6..a80da3c 100644 --- a/src/app/room/classChat/post/QuestionPost.tsx +++ b/src/app/room/classChat/post/QuestionPost.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; -import { MessageCircle, CheckCircle2, Undo2, Trash2, ChevronDown, ChevronUp } from "lucide-react"; +import { MessageCircle, CheckCircle2, Undo2, Trash2, ChevronDown, ChevronUp, Presentation } from "lucide-react"; import { Question, Post } from "@/utils/types"; import { UpvoteButton, renderUsername } from "./PostUtils"; @@ -177,6 +177,13 @@ export default function QuestionPost({ {renderUsername(post.user, post.isAnonymous)} {post.timestamp} + {post.slidePageIndex != null && ( + + + Slide {post.slidePageIndex + 1} + + )} + {hasAnyReplies && ( ([]); + const slideContextRef = useRef({ + slidePageIndex: null, + slideSetId: null, + }); + + const handleSlideContextChange = useCallback((ctx: SlideContextSnapshot) => { + slideContextRef.current = ctx; + }, []); const [socket, setSocket] = useState | null>( null @@ -295,7 +303,9 @@ function RoomInner() { const isProfessor = role === "PROFESSOR"; return ( - +
{isSlidesVisible ? ( @@ -309,6 +319,7 @@ function RoomInner() { setShowEndModal(true) : undefined} + onSlideContextChange={handleSlideContextChange} /> diff --git a/src/app/room/slideViewer.tsx b/src/app/room/slideViewer.tsx index fc930b9..0a3927e 100644 --- a/src/app/room/slideViewer.tsx +++ b/src/app/room/slideViewer.tsx @@ -25,6 +25,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useRoom } from "./RoomContext"; +import type { SlideContextSnapshot } from "./RoomContext"; // --------------------------------------------------------------------------- // Props @@ -33,6 +34,7 @@ import { useRoom } from "./RoomContext"; interface SlideViewerProps { isProfessor: boolean; onEndLecture?: () => void; + onSlideContextChange?: (ctx: SlideContextSnapshot) => void; } // --------------------------------------------------------------------------- @@ -42,11 +44,20 @@ interface SlideViewerProps { interface SlideUIProps { activeDocumentId: string | null; isProfessor: boolean; + slideSetId: string | null; + onSlideContextChange?: (ctx: SlideContextSnapshot) => void; onReplaceSlides?: (file: File) => void; onEndLecture?: () => void; } -function SlideUI({ activeDocumentId, isProfessor, onReplaceSlides, onEndLecture }: SlideUIProps) { +function SlideUI({ + activeDocumentId, + isProfessor, + slideSetId, + onSlideContextChange, + onReplaceSlides, + onEndLecture, +}: SlideUIProps) { const { socket, sessionId } = useRoom(); const router = useRouter(); @@ -60,6 +71,16 @@ function SlideUI({ activeDocumentId, isProfessor, onReplaceSlides, onEndLecture const activeDocument = docManager?.getActiveDocument(); const pageCount = activeDocument?.pageCount || 0; + // Report the viewer's local page to the room container for question context + useEffect(() => { + if (!onSlideContextChange) return; + if (!slideSetId) { + onSlideContextChange({ slidePageIndex: null, slideSetId: null }); + return; + } + onSlideContextChange({ slidePageIndex: pageIndex, slideSetId }); + }, [pageIndex, slideSetId, onSlideContextChange]); + // ------------------------------------------------------------------------- // Socket — slide sync + live updates // ------------------------------------------------------------------------- @@ -465,11 +486,16 @@ function UploadZone({ onUpload, isUploading, uploadError, onEndLecture }: Upload // Public component // --------------------------------------------------------------------------- -export default function SlideViewer({ isProfessor, onEndLecture }: SlideViewerProps) { +export default function SlideViewer({ + isProfessor, + onEndLecture, + onSlideContextChange, +}: SlideViewerProps) { const { engine, isLoading: engineLoading } = usePdfiumEngine(); const { sessionId, socket } = useRoom(); const [slideUrl, setSlideUrl] = useState(null); + const [activeSlideSetId, setActiveSlideSetId] = useState(null); const [isLoadingSlides, setIsLoadingSlides] = useState(true); const [isUploading, setIsUploading] = useState(false); const [uploadError, setUploadError] = useState(null); @@ -481,6 +507,7 @@ export default function SlideViewer({ isProfessor, onEndLecture }: SlideViewerPr const loadSlides = useCallback( async (slideSetId?: string) => { if (slideSetId) { + setActiveSlideSetId(slideSetId); setSlideUrl(`/api/sessions/${sessionId}/slides/${slideSetId}/file`); setIsLoadingSlides(false); return; @@ -491,7 +518,10 @@ export default function SlideViewer({ isProfessor, onEndLecture }: SlideViewerPr const data = await res.json(); if (data.slideSets?.length > 0) { const latest = data.slideSets[0]; + setActiveSlideSetId(latest.id); setSlideUrl(`/api/sessions/${sessionId}/slides/${latest.id}/file`); + } else { + setActiveSlideSetId(null); } } finally { setIsLoadingSlides(false); @@ -514,6 +544,7 @@ export default function SlideViewer({ isProfessor, onEndLecture }: SlideViewerPr if (!socket) return; const onSlidesAvailable = ({ slideSetId }: { slideSetId: string }) => { + setActiveSlideSetId(slideSetId); setSlideUrl(`/api/sessions/${sessionId}/slides/${slideSetId}/file`); }; @@ -523,6 +554,13 @@ export default function SlideViewer({ isProfessor, onEndLecture }: SlideViewerPr }; }, [socket, sessionId]); + // Clear slide context when no deck is loaded + useEffect(() => { + if (!slideUrl) { + onSlideContextChange?.({ slidePageIndex: null, slideSetId: null }); + } + }, [slideUrl, onSlideContextChange]); + // ------------------------------------------------------------------------- // Upload handler // ------------------------------------------------------------------------- @@ -548,6 +586,7 @@ export default function SlideViewer({ isProfessor, onEndLecture }: SlideViewerPr } const newUrl = `/api/sessions/${sessionId}/slides/${data.slideSetId}/file`; + setActiveSlideSetId(data.slideSetId); setSlideUrl(newUrl); if (socket?.connected) { @@ -636,6 +675,8 @@ export default function SlideViewer({ isProfessor, onEndLecture }: SlideViewerPr diff --git a/src/lib/questionValidation.ts b/src/lib/questionValidation.ts index 46367d1..1eef58b 100644 --- a/src/lib/questionValidation.ts +++ b/src/lib/questionValidation.ts @@ -36,6 +36,13 @@ export interface SessionValidationResult { }; } +export interface SlideContextValidationResult { + valid: boolean; + error?: string; + slidePageIndex: number | null; + slideSetId: string | null; +} + // --------------------------------------------------------------------------- // Validation Functions // --------------------------------------------------------------------------- @@ -152,3 +159,82 @@ export async function validateSessionForQuestions( return { valid: true, session }; } + +/** + * Validates optional slide context attached to a question. + * Both fields must be provided together or omitted entirely. + * When provided, slidePageIndex must be a non-negative integer within the + * slide set's page count, and slideSetId must belong to the session. + */ +export async function validateQuestionSlideContext( + sessionId: string, + slidePageIndex: unknown, + slideSetId: unknown +): Promise { + const hasPageIndex = slidePageIndex !== undefined && slidePageIndex !== null; + const hasSlideSetId = slideSetId !== undefined && slideSetId !== null; + + if (!hasPageIndex && !hasSlideSetId) { + return { valid: true, slidePageIndex: null, slideSetId: null }; + } + + if (!hasPageIndex || !hasSlideSetId) { + return { + valid: false, + error: "Slide context requires both slidePageIndex and slideSetId.", + slidePageIndex: null, + slideSetId: null, + }; + } + + if ( + typeof slidePageIndex !== "number" || + !Number.isInteger(slidePageIndex) || + slidePageIndex < 0 + ) { + return { + valid: false, + error: "slidePageIndex must be a non-negative integer.", + slidePageIndex: null, + slideSetId: null, + }; + } + + if (typeof slideSetId !== "string" || slideSetId.trim() === "") { + return { + valid: false, + error: "slideSetId must be a non-empty string.", + slidePageIndex: null, + slideSetId: null, + }; + } + + const slideSet = await prisma.slideSet.findFirst({ + where: { id: slideSetId, sessionId }, + select: { id: true, pageCount: true }, + }); + + if (!slideSet) { + return { + valid: false, + error: "slideSetId does not belong to this session.", + slidePageIndex: null, + slideSetId: null, + }; + } + + if (slidePageIndex >= slideSet.pageCount) { + return { + valid: false, + error: `slidePageIndex must be less than ${slideSet.pageCount}.`, + slidePageIndex: null, + slideSetId: null, + }; + } + + return { + valid: true, + slidePageIndex, + slideSetId, + }; +} diff --git a/src/server.ts b/src/server.ts index 6ab757c..da22662 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,9 @@ -import "dotenv/config"; // must be first — loads .env before any other module runs +import dotenv from "dotenv"; + +// Load .env, then let .env.local override (same as prisma.config.ts). +// Required for `pnpm dev` outside Docker: hosts must be localhost, not postgres/redis. +dotenv.config(); +dotenv.config({ path: ".env.local", override: true }); import { createServer, type IncomingMessage } from "node:http"; import next from "next"; diff --git a/src/socket/handlers/questionHandlers.ts b/src/socket/handlers/questionHandlers.ts index 8f9397b..37ee86a 100644 --- a/src/socket/handlers/questionHandlers.ts +++ b/src/socket/handlers/questionHandlers.ts @@ -7,6 +7,7 @@ import { checkUpvoteRateLimit, checkResolveRateLimit, validateSessionForQuestions, + validateQuestionSlideContext, } from "@/lib/questionValidation"; // --------------------------------------------------------------------------- @@ -18,6 +19,8 @@ interface QuestionCreatePayload { sessionId: string; visibility?: "PUBLIC" | "INSTRUCTOR_ONLY"; isAnonymous?: boolean; + slidePageIndex?: number; + slideSetId?: string; } interface QuestionBroadcastPayload { @@ -29,6 +32,8 @@ interface QuestionBroadcastPayload { authorId?: string | null; authorName?: string | null; authorUtorid?: string | null; + slidePageIndex?: number | null; + slideSetId?: string | null; } interface QuestionUpvotePayload { @@ -129,6 +134,21 @@ export function handleQuestionCreate(socket: Socket, io: Server): void { return; } + // 5b. Slide context validation — invalid context is dropped; question still saves + const slideValidation = await validateQuestionSlideContext( + payload.sessionId, + payload.slidePageIndex, + payload.slideSetId + ); + if ( + !slideValidation.valid && + (payload.slidePageIndex != null || payload.slideSetId != null) + ) { + console.log("[QuestionHandler] Invalid slide context dropped:", slideValidation.error); + } + const slidePageIndex = slideValidation.valid ? slideValidation.slidePageIndex : null; + const slideSetId = slideValidation.valid ? slideValidation.slideSetId : null; + // 5a. Enrollment check — any non-PROFESSOR must be enrolled in the session's course const sessionForEnrollment = await prisma.session.findUnique({ where: { id: payload.sessionId }, @@ -157,6 +177,8 @@ export function handleQuestionCreate(socket: Socket, io: Server): void { content: payload.content.trim(), visibility: payload.visibility ?? "PUBLIC", isAnonymous: payload.isAnonymous ?? false, + slidePageIndex, + slideSetId, }, include: { author: { select: { id: true, name: true, utorid: true } }, @@ -170,6 +192,8 @@ export function handleQuestionCreate(socket: Socket, io: Server): void { visibility: question.visibility, isAnonymous: question.isAnonymous, createdAt: question.createdAt, + slidePageIndex: question.slidePageIndex, + slideSetId: question.slideSetId, ...(question.isAnonymous ? {} : { diff --git a/src/socket/types.ts b/src/socket/types.ts index a76b815..3cadcc3 100644 --- a/src/socket/types.ts +++ b/src/socket/types.ts @@ -10,6 +10,8 @@ export interface QuestionCreatePayload { sessionId: string; visibility?: "PUBLIC" | "INSTRUCTOR_ONLY"; isAnonymous?: boolean; + slidePageIndex?: number; + slideSetId?: string; } export interface QuestionCreatedPayload { @@ -21,6 +23,8 @@ export interface QuestionCreatedPayload { authorId?: string | null; authorName?: string | null; authorUtorid?: string | null; + slidePageIndex?: number | null; + slideSetId?: string | null; } export interface AnswerModeChangePayload { diff --git a/src/utils/types.ts b/src/utils/types.ts index a0366e1..cc3d55b 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -26,6 +26,8 @@ export interface Question extends BasePost { replies: Comment[]; isResolved: boolean; visibility?: "PUBLIC" | "INSTRUCTOR_ONLY"; + slidePageIndex?: number | null; + slideSetId?: string | null; } export interface Comment extends BasePost { From f6b1e8da9c27a80af4f1846a915202f70fedd0bc Mon Sep 17 00:00:00 2001 From: Guneev Pannu Date: Sun, 12 Jul 2026 12:23:27 -0400 Subject: [PATCH 02/10] Users can now jump to the slide a question was asked on, and press an undo button to go back if wanted --- src/app/room/RoomContext.ts | 14 ++++++ src/app/room/classChat/ChatHeader.tsx | 17 +++++-- src/app/room/classChat/post/QuestionPost.tsx | 18 +++++-- src/app/room/page.tsx | 49 ++++++++++++++++++- src/app/room/slideViewer.tsx | 50 +++++++++++++++++++- 5 files changed, 137 insertions(+), 11 deletions(-) diff --git a/src/app/room/RoomContext.ts b/src/app/room/RoomContext.ts index 266557f..a110a0b 100644 --- a/src/app/room/RoomContext.ts +++ b/src/app/room/RoomContext.ts @@ -9,6 +9,11 @@ export interface SlideContextSnapshot { slideSetId: string | null; } +export interface SlideNavigationTarget { + slidePageIndex: number; + slideSetId: string; +} + export interface RoomContextValue { socket: Socket | null; sessionId: string; @@ -16,6 +21,12 @@ export interface RoomContextValue { role: Role; sessionTitle: string; slideContextRef: MutableRefObject; + /** Slide the user was on before jumping via a question badge, if any. */ + slideReturnTarget: SlideContextSnapshot | null; + /** Navigate the slide viewer to a question's slide context. */ + navigateToQuestionSlide: (target: SlideNavigationTarget) => void; + /** Return to the slide position saved before the last question-badge jump. */ + goBackToPreviousSlide: () => void; } const defaultSlideContextRef: MutableRefObject = { @@ -29,6 +40,9 @@ export const RoomContext = createContext({ role: "STUDENT", sessionTitle: "", slideContextRef: defaultSlideContextRef, + slideReturnTarget: null, + navigateToQuestionSlide: () => {}, + goBackToPreviousSlide: () => {}, }); export function useRoom() { diff --git a/src/app/room/classChat/ChatHeader.tsx b/src/app/room/classChat/ChatHeader.tsx index 003f5bf..1408316 100644 --- a/src/app/room/classChat/ChatHeader.tsx +++ b/src/app/room/classChat/ChatHeader.tsx @@ -2,10 +2,11 @@ import { Input } from "@/components/ui/input"; import { useContext, useState } from "react"; -import { PanelRightClose, Users, GraduationCap, Search, X, UserPlus } from "lucide-react"; +import { PanelRightClose, Users, GraduationCap, Search, X, UserPlus, Undo2 } from "lucide-react"; import ManageTAsModal from "./ManageTAsModal"; import { useMediaQuery } from "@/hooks/use-media-query"; import { SlideUpdateContext } from "../SlideUpdateContext"; +import { useRoom } from "../RoomContext"; import type { Role } from "@/utils/types"; interface ChatHeaderProps { @@ -48,8 +49,6 @@ function SlideToggle() { ); } -import { useRoom } from "../RoomContext"; - export default function ChatHeader({ role, answerMode, @@ -57,7 +56,8 @@ export default function ChatHeader({ searchQuery, onSearchChange, }: ChatHeaderProps) { - const { sessionTitle } = useRoom(); + const { sessionTitle, slideReturnTarget, goBackToPreviousSlide } = useRoom(); + const { isSlidesVisible } = useContext(SlideUpdateContext); const [isSearchExpanded, setIsSearchExpanded] = useState(false); const [showTAModal, setShowTAModal] = useState(false); @@ -106,6 +106,15 @@ export default function ChatHeader({ <>
+ {slideReturnTarget?.slidePageIndex != null && !isSlidesVisible && ( + + )} {sessionTitle && (

{sessionTitle} diff --git a/src/app/room/classChat/post/QuestionPost.tsx b/src/app/room/classChat/post/QuestionPost.tsx index a80da3c..4a008f6 100644 --- a/src/app/room/classChat/post/QuestionPost.tsx +++ b/src/app/room/classChat/post/QuestionPost.tsx @@ -6,6 +6,7 @@ import { Textarea } from "@/components/ui/textarea"; import { MessageCircle, CheckCircle2, Undo2, Trash2, ChevronDown, ChevronUp, Presentation } from "lucide-react"; import { Question, Post } from "@/utils/types"; import { UpvoteButton, renderUsername } from "./PostUtils"; +import { useRoom } from "../../RoomContext"; // --------------------------------------------------------------------------- // Reply composer @@ -131,6 +132,7 @@ export default function QuestionPost({ const [isReplying, setIsReplying] = useState(false); const [threadState, setThreadState] = useState("default"); const [confirmingDelete, setConfirmingDelete] = useState(false); + const { navigateToQuestionSlide } = useRoom(); /** Parent (socket/API) is the source of truth; optimistic updates flow through `post`. */ const resolved = post.isResolved; @@ -177,11 +179,21 @@ export default function QuestionPost({ {renderUsername(post.user, post.isAnonymous)} {post.timestamp} - {post.slidePageIndex != null && ( - + {post.slidePageIndex != null && post.slideSetId && ( + )} {hasAnyReplies && ( diff --git a/src/app/room/page.tsx b/src/app/room/page.tsx index 28dbb61..d5fdcbb 100644 --- a/src/app/room/page.tsx +++ b/src/app/room/page.tsx @@ -11,7 +11,7 @@ import ClassChat from "./classChat"; import SlideViewer from "./slideViewer"; import type { ClientToServerEvents, ServerToClientEvents } from "@/socket/types"; import type { Question, Role } from "@/utils/types"; -import { RoomContext, type SlideContextSnapshot } from "./RoomContext"; +import { RoomContext, type SlideContextSnapshot, type SlideNavigationTarget } from "./RoomContext"; import { SlideUpdateContext } from "./SlideUpdateContext"; // --------------------------------------------------------------------------- @@ -148,6 +148,40 @@ function RoomInner() { slideContextRef.current = ctx; }, []); + const [slideReturnTarget, setSlideReturnTarget] = useState(null); + const [slideNavTarget, setSlideNavTarget] = useState(null); + + const navigateToQuestionSlide = useCallback( + (target: SlideNavigationTarget) => { + const current = slideContextRef.current; + const isDifferent = + current.slidePageIndex !== target.slidePageIndex || + current.slideSetId !== target.slideSetId; + + if ( + isDifferent && + current.slidePageIndex !== null && + current.slideSetId !== null && + slideReturnTarget === null + ) { + setSlideReturnTarget({ ...current }); + } + + setIsSlidesVisible(true); + setSlideNavTarget({ + slidePageIndex: target.slidePageIndex, + slideSetId: target.slideSetId, + }); + }, + [slideReturnTarget] + ); + + const goBackToPreviousSlide = useCallback(() => { + if (slideReturnTarget?.slidePageIndex == null || !slideReturnTarget?.slideSetId) return; + setSlideNavTarget({ ...slideReturnTarget }); + setSlideReturnTarget(null); + }, [slideReturnTarget]); + const [socket, setSocket] = useState | null>( null ); @@ -304,7 +338,17 @@ function RoomInner() { return (
@@ -320,6 +364,7 @@ function RoomInner() { isProfessor={isProfessor} onEndLecture={isProfessor ? () => setShowEndModal(true) : undefined} onSlideContextChange={handleSlideContextChange} + slideNavTarget={slideNavTarget} /> diff --git a/src/app/room/slideViewer.tsx b/src/app/room/slideViewer.tsx index 0a3927e..ad7d2e9 100644 --- a/src/app/room/slideViewer.tsx +++ b/src/app/room/slideViewer.tsx @@ -10,6 +10,7 @@ import { Upload, LogOut, Unlink, + Undo2, } from "lucide-react"; import { createPluginRegistration } from "@embedpdf/core"; import { EmbedPDF } from "@embedpdf/core/react"; @@ -35,6 +36,7 @@ interface SlideViewerProps { isProfessor: boolean; onEndLecture?: () => void; onSlideContextChange?: (ctx: SlideContextSnapshot) => void; + slideNavTarget?: SlideContextSnapshot | null; } // --------------------------------------------------------------------------- @@ -45,6 +47,7 @@ interface SlideUIProps { activeDocumentId: string | null; isProfessor: boolean; slideSetId: string | null; + slideNavTarget?: SlideContextSnapshot | null; onSlideContextChange?: (ctx: SlideContextSnapshot) => void; onReplaceSlides?: (file: File) => void; onEndLecture?: () => void; @@ -54,11 +57,12 @@ function SlideUI({ activeDocumentId, isProfessor, slideSetId, + slideNavTarget, onSlideContextChange, onReplaceSlides, onEndLecture, }: SlideUIProps) { - const { socket, sessionId } = useRoom(); + const { socket, sessionId, slideReturnTarget, goBackToPreviousSlide } = useRoom(); const router = useRouter(); const [pageIndex, setPageIndex] = useState(0); @@ -147,17 +151,35 @@ function SlideUI({ // Navigation helpers // ------------------------------------------------------------------------- - const navigateTo = (newIndex: number) => { + const navigateToLocal = (newIndex: number) => { if (pageCount === 0) return; const clamped = Math.max(0, Math.min(newIndex, pageCount - 1)); setPageIndex(clamped); setInputValue(String(clamped + 1)); + }; + + const navigateTo = (newIndex: number) => { + if (pageCount === 0) return; + const clamped = Math.max(0, Math.min(newIndex, pageCount - 1)); + navigateToLocal(clamped); if (isProfessor && socket) { socket.emit("slide:change", { sessionId, pageIndex: clamped }); } }; + // Jump to a question's slide without moving the professor's live deck + useEffect(() => { + if (slideNavTarget?.slidePageIndex == null || !slideNavTarget.slideSetId) return; + if (slideNavTarget.slideSetId !== slideSetId) return; + if (pageCount === 0) return; + + if (!isProfessor && isSynced) { + setIsSynced(false); + } + navigateToLocal(slideNavTarget.slidePageIndex); + }, [slideNavTarget, slideSetId, pageCount, isProfessor, isSynced]); + const handleInputCommit = (value: string) => { const num = parseInt(value, 10); if (isNaN(num) || num < 1) { @@ -254,6 +276,19 @@ function SlideUI({ {/* Controls bar — always rendered */}
+ {slideReturnTarget?.slidePageIndex != null && ( + <> + +
+ + )} + {/* Professor: live indicator + nav */} {isProfessor && ( <> @@ -490,6 +525,7 @@ export default function SlideViewer({ isProfessor, onEndLecture, onSlideContextChange, + slideNavTarget, }: SlideViewerProps) { const { engine, isLoading: engineLoading } = usePdfiumEngine(); const { sessionId, socket } = useRoom(); @@ -561,6 +597,15 @@ export default function SlideViewer({ } }, [slideUrl, onSlideContextChange]); + // Navigate to a question's slide — switch decks when needed + useEffect(() => { + if (slideNavTarget?.slidePageIndex == null || !slideNavTarget.slideSetId) return; + + if (slideNavTarget.slideSetId !== activeSlideSetId) { + loadSlides(slideNavTarget.slideSetId); + } + }, [slideNavTarget, activeSlideSetId, loadSlides]); + // ------------------------------------------------------------------------- // Upload handler // ------------------------------------------------------------------------- @@ -676,6 +721,7 @@ export default function SlideViewer({ activeDocumentId={activeDocumentId} isProfessor={isProfessor} slideSetId={activeSlideSetId} + slideNavTarget={slideNavTarget} onSlideContextChange={onSlideContextChange} onReplaceSlides={isProfessor ? handleUpload : undefined} onEndLecture={isProfessor ? onEndLecture : undefined} From d9d8faa581edffa1a7ac2b6a342e294c8cfb48db Mon Sep 17 00:00:00 2001 From: Guneev Pannu Date: Sun, 12 Jul 2026 12:28:22 -0400 Subject: [PATCH 03/10] Users can decide whether they want to give slide context to their questions --- src/app/room/RoomContext.ts | 3 +++ src/app/room/classChat/ChatInput.tsx | 35 +++++++++++++++++++++++++--- src/app/room/classChat/index.tsx | 15 ++++++++++-- src/app/room/page.tsx | 6 +++++ 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/app/room/RoomContext.ts b/src/app/room/RoomContext.ts index a110a0b..7e944fb 100644 --- a/src/app/room/RoomContext.ts +++ b/src/app/room/RoomContext.ts @@ -20,6 +20,8 @@ export interface RoomContextValue { userId: string; role: Role; sessionTitle: string; + /** Current viewer slide position (updates as the user navigates). */ + slideContext: SlideContextSnapshot; slideContextRef: MutableRefObject; /** Slide the user was on before jumping via a question badge, if any. */ slideReturnTarget: SlideContextSnapshot | null; @@ -39,6 +41,7 @@ export const RoomContext = createContext({ userId: "", role: "STUDENT", sessionTitle: "", + slideContext: { slidePageIndex: null, slideSetId: null }, slideContextRef: defaultSlideContextRef, slideReturnTarget: null, navigateToQuestionSlide: () => {}, diff --git a/src/app/room/classChat/ChatInput.tsx b/src/app/room/classChat/ChatInput.tsx index 68eb018..da222ae 100644 --- a/src/app/room/classChat/ChatInput.tsx +++ b/src/app/room/classChat/ChatInput.tsx @@ -2,17 +2,20 @@ import { useState } from "react"; import { Textarea } from "@/components/ui/textarea"; -import { Ghost, User, Send } from "lucide-react"; +import { Ghost, User, Send, Presentation } from "lucide-react"; +import { useRoom } from "../RoomContext"; const MIN_LENGTH = 5; interface ChatInputProps { - onSubmit: (content: string, isAnonymous: boolean) => void; + onSubmit: (content: string, isAnonymous: boolean, includeSlideContext: boolean) => void; disabled?: boolean; serverError?: string | null; onClearError?: () => void; isAnonymous: boolean; onAnonymousChange: (val: boolean) => void; + includeSlideContext: boolean; + onIncludeSlideContextChange: (val: boolean) => void; } export default function ChatInput({ @@ -22,11 +25,16 @@ export default function ChatInput({ onClearError, isAnonymous, onAnonymousChange, + includeSlideContext, + onIncludeSlideContextChange, }: ChatInputProps) { + const { slideContext } = useRoom(); const [content, setContent] = useState(""); const [localError, setLocalError] = useState(null); const error = serverError ?? localError; + const slideContextAvailable = + slideContext.slidePageIndex !== null && slideContext.slideSetId !== null; const handleChange = (e: React.ChangeEvent) => { setContent(e.target.value); @@ -43,7 +51,7 @@ export default function ChatInput({ return; } setLocalError(null); - onSubmit(trimmed, isAnonymous); + onSubmit(trimmed, isAnonymous, includeSlideContext && slideContextAvailable); setContent(""); }; @@ -94,6 +102,27 @@ export default function ChatInput({ )} + {slideContextAvailable && ( + + )} {error &&

{error}

}
); diff --git a/src/app/room/page.tsx b/src/app/room/page.tsx index d5fdcbb..34d6369 100644 --- a/src/app/room/page.tsx +++ b/src/app/room/page.tsx @@ -143,9 +143,14 @@ function RoomInner() { slidePageIndex: null, slideSetId: null, }); + const [slideContext, setSlideContext] = useState({ + slidePageIndex: null, + slideSetId: null, + }); const handleSlideContextChange = useCallback((ctx: SlideContextSnapshot) => { slideContextRef.current = ctx; + setSlideContext(ctx); }, []); const [slideReturnTarget, setSlideReturnTarget] = useState(null); @@ -344,6 +349,7 @@ function RoomInner() { userId, role, sessionTitle, + slideContext, slideContextRef, slideReturnTarget, navigateToQuestionSlide, From 9c71ad598a622a7ef57b8681e8f4f3d2221637ff Mon Sep 17 00:00:00 2001 From: Guneev Pannu Date: Sun, 12 Jul 2026 12:48:59 -0400 Subject: [PATCH 04/10] Fixed linting issues --- src/app/room/classChat/ChatInput.tsx | 4 +-- src/app/room/classChat/index.tsx | 6 +---- src/app/room/classChat/post/QuestionPost.tsx | 10 ++++++- src/app/room/slideViewer.tsx | 28 ++++++++++++-------- 4 files changed, 28 insertions(+), 20 deletions(-) diff --git a/src/app/room/classChat/ChatInput.tsx b/src/app/room/classChat/ChatInput.tsx index da222ae..de7dbbc 100644 --- a/src/app/room/classChat/ChatInput.tsx +++ b/src/app/room/classChat/ChatInput.tsx @@ -118,9 +118,7 @@ export default function ChatInput({ } > - {includeSlideContext - ? `Slide ${slideContext.slidePageIndex! + 1}` - : "No slide"} + {includeSlideContext ? `Slide ${slideContext.slidePageIndex! + 1}` : "No slide"} )} {error &&

{error}

} diff --git a/src/app/room/classChat/index.tsx b/src/app/room/classChat/index.tsx index cd5b76d..98620e0 100644 --- a/src/app/room/classChat/index.tsx +++ b/src/app/room/classChat/index.tsx @@ -465,11 +465,7 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { slideSetId?: string; } = { sessionId, content, isAnonymous }; - if ( - attachSlideContext && - slidePageIndex !== null && - slideSetId !== null - ) { + if (attachSlideContext && slidePageIndex !== null && slideSetId !== null) { payload.slidePageIndex = slidePageIndex; payload.slideSetId = slideSetId; } diff --git a/src/app/room/classChat/post/QuestionPost.tsx b/src/app/room/classChat/post/QuestionPost.tsx index 4a008f6..d20a98c 100644 --- a/src/app/room/classChat/post/QuestionPost.tsx +++ b/src/app/room/classChat/post/QuestionPost.tsx @@ -3,7 +3,15 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; -import { MessageCircle, CheckCircle2, Undo2, Trash2, ChevronDown, ChevronUp, Presentation } from "lucide-react"; +import { + MessageCircle, + CheckCircle2, + Undo2, + Trash2, + ChevronDown, + ChevronUp, + Presentation, +} from "lucide-react"; import { Question, Post } from "@/utils/types"; import { UpvoteButton, renderUsername } from "./PostUtils"; import { useRoom } from "../../RoomContext"; diff --git a/src/app/room/slideViewer.tsx b/src/app/room/slideViewer.tsx index ad7d2e9..f19d097 100644 --- a/src/app/room/slideViewer.tsx +++ b/src/app/room/slideViewer.tsx @@ -151,12 +151,18 @@ function SlideUI({ // Navigation helpers // ------------------------------------------------------------------------- - const navigateToLocal = (newIndex: number) => { - if (pageCount === 0) return; - const clamped = Math.max(0, Math.min(newIndex, pageCount - 1)); - setPageIndex(clamped); - setInputValue(String(clamped + 1)); - }; + const navigateToLocal = useCallback( + (newIndex: number, options?: { detachFromProfessor?: boolean }) => { + if (pageCount === 0) return; + const clamped = Math.max(0, Math.min(newIndex, pageCount - 1)); + if (options?.detachFromProfessor && !isProfessor && isSynced) { + setIsSynced(false); + } + setPageIndex(clamped); + setInputValue(String(clamped + 1)); + }, + [pageCount, isProfessor, isSynced] + ); const navigateTo = (newIndex: number) => { if (pageCount === 0) return; @@ -174,11 +180,11 @@ function SlideUI({ if (slideNavTarget.slideSetId !== slideSetId) return; if (pageCount === 0) return; - if (!isProfessor && isSynced) { - setIsSynced(false); - } - navigateToLocal(slideNavTarget.slidePageIndex); - }, [slideNavTarget, slideSetId, pageCount, isProfessor, isSynced]); + const targetPage = slideNavTarget.slidePageIndex; + queueMicrotask(() => { + navigateToLocal(targetPage, { detachFromProfessor: true }); + }); + }, [slideNavTarget, slideSetId, pageCount, navigateToLocal]); const handleInputCommit = (value: string) => { const num = parseInt(value, 10); From 5d0560dfaeba2eb5dc7c51dd1a11f4ae8fe291a0 Mon Sep 17 00:00:00 2001 From: Guneev Pannu Date: Thu, 16 Jul 2026 13:42:44 -0400 Subject: [PATCH 05/10] Stop rerendering the entire chat upon changing slides --- src/app/room/RoomContext.ts | 28 ++++++++++++++++++++++++---- src/app/room/classChat/ChatInput.tsx | 4 ++-- src/app/room/page.tsx | 14 +++++++------- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/app/room/RoomContext.ts b/src/app/room/RoomContext.ts index 7e944fb..3abe0af 100644 --- a/src/app/room/RoomContext.ts +++ b/src/app/room/RoomContext.ts @@ -1,4 +1,4 @@ -import { createContext, useContext } from "react"; +import { createContext, useContext, useSyncExternalStore } from "react"; import type { MutableRefObject } from "react"; import type { Socket } from "socket.io-client"; import type { ClientToServerEvents, ServerToClientEvents } from "@/socket/types"; @@ -20,8 +20,7 @@ export interface RoomContextValue { userId: string; role: Role; sessionTitle: string; - /** Current viewer slide position (updates as the user navigates). */ - slideContext: SlideContextSnapshot; + /** Latest viewer slide position — read `.current` in handlers (does not re-render). */ slideContextRef: MutableRefObject; /** Slide the user was on before jumping via a question badge, if any. */ slideReturnTarget: SlideContextSnapshot | null; @@ -41,7 +40,6 @@ export const RoomContext = createContext({ userId: "", role: "STUDENT", sessionTitle: "", - slideContext: { slidePageIndex: null, slideSetId: null }, slideContextRef: defaultSlideContextRef, slideReturnTarget: null, navigateToQuestionSlide: () => {}, @@ -51,3 +49,25 @@ export const RoomContext = createContext({ export function useRoom() { return useContext(RoomContext); } + +/** Slide label for ChatInput only — avoids re-rendering the chat list on page flips. */ +let slideUiSnapshot: SlideContextSnapshot = { slidePageIndex: null, slideSetId: null }; +const slideUiListeners = new Set<() => void>(); + +export function publishSlideContext(ctx: SlideContextSnapshot) { + slideUiSnapshot = ctx; + slideUiListeners.forEach((l) => l()); +} + +export function useSlideContext() { + return useSyncExternalStore( + (onStoreChange) => { + slideUiListeners.add(onStoreChange); + return () => { + slideUiListeners.delete(onStoreChange); + }; + }, + () => slideUiSnapshot, + () => slideUiSnapshot, + ); +} diff --git a/src/app/room/classChat/ChatInput.tsx b/src/app/room/classChat/ChatInput.tsx index de7dbbc..023c2f2 100644 --- a/src/app/room/classChat/ChatInput.tsx +++ b/src/app/room/classChat/ChatInput.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { Textarea } from "@/components/ui/textarea"; import { Ghost, User, Send, Presentation } from "lucide-react"; -import { useRoom } from "../RoomContext"; +import { useSlideContext } from "../RoomContext"; const MIN_LENGTH = 5; @@ -28,7 +28,7 @@ export default function ChatInput({ includeSlideContext, onIncludeSlideContextChange, }: ChatInputProps) { - const { slideContext } = useRoom(); + const slideContext = useSlideContext(); const [content, setContent] = useState(""); const [localError, setLocalError] = useState(null); diff --git a/src/app/room/page.tsx b/src/app/room/page.tsx index 34d6369..39a9775 100644 --- a/src/app/room/page.tsx +++ b/src/app/room/page.tsx @@ -11,7 +11,12 @@ import ClassChat from "./classChat"; import SlideViewer from "./slideViewer"; import type { ClientToServerEvents, ServerToClientEvents } from "@/socket/types"; import type { Question, Role } from "@/utils/types"; -import { RoomContext, type SlideContextSnapshot, type SlideNavigationTarget } from "./RoomContext"; +import { + RoomContext, + publishSlideContext, + type SlideContextSnapshot, + type SlideNavigationTarget, +} from "./RoomContext"; import { SlideUpdateContext } from "./SlideUpdateContext"; // --------------------------------------------------------------------------- @@ -143,14 +148,10 @@ function RoomInner() { slidePageIndex: null, slideSetId: null, }); - const [slideContext, setSlideContext] = useState({ - slidePageIndex: null, - slideSetId: null, - }); const handleSlideContextChange = useCallback((ctx: SlideContextSnapshot) => { slideContextRef.current = ctx; - setSlideContext(ctx); + publishSlideContext(ctx); }, []); const [slideReturnTarget, setSlideReturnTarget] = useState(null); @@ -349,7 +350,6 @@ function RoomInner() { userId, role, sessionTitle, - slideContext, slideContextRef, slideReturnTarget, navigateToQuestionSlide, From deff2e890811548198d71a44efa2e216f563e797 Mon Sep 17 00:00:00 2001 From: Guneev Pannu Date: Thu, 16 Jul 2026 14:02:57 -0400 Subject: [PATCH 06/10] When a professor clicks a slide on the chat, the student now continues to follow the prof --- src/app/room/slideViewer.tsx | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/app/room/slideViewer.tsx b/src/app/room/slideViewer.tsx index f19d097..a460597 100644 --- a/src/app/room/slideViewer.tsx +++ b/src/app/room/slideViewer.tsx @@ -164,17 +164,20 @@ function SlideUI({ [pageCount, isProfessor, isSynced] ); - const navigateTo = (newIndex: number) => { - if (pageCount === 0) return; - const clamped = Math.max(0, Math.min(newIndex, pageCount - 1)); - navigateToLocal(clamped); + const navigateTo = useCallback( + (newIndex: number) => { + if (pageCount === 0) return; + const clamped = Math.max(0, Math.min(newIndex, pageCount - 1)); + navigateToLocal(clamped); - if (isProfessor && socket) { - socket.emit("slide:change", { sessionId, pageIndex: clamped }); - } - }; + if (isProfessor && socket) { + socket.emit("slide:change", { sessionId, pageIndex: clamped }); + } + }, + [pageCount, navigateToLocal, isProfessor, socket, sessionId] + ); - // Jump to a question's slide without moving the professor's live deck + // Question-badge jump: professor broadcasts; students detach from follow mode useEffect(() => { if (slideNavTarget?.slidePageIndex == null || !slideNavTarget.slideSetId) return; if (slideNavTarget.slideSetId !== slideSetId) return; @@ -182,9 +185,13 @@ function SlideUI({ const targetPage = slideNavTarget.slidePageIndex; queueMicrotask(() => { - navigateToLocal(targetPage, { detachFromProfessor: true }); + if (isProfessor) { + navigateTo(targetPage); + } else { + navigateToLocal(targetPage, { detachFromProfessor: true }); + } }); - }, [slideNavTarget, slideSetId, pageCount, navigateToLocal]); + }, [slideNavTarget, slideSetId, pageCount, navigateToLocal, navigateTo, isProfessor]); const handleInputCommit = (value: string) => { const num = parseInt(value, 10); From 313b9c7d1fd509bf0c07f27c59b357f67c5337e5 Mon Sep 17 00:00:00 2001 From: Guneev Pannu Date: Thu, 16 Jul 2026 14:30:11 -0400 Subject: [PATCH 07/10] Back to Slide button now disappears upon moving past the slide both directions Causes two rerenders of chat, one when clicking on a slide from chat and second when moving past the slide --- src/app/room/page.tsx | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/app/room/page.tsx b/src/app/room/page.tsx index 39a9775..1c6d28b 100644 --- a/src/app/room/page.tsx +++ b/src/app/room/page.tsx @@ -19,6 +19,22 @@ import { } from "./RoomContext"; import { SlideUpdateContext } from "./SlideUpdateContext"; +function passedSlideReturnTarget( + returnTarget: SlideContextSnapshot, + jumpTarget: SlideContextSnapshot, + current: SlideContextSnapshot, +): boolean { + const ret = returnTarget.slidePageIndex; + const jump = jumpTarget.slidePageIndex; + const cur = current.slidePageIndex; + if (ret == null || jump == null || cur == null) return false; + if (returnTarget.slideSetId !== current.slideSetId) return false; + + if (jump > ret) return cur <= ret; + if (jump < ret) return cur >= ret; + return true; +} + // --------------------------------------------------------------------------- // Chat history export // --------------------------------------------------------------------------- @@ -148,14 +164,28 @@ function RoomInner() { slidePageIndex: null, slideSetId: null, }); + const slideJumpTargetRef = useRef(null); + + const [slideReturnTarget, setSlideReturnTarget] = useState(null); + const [slideNavTarget, setSlideNavTarget] = useState(null); const handleSlideContextChange = useCallback((ctx: SlideContextSnapshot) => { slideContextRef.current = ctx; publishSlideContext(ctx); - }, []); - const [slideReturnTarget, setSlideReturnTarget] = useState(null); - const [slideNavTarget, setSlideNavTarget] = useState(null); + setSlideReturnTarget((prev) => { + if (!prev?.slidePageIndex || !prev.slideSetId) return prev; + const jump = slideJumpTargetRef.current; + if (!jump) { + return ctx.slidePageIndex === prev.slidePageIndex && ctx.slideSetId === prev.slideSetId + ? null + : prev; + } + if (!passedSlideReturnTarget(prev, jump, ctx)) return prev; + slideJumpTargetRef.current = null; + return null; + }); + }, []); const navigateToQuestionSlide = useCallback( (target: SlideNavigationTarget) => { @@ -171,6 +201,10 @@ function RoomInner() { slideReturnTarget === null ) { setSlideReturnTarget({ ...current }); + slideJumpTargetRef.current = { + slidePageIndex: target.slidePageIndex, + slideSetId: target.slideSetId, + }; } setIsSlidesVisible(true); @@ -185,6 +219,7 @@ function RoomInner() { const goBackToPreviousSlide = useCallback(() => { if (slideReturnTarget?.slidePageIndex == null || !slideReturnTarget?.slideSetId) return; setSlideNavTarget({ ...slideReturnTarget }); + slideJumpTargetRef.current = null; setSlideReturnTarget(null); }, [slideReturnTarget]); From efc5816e544e7605b4f28631a5610e18b2551b88 Mon Sep 17 00:00:00 2001 From: Guneev Pannu Date: Thu, 16 Jul 2026 14:43:01 -0400 Subject: [PATCH 08/10] Fixed mobile UI issuess, Back to Slide Button can now be seen in both student and prof view --- src/app/room/slideViewer.tsx | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/app/room/slideViewer.tsx b/src/app/room/slideViewer.tsx index a460597..bb2c71a 100644 --- a/src/app/room/slideViewer.tsx +++ b/src/app/room/slideViewer.tsx @@ -288,19 +288,20 @@ function SlideUI({ )} {/* Controls bar — always rendered */} -
- {slideReturnTarget?.slidePageIndex != null && ( - <> - -
- - )} +
+
+ {slideReturnTarget?.slidePageIndex != null && ( + <> + +
+ + )} {/* Professor: live indicator + nav */} {isProfessor && ( @@ -433,6 +434,7 @@ function SlideUI({ )} +
); From 0530f2460842599ba49967d5fee19c38c22339fd Mon Sep 17 00:00:00 2001 From: Guneev Pannu Date: Thu, 16 Jul 2026 14:55:21 -0400 Subject: [PATCH 09/10] Stopped Slide # text in chat input from wrapping, added gap between error message and Post button --- src/app/room/classChat/ChatInput.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/room/classChat/ChatInput.tsx b/src/app/room/classChat/ChatInput.tsx index 023c2f2..014057e 100644 --- a/src/app/room/classChat/ChatInput.tsx +++ b/src/app/room/classChat/ChatInput.tsx @@ -79,7 +79,7 @@ export default function ChatInput({ error ? "border-red-400 bg-red-50" : "" }`} /> -
+
+ + )} + +
+ +
setInputValue(e.target.value)} + onBlur={() => handleInputCommit(inputValue)} + onKeyDown={handleKeyDown} + className="w-10 h-9 px-1 text-center bg-white border border-stone-300 rounded focus-visible:ring-1 focus-visible:ring-stone-400 focus-visible:outline-none" /> - - - )} - -
- -
- setInputValue(e.target.value)} - onBlur={() => handleInputCommit(inputValue)} - onKeyDown={handleKeyDown} - className="w-10 h-9 px-1 text-center bg-white border border-stone-300 rounded focus-visible:ring-1 focus-visible:ring-stone-400 focus-visible:outline-none" - /> - {pageCount > 0 && / {pageCount}} -
- - - )} + {pageCount > 0 && / {pageCount}} +
+ + + )} - {/* Student: following mode */} - {!isProfessor && isSynced && ( - <> -
- - Following Professor -
- -
- - - )} + {/* Student: following mode */} + {!isProfessor && isSynced && ( + <> +
+ + Following Professor +
+ +
+ + + )} - {/* Student: free navigation mode */} - {!isProfessor && !isSynced && ( - <> - -
- setInputValue(e.target.value)} - onBlur={() => handleInputCommit(inputValue)} - onKeyDown={handleKeyDown} - className="w-10 h-9 px-1 text-center bg-white border border-stone-300 rounded focus-visible:ring-1 focus-visible:ring-stone-400 focus-visible:outline-none" - /> - {pageCount > 0 && / {pageCount}} -
- - -
- - - )} + {/* Student: free navigation mode */} + {!isProfessor && !isSynced && ( + <> + +
+ setInputValue(e.target.value)} + onBlur={() => handleInputCommit(inputValue)} + onKeyDown={handleKeyDown} + className="w-10 h-9 px-1 text-center bg-white border border-stone-300 rounded focus-visible:ring-1 focus-visible:ring-stone-400 focus-visible:outline-none" + /> + {pageCount > 0 && / {pageCount}} +
+ + +
+ + + )}