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
148 changes: 148 additions & 0 deletions src/__tests__/projection-mode.test.tsx
Original file line number Diff line number Diff line change
@@ -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> = {}): 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(
<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 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);
});
});
139 changes: 87 additions & 52 deletions src/app/room/classChat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -13,6 +24,8 @@ interface ChatHeaderProps {
role: Role;
answerMode: "all" | "instructors_only";
onToggleAnswerMode: () => void;
projectionMode: boolean;
onToggleProjectionMode: () => void;
searchQuery: string;
onSearchChange: (value: string) => void;
}
Expand All @@ -24,7 +37,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 @@ -37,7 +50,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 @@ -53,6 +66,8 @@ export default function ChatHeader({
role,
answerMode,
onToggleAnswerMode,
projectionMode,
onToggleProjectionMode,
searchQuery,
onSearchChange,
}: ChatHeaderProps) {
Expand Down Expand Up @@ -104,7 +119,8 @@ 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 />
{slideReturnTarget?.slidePageIndex != null && !isSlidesVisible && (
<button
Expand All @@ -116,65 +132,84 @@ export default function ChatHeader({
</button>
)}
{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>

<div className="flex items-center gap-2 shrink-0 animate-in fade-in duration-200">
<button
onClick={() => setIsSearchExpanded(true)}
className={`w-9 h-9 flex items-center justify-center rounded-md transition-colors ${
searchQuery
? "bg-stone-800 text-stone-50 hover:bg-stone-700"
: "bg-stone-200 text-stone-600 hover:bg-stone-300"
}`}
aria-label="Search"
>
<Search className="w-4 h-4" />
{searchQuery && (
<span className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-green-500" />
)}
</button>

{/* Answer mode toggle — professors only */}
{role === "PROFESSOR" && (
<HintTooltip label="Search">
<button
onClick={onToggleAnswerMode}
title={
answerMode === "all"
? "Anyone can answer — click to restrict to TAs/Professors"
: "TAs/Professors only — click to allow everyone"
}
className={`flex items-center gap-1.5 h-9 px-3 rounded-md text-sm font-medium transition-colors shrink-0 cursor-pointer ${
answerMode === "all"
? "bg-green-100 text-green-700 hover:bg-green-200"
: "bg-amber-100 text-amber-700 hover:bg-amber-200"
onClick={() => setIsSearchExpanded(true)}
className={`w-9 h-9 flex items-center justify-center rounded-md transition-colors ${
searchQuery
? "bg-stone-800 text-stone-50 hover:bg-stone-700"
: "bg-stone-200 text-stone-600 hover:bg-stone-300"
}`}
aria-label="Search"
>
{answerMode === "all" ? (
<>
<Users className="w-3.5 h-3.5" />
Anyone
</>
) : (
<>
<GraduationCap className="w-3.5 h-3.5" />
TAs only
</>
<Search className="w-4 h-4" />
{searchQuery && (
<span className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-green-500" />
)}
</button>
</HintTooltip>

{/* Projection mode (hide names) toggle — instructors only */}
{(role === "PROFESSOR" || role === "TA") && (
<HintTooltip label={projectionMode ? "Show names" : "Hide names"}>
<button
onClick={onToggleProjectionMode}
aria-label="Toggle name visibility"
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>
</HintTooltip>
)}

{/* Answer mode toggle — professors only */}
{role === "PROFESSOR" && (
<HintTooltip label={answerMode === "all" ? "Restrict to TAs" : "Allow everyone"}>
<button
onClick={onToggleAnswerMode}
className={`flex items-center gap-1.5 h-9 px-3 rounded-md text-sm font-medium transition-colors shrink-0 cursor-pointer ${
answerMode === "all"
? "bg-green-100 text-green-700 hover:bg-green-200"
: "bg-amber-100 text-amber-700 hover:bg-amber-200"
}`}
>
{answerMode === "all" ? (
<>
<Users className="w-3.5 h-3.5" />
Anyone
</>
) : (
<>
<GraduationCap className="w-3.5 h-3.5" />
TAs only
</>
)}
</button>
</HintTooltip>
)}
{role === "PROFESSOR" && (
<button
onClick={() => setShowTAModal(true)}
title="Manage TAs"
className="w-9 h-9 flex items-center justify-center rounded-md text-stone-600 bg-stone-200 hover:bg-stone-300 transition-colors shrink-0"
aria-label="Manage TAs"
>
<UserPlus className="w-4 h-4" />
</button>
<HintTooltip label="Manage TAs">
<button
onClick={() => setShowTAModal(true)}
className="w-9 h-9 flex items-center justify-center rounded-md text-stone-600 bg-stone-200 hover:bg-stone-300 transition-colors shrink-0"
aria-label="Manage TAs"
>
<UserPlus className="w-4 h-4" />
</button>
</HintTooltip>
)}
</div>
</>
Expand Down
Loading
Loading