1- import React , { useEffect , useState , useRef , useCallback } from "react" ;
1+ import React , { useEffect , useMemo , useRef , useState } from "react" ;
22import { useLectureStatusStore } from "@/store/useLectureStatusStore" ;
3+ import { ChatMessage , useLectureChat } from "@/hooks/useLectureChat" ;
34import NoDataView from "@/components/NoDataView/NoDataView" ;
45import { MessageCircle , Send } from "lucide-react" ;
56import styles from "./QuestionListSection.module.scss" ;
67import LoadingSpinner from "@/components/LoadingSpinner/LoadingSpinner" ;
78import BasicInput from "@/components/Input/BasicInput/BasicInput" ;
89import IconButton from "@/components/Button/IconButton/IconButton" ;
10+ import { fetchChattingList } from "@/api/lectures/fetchChattingList" ;
911
1012export 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 >
0 commit comments