Skip to content

Commit 6481373

Browse files
authored
Merge pull request #335 from KW-ClassLog/Feat/#332/student-chatting-socket
✨ Feat/#332 강의중일 때 학생 채팅 소켓 연결
2 parents 34c483c + 79beb20 commit 6481373

File tree

9 files changed

+126
-147
lines changed

9 files changed

+126
-147
lines changed

frontend/api/axiosInstance.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export const axiosInstance = axios.create({
77
withCredentials: true,
88
});
99

10-
// 토큰이 필요하지 않은 API들 (로그인, 로그아웃, 회원가입, 이메일 인증)
10+
// 토큰이 필요하지 않은 API들 (로그인, 회원가입, 이메일 인증 페이지)
1111
const noTokenRequired = [
1212
"/users/login",
1313
"/users/signup",
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { axiosInstance } from "@/api/axiosInstance";
2+
import axios from "axios"; // 추가
3+
import { ENDPOINTS } from "@/constants/endpoints";
4+
import { ApiResponse } from "@/types/apiResponseTypes";
5+
import { FetchChattingListResult } from "@/types/lectures/fetchChattingListTypes";
6+
7+
export async function fetchChattingList(lectureId: string) {
8+
try {
9+
const response = await axiosInstance.get<
10+
ApiResponse<FetchChattingListResult>
11+
>(ENDPOINTS.LECTURES.GET_CHATTING_LIST(lectureId));
12+
return response.data;
13+
} catch (error: unknown) {
14+
if (axios.isAxiosError(error) && error.response) {
15+
return error.response.data as ApiResponse<FetchChattingListResult>;
16+
}
17+
throw error;
18+
}
19+
}

frontend/app/student/lecture-detail/[lectureId]/_components/QuestionListSection/QuestionListSection.module.scss

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
}
2323

2424
.questionItem {
25+
display: flex;
26+
flex-direction: column;
27+
gap: $spacing-xs;
2528
width: fit-content;
2629
padding: $spacing-sm;
2730
border: 1px solid $color-neutral-7;
@@ -30,11 +33,15 @@
3033

3134
&:last-child {
3235
border-bottom: none;
36+
margin-bottom: $spacing-sm;
3337
}
3438
}
3539

40+
.bottomSpacer {
41+
height: 1px;
42+
}
43+
3644
.timestamp {
37-
margin-top: $spacing-xs;
3845
font-size: $font-size-sm;
3946
color: $color-neutral-5;
4047
}
@@ -46,6 +53,12 @@
4653
font-weight: $font-weight-light;
4754
}
4855

