Skip to content
Merged
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
48 changes: 45 additions & 3 deletions src/app/room/classChat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,18 @@

import { Input } from "@/components/ui/input";
import { useContext, useState } from "react";
import { PanelRightClose, Users, GraduationCap, Search, X, UserPlus, Undo2 } from "lucide-react";
import {
PanelRightClose,
Users,
GraduationCap,
Search,
X,
UserPlus,
Undo2,
VolumeOff,
Volume2,
BellRing,
} from "lucide-react";
import ManageTAsModal from "./ManageTAsModal";
import { useMediaQuery } from "@/hooks/use-media-query";
import { SlideUpdateContext } from "../SlideUpdateContext";
Expand All @@ -15,6 +26,8 @@ const TITLE_MAX_CHARS = 6;
interface ChatHeaderProps {
role: Role;
answerMode: "all" | "instructors_only";
notificationMode: "off" | "sound" | "browser";
onToggleNotificationMode: () => void;
onToggleAnswerMode: () => void;
searchQuery: string;
onSearchChange: (value: string) => void;
Expand Down Expand Up @@ -55,6 +68,8 @@ function SlideToggle() {
export default function ChatHeader({
role,
answerMode,
notificationMode,
onToggleNotificationMode,
onToggleAnswerMode,
searchQuery,
onSearchChange,
Expand All @@ -63,6 +78,24 @@ export default function ChatHeader({
const { isSlidesVisible } = useContext(SlideUpdateContext);
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const [showTAModal, setShowTAModal] = useState(false);
const notificationButton =
notificationMode === "off"
? {
Icon: VolumeOff,
title: "Notifications off",
className: "bg-red-100 text-red-700 hover:bg-red-200",
}
: notificationMode === "sound"
? {
Icon: Volume2,
title: "Beep notifications on",
className: "bg-stone-200 text-stone-600 hover:bg-stone-300",
}
: {
Icon: BellRing,
title: "Browser and beep notifications on",
className: "bg-amber-100 text-amber-700 hover:bg-amber-200",
};

return (
<>
Expand Down Expand Up @@ -126,8 +159,17 @@ export default function ChatHeader({
</div>

<div className="flex items-center gap-2 shrink-0 animate-in fade-in duration-200">
<button
onClick={onToggleNotificationMode}
title={`${notificationButton.title} - click to cycle`}
className={`w-9 h-9 flex items-center justify-center rounded-md transition-colors ${notificationButton.className}`}
aria-label={notificationButton.title}
>
<notificationButton.Icon className="w-4 h-4" />
</button>
<button
onClick={() => setIsSearchExpanded(true)}
title="Search questions and answers"
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"
Expand All @@ -147,8 +189,8 @@ export default function ChatHeader({
onClick={onToggleAnswerMode}
title={
answerMode === "all"
? "Anyone can answer click to restrict to TAs/Professors"
: "TAs/Professors only click to allow everyone"
? "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"
Expand Down
96 changes: 94 additions & 2 deletions src/app/room/classChat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,12 @@
}

export default function ClassChat({ chatHistoryRef }: ClassChatProps) {
const { socket, sessionId, userId, role, slideContextRef } = useRoom();
const { socket, sessionId, userId, role, slideContextRef, sessionTitle } = useRoom();

const [commentView, setCommentView] = useState<"all" | "unresolved" | "resolved">("all");
const [questions, setQuestions] = useState<Question[]>([]);
const [answerMode, setAnswerMode] = useState<"all" | "instructors_only">("instructors_only");
const [notificationMode, setNotificationMode] = useState<"off" | "sound" | "browser">("off");
const [globalIsAnonymous, setGlobalIsAnonymous] = useState(false);
const [includeSlideContext, setIncludeSlideContext] = useState(true);
const [isLoading, setIsLoading] = useState(true);
Expand All @@ -143,6 +144,59 @@
// Separate history that keeps deleted messages (marked as [deleted]) for the
// session export. Never removes items — deletions are marked in-place.
const historyRef = useRef<Question[]>([]);
const audioContextRef = useRef<AudioContext | null>(null);

const playQuestionBeep = (mode = notificationMode) => {
if (mode === "off" || typeof window === "undefined") return;

const audioContext = audioContextRef.current ?? new window.AudioContext();
audioContextRef.current = audioContext;
if (audioContext.state === "suspended") {
void audioContext.resume().catch(() => {});
}

const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
const now = audioContext.currentTime;

oscillator.type = "sine";
oscillator.frequency.setValueAtTime(880, now);
gainNode.gain.setValueAtTime(0.0001, now);
gainNode.gain.exponentialRampToValueAtTime(0.14, now + 0.01);
gainNode.gain.exponentialRampToValueAtTime(0.0001, now + 0.18);

oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.start(now);
oscillator.stop(now + 0.18);
};

const requestBrowserNotificationPermission = async () => {
if (typeof window === "undefined" || !("Notification" in window)) return false;
if (Notification.permission === "granted") return true;
if (Notification.permission === "denied") return false;

try {
return (await Notification.requestPermission()) === "granted";
} catch {
return false;
}
};

const showBrowserNotification = (
title: string,
body: string,
tag: string,
mode = notificationMode
) => {
if (mode !== "browser" || typeof window === "undefined" || !("Notification" in window)) return;
if (Notification.permission !== "granted") return;
if (document.visibilityState === "visible" && document.hasFocus()) return;

const notification = new Notification(title, { body, tag });

setTimeout(() => notification.close(), 5000);
};

// -------------------------------------------------------------------------
// Initial data fetch
Expand Down Expand Up @@ -243,6 +297,18 @@

setQuestions((prev) => [...prev, newQuestion]);
historyRef.current = [...historyRef.current, { ...newQuestion, replies: [] }];
if (!payload.isMine) {
const author =
newQuestion.isAnonymous || !newQuestion.user?.username
? "Anonymous"
: newQuestion.user.username;
playQuestionBeep();
showBrowserNotification(
sessionTitle || "New question",
`${author}: ${newQuestion.content}`,
`question-${newQuestion.id}`
);
}
if (payload.isMine) setScrollTargetId(payload.id);
};

Expand Down Expand Up @@ -298,6 +364,12 @@
isMine: payload.isMine,
};
const newReply = apiAnswerToPost(apiAnswer);
const question = historyRef.current.find((q) => q.id === payload.questionId);
const isFollowUpOnMyThread =
!payload.isMine &&
(question?.isMine === true || question?.replies.some((reply) => isOwnPost(reply)) === true);
const replyAuthor =
newReply.isAnonymous || !newReply.user?.username ? "Anonymous" : newReply.user.username;

setQuestions((prev) =>
prev.map((q) =>
Expand All @@ -307,6 +379,14 @@
historyRef.current = historyRef.current.map((q) =>
q.id === payload.questionId ? { ...q, replies: [...q.replies, { ...newReply }] } : q
);
if (isFollowUpOnMyThread) {
playQuestionBeep();
showBrowserNotification(
sessionTitle || "New reply",
`${replyAuthor} replied: ${newReply.content}`,
`answer-${newReply.id}`
);
}
};

const onAnswerUpdated = (payload: { id: string; questionId: string; upvoteCount: number }) => {
Expand Down Expand Up @@ -465,7 +545,7 @@
socket.off("question:author:revealed", onQuestionAuthorRevealed);
socket.off("answer:author:revealed", onAnswerAuthorRevealed);
};
}, [socket, sessionId, chatHistoryRef, role, userId]);
}, [socket, sessionId, chatHistoryRef, role, userId, notificationMode]);

Check warning on line 548 in src/app/room/classChat/index.tsx

View workflow job for this annotation

GitHub Actions / build (20)

React Hook useEffect has missing dependencies: 'isOwnPost', 'playQuestionBeep', 'sessionTitle', and 'showBrowserNotification'. Either include them or remove the dependency array

// Keep chatHistoryRef in sync whenever historyRef is updated via data load
// or new questions/answers arriving (deletions update it inline above).
Expand Down Expand Up @@ -556,6 +636,16 @@
setAnswerMode(newMode); // Optimistic update
};

const handleToggleNotificationMode = () => {
const nextMode =
notificationMode === "off" ? "sound" : notificationMode === "sound" ? "browser" : "off";
setNotificationMode(nextMode);
if (nextMode !== "off") playQuestionBeep(nextMode);
if (nextMode === "browser") {
void requestBrowserNotificationPermission();
}
};

const handleDeleteQuestion = (questionId: string) => {
if (!socket) return;
socket.emit("question:delete", { questionId, sessionId });
Expand Down Expand Up @@ -643,6 +733,8 @@
<ChatHeader
role={role}
answerMode={answerMode}
notificationMode={notificationMode}
onToggleNotificationMode={handleToggleNotificationMode}
onToggleAnswerMode={handleToggleAnswerMode}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
Expand Down
4 changes: 2 additions & 2 deletions src/app/room/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ function RoomInner() {

const isMdSize = useMediaQuery("(min-width: 1024px)");
const [isSlidesVisible, setIsSlidesVisible] = useState(true);
const [resizableWidth, setResizableWidth] = useState(30);
const [resizableWidth, setResizableWidth] = useState(32);

const [userId, setUserId] = useState("");
const [role, setRole] = useState<Role>("STUDENT");
Expand Down Expand Up @@ -409,7 +409,7 @@ function RoomInner() {
/>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={resizableWidth} minSize={30}>
<ResizablePanel defaultSize={resizableWidth} minSize={32}>
<ClassChat chatHistoryRef={chatHistoryRef} />
</ResizablePanel>
</ResizablePanelGroup>
Expand Down
Loading