Skip to content
Closed
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
123 changes: 123 additions & 0 deletions src/__tests__/projection-mode.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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> = {}): 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("removes the author from questions and all replies", () => {
const stripped = stripAuthors([makeQuestion()]);
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(
<ChatHeader
role={role}
answerMode="instructors_only"
onToggleAnswerMode={vi.fn()}
projectionMode={projectionMode}
onToggleProjectionMode={onToggle}
searchQuery=""
onSearchChange={vi.fn()}
/>
);
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 in the tooltip", () => {
renderHeader("PROFESSOR", true);
expect(screen.getByLabelText(TOGGLE_LABEL).getAttribute("title")).toContain("Names hidden");
cleanup();
renderHeader("PROFESSOR", false);
expect(screen.getByLabelText(TOGGLE_LABEL).getAttribute("title")).toContain("Names visible");
});

it("calls the toggle callback on click", () => {
const onToggle = renderHeader("PROFESSOR");
fireEvent.click(screen.getByLabelText(TOGGLE_LABEL));
expect(onToggle).toHaveBeenCalledTimes(1);
});
});
46 changes: 39 additions & 7 deletions src/app/room/classChat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -12,6 +21,8 @@ interface ChatHeaderProps {
role: Role;
answerMode: "all" | "instructors_only";
onToggleAnswerMode: () => void;
projectionMode: boolean;
onToggleProjectionMode: () => void;
searchQuery: string;
onSearchChange: (value: string) => void;
}
Expand All @@ -23,7 +34,7 @@ function SlideToggle() {
if (!isMDsize) {
return (
<button
className="w-9 h-9 flex items-center justify-center text-stone-400 hover:text-stone-900 hover:bg-stone-200/60 rounded-md transition-colors"
className="w-9 h-9 shrink-0 flex items-center justify-center text-stone-400 hover:text-stone-900 hover:bg-stone-200/60 rounded-md transition-colors"
onClick={() => rerender()}
>
{isSlidesVisible ? (
Expand All @@ -36,7 +47,7 @@ function SlideToggle() {
}
return (
<button
className="w-9 h-9 flex items-center justify-center text-stone-400 hover:text-stone-900 hover:bg-stone-200/60 rounded-md transition-colors"
className="w-9 h-9 shrink-0 flex items-center justify-center text-stone-400 hover:text-stone-900 hover:bg-stone-200/60 rounded-md transition-colors"
onClick={() => rerender()}
>
{isSlidesVisible ? (
Expand All @@ -54,6 +65,8 @@ export default function ChatHeader({
role,
answerMode,
onToggleAnswerMode,
projectionMode,
onToggleProjectionMode,
searchQuery,
onSearchChange,
}: ChatHeaderProps) {
Expand Down Expand Up @@ -104,12 +117,11 @@ export default function ChatHeader({
</div>
) : (
<>
<div className="flex items-center gap-2 shrink-0 animate-in fade-in duration-200">
{/* min-w-0 lets the title truncate so the right-side controls never overflow */}
<div className="flex items-center gap-2 min-w-0 animate-in fade-in duration-200">
<SlideToggle />
{sessionTitle && (
<h1 className="text-xl font-bold truncate max-w-[140px] sm:max-w-xs">
{sessionTitle}
</h1>
<h1 className="text-xl font-bold truncate min-w-0 sm:max-w-xs">{sessionTitle}</h1>
)}
</div>

Expand All @@ -129,6 +141,26 @@ export default function ChatHeader({
)}
</button>

{/* Projection mode (hide names) toggle — instructors only */}
{(role === "PROFESSOR" || role === "TA") && (
<button
onClick={onToggleProjectionMode}
aria-label="Toggle name visibility"
title={
projectionMode
? "Names hidden (safe to project) — click to show names"
: "Names visible — click to hide names for projecting"
}
className={`w-9 h-9 flex items-center justify-center rounded-md transition-colors shrink-0 cursor-pointer ${
projectionMode
? "bg-stone-800 text-stone-50 hover:bg-stone-700"
: "bg-stone-200 text-stone-600 hover:bg-stone-300"
}`}
>
{projectionMode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
)}

{/* Answer mode toggle — professors only */}
{role === "PROFESSOR" && (
<button
Expand Down
40 changes: 36 additions & 4 deletions src/app/room/classChat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -114,6 +118,15 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) {
const [isLoading, setIsLoading] = useState(true);
const [questionError, setQuestionError] = useState<string | null>(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<HTMLDivElement>(null);
// Separate history that keeps deleted messages (marked as [deleted]) for the
Expand Down Expand Up @@ -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 });
Expand All @@ -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;
}
Expand All @@ -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();
Expand Down Expand Up @@ -558,6 +588,8 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) {
role={role}
answerMode={answerMode}
onToggleAnswerMode={handleToggleAnswerMode}
projectionMode={projectionMode}
onToggleProjectionMode={handleToggleProjectionMode}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
Expand Down
16 changes: 15 additions & 1 deletion src/app/room/classChat/post/PostUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 })),
}));
}
Loading