diff --git a/src/__tests__/projection-mode.test.tsx b/src/__tests__/projection-mode.test.tsx new file mode 100644 index 0000000..75e91d0 --- /dev/null +++ b/src/__tests__/projection-mode.test.tsx @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; + +afterEach(cleanup); + +import ChatHeader from "@/app/room/classChat/ChatHeader"; +import { stripAuthors } from "@/app/room/classChat/post/PostUtils"; +import type { Question, Role } from "@/utils/types"; + +// --------------------------------------------------------------------------- +// stripAuthors — projection mode anonymization +// --------------------------------------------------------------------------- + +function makeQuestion(overrides: Partial = {}): Question { + return { + id: "q1", + type: "question", + user: { id: "u1", utorid: "student1", username: "Student One", pfp: "", role: "STUDENT" }, + timestamp: "10:00 AM", + content: "What is a pointer?", + upvotes: 3, + isResolved: false, + isAnonymous: false, + replies: [ + { + id: "a1", + type: "comment", + user: { id: "u2", utorid: "ta1", username: "TA One", pfp: "", role: "TA" }, + timestamp: "10:01 AM", + content: "A memory address.", + upvotes: 1, + isAnonymous: false, + }, + ], + visibility: "PUBLIC", + ...overrides, + }; +} + +describe("stripAuthors", () => { + it("keeps authors of publicly-attributed questions and replies", () => { + const stripped = stripAuthors([makeQuestion()]); + expect(stripped[0].user?.username).toBe("Student One"); + expect(stripped[0].replies[0].user?.username).toBe("TA One"); + }); + + it("hides authors of anonymous questions and replies", () => { + const anon = makeQuestion({ + isAnonymous: true, + replies: [ + { + id: "a1", + type: "comment", + user: { id: "u2", utorid: "s2", username: "Student Two", pfp: "", role: "STUDENT" }, + timestamp: "10:01 AM", + content: "me too", + upvotes: 0, + isAnonymous: true, + }, + ], + }); + const stripped = stripAuthors([anon]); + expect(stripped[0].user).toBeNull(); + expect(stripped[0].replies[0].user).toBeNull(); + }); + + it("strips revealed anonymous authors too", () => { + // Simulates a question whose author arrived via question:author:revealed + const revealed = makeQuestion({ + isAnonymous: true, + user: { id: "u9", utorid: "revealed1", username: "Revealed Name", pfp: "", role: "STUDENT" }, + }); + const stripped = stripAuthors([revealed]); + expect(stripped[0].user).toBeNull(); + }); + + it("preserves content, upvotes, and resolution state", () => { + const stripped = stripAuthors([makeQuestion({ isResolved: true })]); + expect(stripped[0].content).toBe("What is a pointer?"); + expect(stripped[0].upvotes).toBe(3); + expect(stripped[0].isResolved).toBe(true); + expect(stripped[0].replies[0].content).toBe("A memory address."); + }); + + it("does not mutate the original questions", () => { + const original = makeQuestion(); + stripAuthors([original]); + expect(original.user?.username).toBe("Student One"); + expect(original.replies[0].user?.username).toBe("TA One"); + }); +}); + +// --------------------------------------------------------------------------- +// ChatHeader — toggle visibility and behaviour +// --------------------------------------------------------------------------- + +function renderHeader(role: Role, projectionMode = true, onToggle = vi.fn()) { + render( + + ); + return onToggle; +} + +const TOGGLE_LABEL = "Toggle name visibility"; + +describe("ChatHeader projection mode toggle", () => { + it("is visible to professors", () => { + renderHeader("PROFESSOR"); + expect(screen.getByLabelText(TOGGLE_LABEL)).toBeDefined(); + }); + + it("is visible to TAs", () => { + renderHeader("TA"); + expect(screen.getByLabelText(TOGGLE_LABEL)).toBeDefined(); + }); + + it("is not rendered for students", () => { + renderHeader("STUDENT"); + expect(screen.queryByLabelText(TOGGLE_LABEL)).toBeNull(); + }); + + it("reflects the projection state with the eye icon", () => { + renderHeader("PROFESSOR", true); + expect( + screen.getByLabelText(TOGGLE_LABEL).querySelector("svg")?.getAttribute("class") + ).toContain("lucide-eye-off"); + cleanup(); + renderHeader("PROFESSOR", false); + const cls = + screen.getByLabelText(TOGGLE_LABEL).querySelector("svg")?.getAttribute("class") ?? ""; + expect(cls).toContain("lucide-eye"); + expect(cls).not.toContain("lucide-eye-off"); + }); + + it("calls the toggle callback on click", () => { + const onToggle = renderHeader("PROFESSOR"); + fireEvent.click(screen.getByLabelText(TOGGLE_LABEL)); + expect(onToggle).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/room/classChat/ChatHeader.tsx b/src/app/room/classChat/ChatHeader.tsx index 1408316..f5c16f3 100644 --- a/src/app/room/classChat/ChatHeader.tsx +++ b/src/app/room/classChat/ChatHeader.tsx @@ -2,8 +2,19 @@ import { Input } from "@/components/ui/input"; import { useContext, useState } from "react"; -import { PanelRightClose, Users, GraduationCap, Search, X, UserPlus, Undo2 } from "lucide-react"; +import { + Eye, + EyeOff, + PanelRightClose, + Users, + GraduationCap, + Search, + X, + UserPlus, + Undo2, +} from "lucide-react"; import ManageTAsModal from "./ManageTAsModal"; +import { HintTooltip } from "@/components/ui/tooltip"; import { useMediaQuery } from "@/hooks/use-media-query"; import { SlideUpdateContext } from "../SlideUpdateContext"; import { useRoom } from "../RoomContext"; @@ -13,6 +24,8 @@ interface ChatHeaderProps { role: Role; answerMode: "all" | "instructors_only"; onToggleAnswerMode: () => void; + projectionMode: boolean; + onToggleProjectionMode: () => void; searchQuery: string; onSearchChange: (value: string) => void; } @@ -24,7 +37,7 @@ function SlideToggle() { if (!isMDsize) { return ( - - {/* Answer mode toggle — professors only */} - {role === "PROFESSOR" && ( + + + + {/* Projection mode (hide names) toggle — instructors only */} + {(role === "PROFESSOR" || role === "TA") && ( + + + + )} + + {/* Answer mode toggle — professors only */} + {role === "PROFESSOR" && ( + + + )} {role === "PROFESSOR" && ( - + + + )} diff --git a/src/app/room/classChat/index.tsx b/src/app/room/classChat/index.tsx index 98620e0..bdaa6ce 100644 --- a/src/app/room/classChat/index.tsx +++ b/src/app/room/classChat/index.tsx @@ -8,8 +8,12 @@ import PostItem from "./post"; import ChatHeader from "./ChatHeader"; import ChatInput from "./ChatInput"; import FilterTabs from "./FilterTabs"; +import { stripAuthors } from "./post/PostUtils"; import type { Question, Comment, Role } from "@/utils/types"; +/** localStorage key for the instructor's projection-mode (hide names) choice. */ +const PROJECTION_MODE_KEY = "room:projectionMode"; + // --------------------------------------------------------------------------- // API response types (what the REST endpoints return) // --------------------------------------------------------------------------- @@ -119,6 +123,15 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { const [isLoading, setIsLoading] = useState(true); const [questionError, setQuestionError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); + // Projection mode (instructors only): hide all author identities so the + // screen can be safely projected. Defaults to ON (hidden) for safety; the + // persisted choice is loaded after mount to avoid hydration mismatches. + const [projectionMode, setProjectionMode] = useState(true); + + useEffect(() => { + const stored = localStorage.getItem(PROJECTION_MODE_KEY); + if (stored !== null) setProjectionMode(stored === "true"); + }, []); const bottomRef = useRef(null); // Separate history that keeps deleted messages (marked as [deleted]) for the @@ -511,6 +524,12 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { setAnswerMode(newMode); // Optimistic update }; + const handleToggleProjectionMode = () => { + const next = !projectionMode; + localStorage.setItem(PROJECTION_MODE_KEY, String(next)); + setProjectionMode(next); + }; + const handleDeleteQuestion = (questionId: string) => { if (!socket) return; socket.emit("question:delete", { questionId, sessionId }); @@ -528,12 +547,18 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { * Anonymous posts are excluded because the client cannot verify the * author's role, and the author could be a professor or another TA. * - STUDENT: never + * + * Looks the post up in the unstripped state by id, so permissions are + * unaffected by projection mode (which nulls `user` on rendered posts). */ - function canDelete(post: { user: { id?: string; role: Role } | null }): boolean { + function canDelete(post: { id: string }): boolean { if (role === "PROFESSOR") return true; if (role === "TA") { - if (!post.user) return false; // anonymous — author role unknown, hide button - return post.user.role === "STUDENT" || post.user.id === userId; + const original = + questions.find((q) => q.id === post.id) ?? + questions.flatMap((q) => q.replies).find((r) => r.id === post.id); + if (!original?.user) return false; // anonymous — author role unknown, hide button + return original.user.role === "STUDENT" || original.user.id === userId; } return false; } @@ -542,8 +567,13 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { // Search filter // ------------------------------------------------------------------------- + // In projection mode the instructor's rendered list is anonymized up front, + // so author names never reach the DOM and can't be matched by search either. + // State keeps the real authors — toggling off restores them instantly. + const hideIdentities = isInstructor && projectionMode; + const filteredQuestions = (() => { - let list = questions; + let list = hideIdentities ? stripAuthors(questions) : questions; // Search filter const q = searchQuery.trim().toLowerCase(); @@ -586,6 +616,8 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { role={role} answerMode={answerMode} onToggleAnswerMode={handleToggleAnswerMode} + projectionMode={projectionMode} + onToggleProjectionMode={handleToggleProjectionMode} searchQuery={searchQuery} onSearchChange={setSearchQuery} /> diff --git a/src/app/room/classChat/post/CommentPost.tsx b/src/app/room/classChat/post/CommentPost.tsx index 5598191..8369a9d 100644 --- a/src/app/room/classChat/post/CommentPost.tsx +++ b/src/app/room/classChat/post/CommentPost.tsx @@ -5,6 +5,7 @@ import { Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Comment } from "@/utils/types"; import { renderAvatar, UpvoteButton, renderUsername } from "./PostUtils"; +import { HintTooltip } from "@/components/ui/tooltip"; interface CommentPostProps { post: Comment; @@ -74,15 +75,16 @@ export default function CommentPost({ post, onUpvote, onDelete }: CommentPostPro ) : ( - + + + ))} diff --git a/src/app/room/classChat/post/PostUtils.tsx b/src/app/room/classChat/post/PostUtils.tsx index b9d006e..f823cfa 100644 --- a/src/app/room/classChat/post/PostUtils.tsx +++ b/src/app/room/classChat/post/PostUtils.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { ArrowBigUp, GraduationCap } from "lucide-react"; -import { Post, User, getInitials, isLikelyAvatarImageUrl } from "@/utils/types"; +import { Post, Question, User, getInitials, isLikelyAvatarImageUrl } from "@/utils/types"; export function renderAvatar(post: Post) { if (post?.user) { @@ -95,3 +95,17 @@ export function renderUsername(user: User | null, isAnonymous?: boolean) { export const bestToTop = (replies: Post[] | undefined) => { return replies ?? []; }; + +/** + * Hides the author of posts that were submitted anonymously (including + * anonymous authors the instructor revealed) so they can't be projected. + * Publicly-attributed posts keep their author. Used by projection mode — the + * underlying state is untouched, so flipping the toggle back restores names. + */ +export function stripAuthors(questions: Question[]): Question[] { + return questions.map((q) => ({ + ...q, + user: q.isAnonymous ? null : q.user, + replies: q.replies.map((r) => (r.isAnonymous ? { ...r, user: null } : r)), + })); +} diff --git a/src/app/room/classChat/post/QuestionPost.tsx b/src/app/room/classChat/post/QuestionPost.tsx index d20a98c..5f9922c 100644 --- a/src/app/room/classChat/post/QuestionPost.tsx +++ b/src/app/room/classChat/post/QuestionPost.tsx @@ -14,6 +14,7 @@ import { } from "lucide-react"; import { Question, Post } from "@/utils/types"; import { UpvoteButton, renderUsername } from "./PostUtils"; +import { HintTooltip } from "@/components/ui/tooltip"; import { useRoom } from "../../RoomContext"; // --------------------------------------------------------------------------- @@ -268,40 +269,43 @@ export default function QuestionPost({ )} {onResolve && !resolved && ( - + + + )} {onUnresolve && resolved && ( - + + + )} {onDelete && ( - + + + )} )} diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..457977f --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,42 @@ +"use client"; + +import * as React from "react"; +import { Tooltip as TooltipPrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; + +const TooltipProvider = TooltipPrimitive.Provider; +const Tooltip = TooltipPrimitive.Root; +const TooltipTrigger = TooltipPrimitive.Trigger; + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +/** Wraps a trigger element with a short-delay tooltip. Replaces native `title`. */ +function HintTooltip({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + + {children} + {label} + + + ); +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, HintTooltip };