56+
.teacherName {
57+
font-size: $font-size-sm;
58+
font-weight: $font-weight-medium;
59+
color: $color-blue;
60+
}
61+
4962
.questionInputContainer {
5063
display: flex;
5164
gap: $spacing-sm;

frontend/app/student/lecture-detail/[lectureId]/_components/QuestionListSection/QuestionListSection.tsx

Lines changed: 62 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,116 +1,79 @@
1-
import React, { useEffect, useState, useRef, useCallback } from "react";
1+
import React, { useEffect, useMemo, useRef, useState } from "react";
22
import { useLectureStatusStore } from "@/store/useLectureStatusStore";
3+
import { ChatMessage, useLectureChat } from "@/hooks/useLectureChat";
34
import NoDataView from "@/components/NoDataView/NoDataView";
45
import { MessageCircle, Send } from "lucide-react";
56
import styles from "./QuestionListSection.module.scss";
67
import LoadingSpinner from "@/components/LoadingSpinner/LoadingSpinner";
78
import BasicInput from "@/components/Input/BasicInput/BasicInput";
89
import IconButton from "@/components/Button/IconButton/IconButton";
10+
import { fetchChattingList } from "@/api/lectures/fetchChattingList";
911

1012
export default function QuestionListSection({
1113
lectureId,
1214
}: {
1315
lectureId: string;
1416
}) {
1517
const { lectureStatus } = useLectureStatusStore();
16-
const [questions, setQuestions] = useState<string[]>([]);
18+
const { messages, connected, sendMessage } = useLectureChat(lectureId);
1719
const [questionInput, setQuestionInput] = useState<string>("");
1820
const [loading, setLoading] = useState(true);
19-
const socketRef = useRef<WebSocket | null>(null);
20-
21-
// 소켓 연결 함수
22-
const connectSocket = useCallback(() => {
23-
if (socketRef.current?.readyState === WebSocket.OPEN) return;
24-
25-
try {
26-
// TODO: 실제 소켓 서버 URL로 변경
27-
const socketUrl = `ws://localhost:8080/ws/lecture/${lectureId}`;
28-
socketRef.current = new WebSocket(socketUrl);
29-
30-
socketRef.current.onopen = () => {
31-
console.log("소켓 연결 성공");
32-
};
33-
34-
socketRef.current.onmessage = (event) => {
35-
try {
36-
const data = JSON.parse(event.data);
37-
handleSocketMessage(data);
38-
} catch (error) {
39-
console.error("소켓 메시지 파싱 오류:", error);
40-
}
41-
};
42-
43-
socketRef.current.onclose = () => {
44-
console.log("소켓 연결 종료");
45-
};
46-
47-
socketRef.current.onerror = (error) => {
48-
console.error("소켓 오류:", error);
49-
};
50-
} catch (error) {
51-
console.error("소켓 연결 실패:", error);
52-
}
53-
}, [lectureId]);
54-
55-
// 소켓 메시지 처리 함수
56-
const handleSocketMessage = (data: {
57-
type: string;
58-
question?: string;
59-
questions?: string[];
60-
}) => {
61-
switch (data.type) {
62-
case "newQuestion":
63-
setQuestions((prev) => [...prev, data.question || ""]);
64-
break;
65-
case "questionList":
66-
setQuestions(data.questions || []);
67-
break;
68-
default:
69-
console.log("알 수 없는 메시지 타입:", data.type);
70-
}
71-
};
21+
const [previousMessages, setPreviousMessages] = useState<ChatMessage[]>([]);
22+
const listEndRef = useRef<HTMLLIElement | null>(null);
7223

7324
// 질문 전송 함수
7425
const sendQuestion = () => {
75-
if (!questionInput.trim() || !socketRef.current) return;
26+
if (!questionInput.trim() || !connected) return;
7627

77-
const message = {
78-
type: "sendQuestion",
79-
lectureId: lectureId,
80-
question: questionInput.trim(),
81-
timestamp: new Date().toISOString(),
82-
};
83-
84-
socketRef.current.send(JSON.stringify(message));
28+
sendMessage(questionInput.trim());
8529
setQuestionInput(""); // 입력창 초기화
8630
};
8731

8832
useEffect(() => {
89-
// TODO: API 호출로 변경
90-
setQuestions([
91-
"dd",
92-
"AsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfasAsdfas",
93-
"Asdfadfg",
94-
"Asdfadfg",
95-
]);
96-
setLoading(false);
97-
98-
// 강의 중일 때만 소켓 연결
99-
if (lectureStatus === "onLecture") {
100-
connectSocket();
101-
}
102-
103-
// 컴포넌트 언마운트 시 소켓 연결 해제
104-
return () => {
105-
if (socketRef.current) {
106-
socketRef.current.close();
33+
let isMounted = true;
34+
const loadPreviousMessages = async () => {
35+
try {
36+
const res = await fetchChattingList(lectureId);
37+
if (!isMounted) return;
38+
if (res.isSuccess && Array.isArray(res.result)) {
39+
const mapped: ChatMessage[] = res.result.map((m) => ({
40+
senderId: null,
41+
senderName: null,
42+
content: m.content,
43+
role: m.role,
44+
timestamp: m.timestamp,
45+
}));
46+
setPreviousMessages(mapped);
47+
} else {
48+
setPreviousMessages([]);
49+
}
50+
} catch {
51+
if (!isMounted) return;
52+
setPreviousMessages([]);
53+
} finally {
54+
if (isMounted) setLoading(false);
10755
}
10856
};
109-
}, [lectureId, lectureStatus, connectSocket]);
57+
loadPreviousMessages();
58+
return () => {
59+
isMounted = false;
60+
};
61+
}, [lectureId]);
62+
63+
const combinedMessages = useMemo(() => {
64+
// 과거 메시지 이후에 실시간 메시지 순서로 노출
65+
return [...previousMessages, ...messages];
66+
}, [previousMessages, messages]);
67+
68+
// 새로운 메시지가 추가될 때 항상 맨 아래로 스크롤
69+
useEffect(() => {
70+
listEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
71+
}, [combinedMessages]);
11072

111-
const now = () => {
73+
// 시간 포맷팅 함수
74+
const formatTime = (timestamp: string) => {
11275
try {
113-
const date = new Date();
76+
const date = new Date(timestamp);
11477
const hours = date.getHours().toString().padStart(2, "0");
11578
const minutes = date.getMinutes().toString().padStart(2, "0");
11679
return `${hours}:${minutes}`;
@@ -126,12 +89,22 @@ export default function QuestionListSection({
12689
{lectureStatus === "onLecture" ? (
12790
<div className={styles.questionListContainer}>
12891
<ul className={styles.questionList}>
129-
{questions.map((q, index) => (
92+
{combinedMessages.map((message, index) => (
13093
<li key={index} className={styles.questionItem}>
131-
<div className={styles.message}>{q}</div>
132-
<div className={styles.timestamp}>{now()}</div>
94+
<div className={styles.message}>
95+
<div className={styles.content}>{message.content}</div>
96+
</div>
97+
<div className={styles.timestamp}>
98+
{formatTime(message.timestamp)}
99+
</div>
100+
{message.role === "TEACHER" && (
101+
<div className={styles.teacherName}>
102+
* 강사가 보낸 메시지입니다.
103+
</div>
104+
)}
133105
</li>
134106
))}
107+
<li ref={listEndRef} className={styles.bottomSpacer} />
135108
</ul>
136109
<div className={styles.questionInputContainer}>
137110
<BasicInput
@@ -143,6 +116,7 @@ export default function QuestionListSection({
143116
icon={<Send />}
144117
onClick={sendQuestion}
145118
ariaLabel={"전송"}
119+
disabled={!connected}
146120
/>
147121
</div>
148122
</div>
Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useEffect, useRef, useState } from "react";
3+
import { useCallback, useEffect, useRef, useState } from "react";
44
import { useParams } from "next/navigation";
55
import IconButton from "@/components/Button/IconButton/IconButton";
66
import ToolPopover from "../../ToolPopover/ToolPopover";
@@ -10,29 +10,20 @@ import { FileText } from "lucide-react";
1010
import { fetchLectureNoteByLectureId } from "@/api/lectures/fetchLectureNoteByLectureId";
1111
import { FetchLectureNoteByLectureIdResult } from "@/types/lectures/fetchLectureNoteByLectureIdTypes";
1212
import styles from "./LectureNoteButton.module.scss";
13+
import useSelectedClassStore from "@/store/useSelectedClassStore";
1314

1415
export default function LectureNoteButton() {
1516
const { lectureId } = useParams<{ lectureId: string }>();
1617
const docBtnRef = useRef<HTMLSpanElement>(null);
1718

1819
const [openDoc, setOpenDoc] = useState(false);
1920
const [uploadOpen, setUploadOpen] = useState(false);
20-
const [classId, setClassId] = useState<string | null>(null);
21-
const [lectureNotes, setLectureNotes] = useState<FetchLectureNoteByLectureIdResult[]>([]);
21+
const [lectureNotes, setLectureNotes] = useState<
22+
FetchLectureNoteByLectureIdResult[]
23+
>([]);
24+
const { selectedClassId } = useSelectedClassStore();
2225

23-
useEffect(() => {
24-
const raw = localStorage.getItem("class-storage");
25-
if (raw) {
26-
try {
27-
const parsed = JSON.parse(raw);
28-
setClassId(parsed.state?.selectedClassId ?? null);
29-
} catch (err) {
30-
console.error("class-storage 파싱 실패:", err);
31-
}
32-
}
33-
}, []);
34-
35-
const fetchLectureNotes = async () => {
26+
const fetchLectureNotes = useCallback(async () => {
3627
try {
3728
const response = await fetchLectureNoteByLectureId(lectureId);
3829
if (response.isSuccess && response.result) {
@@ -45,11 +36,11 @@ export default function LectureNoteButton() {
4536
console.error("강의자료 조회 오류:", err);
4637
setLectureNotes([]);
4738
}
48-
};
39+
}, [lectureId]);
4940

5041
useEffect(() => {
5142
if (lectureId) fetchLectureNotes();
52-
}, [lectureId]);
43+
}, [lectureId, fetchLectureNotes]);
5344

5445
return (
5546
<>
@@ -69,15 +60,15 @@ export default function LectureNoteButton() {
6960
side="bottom"
7061
>
7162
<LectureNotePopover
72-
notes={lectureNotes}
73-
onPicked={() => setOpenDoc(false)}
74-
onUploadRequest={() => setUploadOpen(true)}
63+
notes={lectureNotes}
64+
onPicked={() => setOpenDoc(false)}
65+
onUploadRequest={() => setUploadOpen(true)}
7566
/>
7667
</ToolPopover>
7768

78-
{uploadOpen && classId && (
69+
{uploadOpen && selectedClassId && (
7970
<FileSelectModal
80-
classId={classId}
71+
classId={selectedClassId}
8172
lectureId={lectureId}
8273
onClose={() => setUploadOpen(false)}
8374
registeredFiles={lectureNotes.map((n) => n.lectureNoteName)}
@@ -89,4 +80,4 @@ export default function LectureNoteButton() {
8980
)}
9081
</>
9182
);
92-
}
83+
}

frontend/constants/endpoints.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ export const ENDPOINTS = {
7272
`${BASE_API}/lectures/student/${lectureId}`,
7373
GET_CLASS_NAME: (lectureId: string) =>
7474
`${BASE_API}/lectures/classes/${lectureId}`,
75+
GET_CHATTING_LIST: (lectureId: string) =>
76+
`${BASE_API}/lectures/chatting/before/${lectureId}`,
7577

7678
// 노트 관련
7779
UPLOAD_NOTE: (classId: string) =>

frontend/store/resetAllStores.ts

Lines changed: 0 additions & 33 deletions
This file was deleted.

0 commit comments

Comments
 (0)