From 8b4ecffbddb7573ab537313082f05dfeb9bd8b3f Mon Sep 17 00:00:00 2001 From: notjackl3 Date: Wed, 8 Jul 2026 07:04:19 -0400 Subject: [PATCH 1/5] feat: add stripAuthors helper to anonymize post authors --- src/app/room/classChat/post/PostUtils.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/app/room/classChat/post/PostUtils.tsx b/src/app/room/classChat/post/PostUtils.tsx index b9d006e..fdc8013 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 ?? []; }; + +/** + * Strips author identity from questions and their replies so posts render + * exactly like anonymous ones (no name, utorid, role icon, or avatar + * initials). Used by the instructor's projection mode — the underlying state + * keeps the real authors, so flipping the toggle back restores them. + */ +export function stripAuthors(questions: Question[]): Question[] { + return questions.map((q) => ({ + ...q, + user: null, + replies: q.replies.map((r) => ({ ...r, user: null })), + })); +} From b09f09f06ea8fc883bd1a7e0ae19f18f7d18c9b7 Mon Sep 17 00:00:00 2001 From: notjackl3 Date: Wed, 8 Jul 2026 07:04:19 -0400 Subject: [PATCH 2/5] feat: wire projection mode into class chat with persisted toggle --- src/app/room/classChat/index.tsx | 40 ++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/src/app/room/classChat/index.tsx b/src/app/room/classChat/index.tsx index 1552f93..3c47cf7 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) // --------------------------------------------------------------------------- @@ -114,6 +118,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 @@ -483,6 +496,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 }); @@ -500,12 +519,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; } @@ -514,8 +539,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(); @@ -558,6 +588,8 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) { role={role} answerMode={answerMode} onToggleAnswerMode={handleToggleAnswerMode} + projectionMode={projectionMode} + onToggleProjectionMode={handleToggleProjectionMode} searchQuery={searchQuery} onSearchChange={setSearchQuery} /> From 8ae59de1748aed7323b79f3158094bb9cfe504df Mon Sep 17 00:00:00 2001 From: notjackl3 Date: Wed, 8 Jul 2026 07:04:19 -0400 Subject: [PATCH 3/5] feat: add name-visibility toggle button to chat header --- src/app/room/classChat/ChatHeader.tsx | 46 +++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/src/app/room/classChat/ChatHeader.tsx b/src/app/room/classChat/ChatHeader.tsx index 003f5bf..1491b0a 100644 --- a/src/app/room/classChat/ChatHeader.tsx +++ b/src/app/room/classChat/ChatHeader.tsx @@ -2,7 +2,16 @@ import { Input } from "@/components/ui/input"; import { useContext, useState } from "react"; -import { PanelRightClose, Users, GraduationCap, Search, X, UserPlus } from "lucide-react"; +import { + Eye, + EyeOff, + PanelRightClose, + Users, + GraduationCap, + Search, + X, + UserPlus, +} from "lucide-react"; import ManageTAsModal from "./ManageTAsModal"; import { useMediaQuery } from "@/hooks/use-media-query"; import { SlideUpdateContext } from "../SlideUpdateContext"; @@ -12,6 +21,8 @@ interface ChatHeaderProps { role: Role; answerMode: "all" | "instructors_only"; onToggleAnswerMode: () => void; + projectionMode: boolean; + onToggleProjectionMode: () => void; searchQuery: string; onSearchChange: (value: string) => void; } @@ -23,7 +34,7 @@ function SlideToggle() { if (!isMDsize) { return ( + {/* Projection mode (hide names) toggle — instructors only */} + {(role === "PROFESSOR" || role === "TA") && ( + + )} + {/* Answer mode toggle — professors only */} {role === "PROFESSOR" && ( - - {/* Projection mode (hide names) toggle — instructors only */} - {(role === "PROFESSOR" || role === "TA") && ( + + + + {/* 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/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 fdc8013..f823cfa 100644 --- a/src/app/room/classChat/post/PostUtils.tsx +++ b/src/app/room/classChat/post/PostUtils.tsx @@ -97,15 +97,15 @@ export const bestToTop = (replies: Post[] | undefined) => { }; /** - * Strips author identity from questions and their replies so posts render - * exactly like anonymous ones (no name, utorid, role icon, or avatar - * initials). Used by the instructor's projection mode — the underlying state - * keeps the real authors, so flipping the toggle back restores them. + * 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: null, - replies: q.replies.map((r) => ({ ...r, user: null })), + 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 c9633e6..91db4a0 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 } from "lucide-react"; import { Question, Post } from "@/utils/types"; import { UpvoteButton, renderUsername } from "./PostUtils"; +import { HintTooltip } from "@/components/ui/tooltip"; // --------------------------------------------------------------------------- // Reply composer @@ -241,40 +242,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 };