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
4 changes: 2 additions & 2 deletions src/app/components/CourseViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { BookOpen, Calendar, ArrowRight, Video, LayoutGrid } from "lucide-react";
import { BookOpen, Calendar, ArrowRight, Monitor, LayoutGrid } from "lucide-react";

interface Course {
id: string;
Expand Down Expand Up @@ -122,7 +122,7 @@ export default function CourseViewer() {
<div
className={`w-12 h-12 rounded-md flex items-center justify-center shrink-0 transition-all ${isActive ? "bg-green-50 text-green-500 group-hover:scale-110" : "bg-stone-50 text-stone-600"}`}
>
{isActive ? <Video className="w-6 h-6" /> : <BookOpen className="w-6 h-6" />}
{isActive ? <Monitor className="w-6 h-6" /> : <BookOpen className="w-6 h-6" />}
</div>

{isActive && (
Expand Down
10 changes: 10 additions & 0 deletions src/app/room/classChat/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { PanelRightClose, Users, GraduationCap, Search, X } from "lucide-react";
import { useMediaQuery } from "@/hooks/use-media-query";
import { SlideUpdateContext } from "../SlideUpdateContext";
import type { Role } from "@/utils/types";
import Link from "next/link";
import Image from "next/image";
import askEasyLogo from "@/app/icon.png";

interface ChatHeaderProps {
role: Role;
Expand Down Expand Up @@ -154,6 +157,13 @@ export default function ChatHeader({
)}
</button>
)}
<Link
href="/"
aria-label="Go to main page"
className="inline-flex items-center justify-center rounded-md p-1 text-stone-900 hover:bg-stone-200/60 transition-colors"
>
<Image src={askEasyLogo} alt="AskEasy logo" width={50} height={50} />
</Link>
</div>
</>
)}
Expand Down
59 changes: 47 additions & 12 deletions src/app/room/classChat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,12 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) {
);
};

const onQuestionUnresolved = (payload: { id: string }) => {
setQuestions((prev) =>
prev.map((q) => (q.id === payload.id ? { ...q, isResolved: false } : q))
);
};

const onAnswerCreated = (payload: {
id: string;
questionId: string;
Expand Down Expand Up @@ -386,6 +392,7 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) {
socket.on("question:created", onQuestionCreated);
socket.on("question:updated", onQuestionUpdated);
socket.on("question:resolved", onQuestionResolved);
socket.on("question:unresolved", onQuestionUnresolved);
socket.on("question:deleted", onQuestionDeleted);
socket.on("answer:created", onAnswerCreated);
socket.on("answer:updated", onAnswerUpdated);
Expand All @@ -400,6 +407,7 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) {
socket.off("question:created", onQuestionCreated);
socket.off("question:updated", onQuestionUpdated);
socket.off("question:resolved", onQuestionResolved);
socket.off("question:unresolved", onQuestionUnresolved);
socket.off("question:deleted", onQuestionDeleted);
socket.off("answer:created", onAnswerCreated);
socket.off("answer:updated", onAnswerUpdated);
Expand Down Expand Up @@ -454,6 +462,15 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) {
setQuestions((prev) => prev.map((q) => (q.id === questionId ? { ...q, isResolved: true } : q)));
};

const handleUnresolve = (questionId: string) => {
if (!socket) return;
socket.emit("question:unresolve", { questionId });
// Optimistic update
setQuestions((prev) =>
prev.map((q) => (q.id === questionId ? { ...q, isResolved: false } : q))
);
};

const handleSubmitAnswer = (questionId: string, content: string) => {
if (!socket) return;
socket.emit("answer:create", { questionId, content, isAnonymous: globalIsAnonymous });
Expand Down Expand Up @@ -498,19 +515,36 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) {
// -------------------------------------------------------------------------

const filteredQuestions = (() => {
let list = questions;

// Search filter
const q = searchQuery.trim().toLowerCase();
if (!q) return questions;
const tokens = q.split(/\s+/).filter(Boolean);
return questions.filter((question) => {
const haystack = [
question.content,
question.user?.username ?? "",
...question.replies.map((r) => r.content),
...question.replies.map((r) => r.user?.username ?? ""),
]
.join(" ")
.toLowerCase();
return tokens.every((token) => haystack.includes(token));
if (q) {
const tokens = q.split(/\s+/).filter(Boolean);
list = list.filter((question) => {
const haystack = [
question.content,
question.user?.username ?? "",
...question.replies.map((r) => r.content),
...question.replies.map((r) => r.user?.username ?? ""),
]
.join(" ")
.toLowerCase();
return tokens.every((token) => haystack.includes(token));
});
}

// Priority sort: resolved sink to bottom (on "All" tab), then by upvotes
// desc, then oldest first as tiebreaker (index in original array = time order)
return [...list].sort((a, b) => {
// Resolved questions sink to bottom on the "All" tab
if (commentView === "all") {
if (a.isResolved !== b.isResolved) return a.isResolved ? 1 : -1;
}
// Higher upvotes first
if (b.upvotes !== a.upvotes) return b.upvotes - a.upvotes;
// Oldest first (earlier index in the original array = posted earlier)
return list.indexOf(a) - list.indexOf(b);
});
})();

Expand Down Expand Up @@ -565,6 +599,7 @@ export default function ClassChat({ chatHistoryRef }: ClassChatProps) {
? () => handleResolve(q.id)
: undefined
}
onUnresolve={isInstructor ? () => handleUnresolve(q.id) : undefined}
canAnswer={canAnswerGlobal || q.user?.id === userId}
onSubmitAnswer={(content) => handleSubmitAnswer(q.id, content)}
onAnswerUpvote={handleAnswerUpvote}
Expand Down
21 changes: 19 additions & 2 deletions src/app/room/classChat/post/QuestionPost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { MessageCircle, CheckCircle2, Trash2, ChevronDown, ChevronUp } from "lucide-react";
import { MessageCircle, CheckCircle2, Undo2, Trash2, ChevronDown, ChevronUp } from "lucide-react";
import { Question, Post } from "@/utils/types";
import { UpvoteButton, renderUsername } from "./PostUtils";

Expand Down Expand Up @@ -111,6 +111,7 @@ export default function QuestionPost({
canAnswer = true,
onUpvote,
onResolve,
onUnresolve,
onDelete,
onSubmitAnswer,
children,
Expand All @@ -122,6 +123,7 @@ export default function QuestionPost({
canAnswer?: boolean;
onUpvote?: () => void;
onResolve?: () => void;
onUnresolve?: () => void;
onDelete?: () => void;
onSubmitAnswer?: (content: string) => void;
children?: React.ReactNode;
Expand Down Expand Up @@ -159,7 +161,9 @@ export default function QuestionPost({
const showThread = threadState !== "collapsed" && (visibleReplies.length > 0 || isReplying);

return (
<div className="flex flex-col gap-2 bg-stone-075 rounded-md p-4 border border-stone-200">
<div
className={`flex flex-col gap-2 rounded-md p-4 border transition-colors duration-200 ease-out ${resolved ? "bg-green-50/60 border-l-2 border-green-200 border-l-green-400" : "bg-stone-075 border-l-2 border-stone-200 border-l-amber-400"}`}
>
{/* Question body */}
<div className="font-semibold whitespace-pre-wrap text-stone-900">{post.content}</div>

Expand Down Expand Up @@ -248,6 +252,19 @@ export default function QuestionPost({
</Button>
)}

{onUnresolve && resolved && (
<Button
variant="ghost"
size="sm"
className="group/unresolve h-7 px-2 text-xs gap-1 text-green-600 hover:text-amber-600 hover:bg-amber-50"
onClick={() => onUnresolve()}
title="Unresolve"
>
<CheckCircle2 className="h-4 w-4 group-hover/unresolve:hidden" />
<Undo2 className="h-4 w-4 hidden group-hover/unresolve:block" />
</Button>
)}

{onDelete && (
<Button
variant="ghost"
Expand Down
3 changes: 3 additions & 0 deletions src/app/room/classChat/post/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface PostItemProps {
canAnswer?: boolean;
onUpvote?: () => void;
onResolve?: () => void;
onUnresolve?: () => void;
onSubmitAnswer?: (content: string) => void;
onAnswerUpvote?: (answerId: string) => void;
/** Called when the professor/TA wants to delete this question. */
Expand All @@ -30,6 +31,7 @@ export default function PostItem({
canAnswer = true,
onUpvote,
onResolve,
onUnresolve,
onSubmitAnswer,
onAnswerUpvote,
onDeleteQuestion,
Expand All @@ -47,6 +49,7 @@ export default function PostItem({
canAnswer={canAnswer}
onUpvote={onUpvote}
onResolve={onResolve}
onUnresolve={onUnresolve}
onSubmitAnswer={onSubmitAnswer}
onDelete={onDeleteQuestion}
renderReply={(reply) => (
Expand Down
2 changes: 1 addition & 1 deletion src/app/room/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ function RoomInner() {

return (
<RoomContext.Provider value={{ socket, sessionId, userId, role, sessionTitle }}>
<div className="h-screen w-full bg-background font-sans">
<div className="relative h-screen w-full bg-background font-sans">
<SlideUpdateContext.Provider value={{ isSlidesVisible, rerender }}>
{isSlidesVisible ? (
<div className="h-screen w-full bg-background font-sans">
Expand Down
118 changes: 118 additions & 0 deletions src/socket/handlers/questionHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,124 @@ export function handleQuestionResolve(socket: Socket, io: Server): void {
});
}

// ---------------------------------------------------------------------------
// Unresolving
// ---------------------------------------------------------------------------

interface QuestionUnresolvePayload {
questionId: string;
}

/**
* Registers the `question:unresolve` event listener on the given socket.
*
* Only TA / PROFESSOR may unresolve. Reverts status to OPEN.
* Shares the same rate limit counter as resolve.
*
* Guard order (cheap-before-expensive):
* 1. Auth — socket.data.userId must exist
* 2. Payload shape — must be a non-null object with a string questionId
* 3. Rate limit — shared with resolve: 20 / 60 s per user
* 4. Question — must exist and currently be RESOLVED
* 5. Permission — TA/PROFESSOR only
* 6. Persist — question status updated to OPEN
* 7. Broadcast — emit unresolved status to the session room
*/
export function handleQuestionUnresolve(socket: Socket, io: Server): void {
socket.on("question:unresolve", async (payload: QuestionUnresolvePayload) => {
try {
// 1. Auth guard
const userId: string | undefined = socket.data?.userId;
if (!userId) {
socket.emit("question:error", { message: "Authentication required." });
return;
}

// 2. Payload shape guard
if (!payload || typeof payload !== "object") {
socket.emit("question:error", { message: "Invalid request." });
return;
}

const { questionId } = payload;
if (!questionId || typeof questionId !== "string") {
socket.emit("question:error", { message: "Question ID is required." });
return;
}

// 3. Rate limit (shared with resolve)
const isRateLimited = await checkResolveRateLimit(userId);
if (isRateLimited) {
socket.emit("question:error", {
message: "You are resolving too quickly. Please wait before trying again.",
});
return;
}

// 4. Fetch question
const question = await prisma.question.findUnique({
where: { id: questionId },
select: {
id: true,
sessionId: true,
status: true,
visibility: true,
session: { select: { courseId: true } },
},
});

if (!question) {
socket.emit("question:error", { message: "Question not found." });
return;
}

if (question.status !== "RESOLVED") {
socket.emit("question:error", { message: "Question is not resolved." });
return;
}

// 5. Permission check — only TA/PROFESSOR may unresolve
const enrollment = await prisma.courseEnrollment.findUnique({
where: {
userId_courseId: { userId, courseId: question.session.courseId },
},
select: { role: true },
});

const requesterRole = enrollment?.role ?? "STUDENT";

if (requesterRole !== "TA" && requesterRole !== "PROFESSOR") {
socket.emit("question:error", {
message: "You do not have permission to unresolve this question.",
});
return;
}

// 6. Update status back to OPEN
await prisma.question.update({
where: { id: questionId },
data: { status: "OPEN" },
});

// 7. Broadcast to the appropriate room
const targetRoom =
question.visibility === "INSTRUCTOR_ONLY"
? `session:${question.sessionId}:instructors`
: `session:${question.sessionId}`;

io.to(targetRoom).emit("question:unresolved", {
id: questionId,
status: "OPEN",
});
} catch (error) {
console.error("[QuestionHandler] Failed to unresolve question:", error);
socket.emit("question:error", {
message: "An error occurred while unresolving the question.",
});
}
});
}

// ---------------------------------------------------------------------------
// Deleting
// ---------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions src/socket/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
handleQuestionCreate,
handleQuestionUpvote,
handleQuestionResolve,
handleQuestionUnresolve,
handleQuestionDelete,
} from "./handlers/questionHandlers";
import {
Expand Down Expand Up @@ -238,6 +239,7 @@ export async function initSocketIO(
handleQuestionCreate(socket, io!);
handleQuestionUpvote(socket, io!);
handleQuestionResolve(socket, io!);
handleQuestionUnresolve(socket, io!);
handleQuestionDelete(socket, io!);
handleAnswerCreate(socket, io!);
handleAnswerUpvote(socket, io!);
Expand Down
Loading
Loading