From 60b6d77a425f90c3372f520ad0d82a967f8d998a Mon Sep 17 00:00:00 2001 From: 42inshin Date: Tue, 18 Feb 2025 22:36:47 +0900 Subject: [PATCH 01/12] =?UTF-8?q?[FE]=20FEAT:=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EC=86=8C=EC=BC=93=20=ED=86=B5=EC=8B=A0=20+=20=EB=AC=B4?= =?UTF-8?q?=ED=95=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?#193?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/api/endpoints/room/room.api.ts | 17 +- .../src/api/endpoints/room/room.interface.ts | 15 + .../ChatLayout/ChatNickname/index.tsx | 2 +- .../ChatMessages/ChatLayout/index.css.ts | 4 +- .../Chating/ChatMessages/ChatLayout/index.tsx | 22 +- .../Sidebar/Chating/ChatMessages/index.css.ts | 0 .../Sidebar/Chating/ChatMessages/index.tsx | 28 -- .../src/components/Sidebar/Chating/index.tsx | 287 ++++-------------- src/frontend/src/components/Sidebar/index.tsx | 4 +- src/frontend/src/pages/RoomPage/index.tsx | 8 +- .../src/stores/useCurrentRoomStore.ts | 78 ++++- src/frontend/src/stores/useMyRoomsStore.ts | 5 + src/frontend/src/stores/useWebSocketStore.ts | 2 +- src/frontend/src/types/dto/Message.dto.ts | 10 - src/frontend/src/utils/dateUtils.ts | 21 +- 15 files changed, 193 insertions(+), 310 deletions(-) delete mode 100644 src/frontend/src/components/Sidebar/Chating/ChatMessages/index.css.ts delete mode 100644 src/frontend/src/components/Sidebar/Chating/ChatMessages/index.tsx delete mode 100644 src/frontend/src/types/dto/Message.dto.ts diff --git a/src/frontend/src/api/endpoints/room/room.api.ts b/src/frontend/src/api/endpoints/room/room.api.ts index 79b2d11c..f4de8766 100644 --- a/src/frontend/src/api/endpoints/room/room.api.ts +++ b/src/frontend/src/api/endpoints/room/room.api.ts @@ -1,5 +1,12 @@ import instance from '@/api/axios.instance'; -import { PlaylistDto, RoomRequestDto, MyRoomDto, RoomDto, CurrentRoomDto } from './room.interface'; +import { + PlaylistDto, + RoomRequestDto, + MyRoomDto, + RoomDto, + CurrentRoomDto, + ReceiveMessageDto, +} from './room.interface'; export const roomApi = { // 방 생성 @@ -41,6 +48,14 @@ export const roomApi = { // return data; // }, + // 메시지 조회 + getMessages: async (roomId: number, cursor?: number, limit?: number) => { + const { data } = await instance.get(`/messages/${roomId}`, { + params: { cursor, limit: limit ?? 10 }, + }); + return data; + }, + // 방 참여자 조회 getParticipants: async (roomId: string) => { const { data } = await instance.get(`/rooms/participants`, { params: { roomId } }); diff --git a/src/frontend/src/api/endpoints/room/room.interface.ts b/src/frontend/src/api/endpoints/room/room.interface.ts index e18dd491..9484b6a1 100644 --- a/src/frontend/src/api/endpoints/room/room.interface.ts +++ b/src/frontend/src/api/endpoints/room/room.interface.ts @@ -66,3 +66,18 @@ export interface CurrentRoomDto { playlist: CurrentRoomPlaylistDto[]; }; } + +export interface SendMessageDto { + roomId: number; + userId: number; + nickname: string | null; + role: number; + profileImageUrl: string | null; + content: string | null; + message: string; +} + +export interface ReceiveMessageDto extends SendMessageDto { + id: string; + timestamp: number; +} diff --git a/src/frontend/src/components/Sidebar/Chating/ChatMessages/ChatLayout/ChatNickname/index.tsx b/src/frontend/src/components/Sidebar/Chating/ChatMessages/ChatLayout/ChatNickname/index.tsx index efb4ec4a..fa32cf0c 100644 --- a/src/frontend/src/components/Sidebar/Chating/ChatMessages/ChatLayout/ChatNickname/index.tsx +++ b/src/frontend/src/components/Sidebar/Chating/ChatMessages/ChatLayout/ChatNickname/index.tsx @@ -34,7 +34,7 @@ export const ChatNickname = (props: IChatNickname) => { const Wrapper = styled.div` display: flex; align-items: center; - gap: 8px; + gap: 4px; `; const Img = styled.img``; diff --git a/src/frontend/src/components/Sidebar/Chating/ChatMessages/ChatLayout/index.css.ts b/src/frontend/src/components/Sidebar/Chating/ChatMessages/ChatLayout/index.css.ts index 1fd09164..a0ed5829 100644 --- a/src/frontend/src/components/Sidebar/Chating/ChatMessages/ChatLayout/index.css.ts +++ b/src/frontend/src/components/Sidebar/Chating/ChatMessages/ChatLayout/index.css.ts @@ -9,6 +9,7 @@ export const Profile = styled.img` width: 40px; height: 40px; margin-right: 4px; + border-radius: 10px; `; export const ChatContainer = styled.div` display: flex; @@ -18,10 +19,11 @@ export const Title = styled.div` display: flex; flex-direction: row; align-items: center; + margin-bottom: 4px; `; export const Title__Time = styled.div` font-size: 12px; color: #888888; - margin-left: 4px; + margin-left: 8px; `; export const ChatText = styled.div``; diff --git a/src/frontend/src/components/Sidebar/Chating/ChatMessages/ChatLayout/index.tsx b/src/frontend/src/components/Sidebar/Chating/ChatMessages/ChatLayout/index.tsx index aeefc2e0..528fb096 100644 --- a/src/frontend/src/components/Sidebar/Chating/ChatMessages/ChatLayout/index.tsx +++ b/src/frontend/src/components/Sidebar/Chating/ChatMessages/ChatLayout/index.tsx @@ -1,25 +1,19 @@ -import ProfileImg from '@/assets/img/ProfileImg.svg'; import { Wrapper, Profile, ChatContainer, Title, Title__Time, ChatText } from './index.css'; -import { UserRole } from '@/types/enums/UserRole'; import { ChatNickname } from '@/components/Sidebar/Chating/ChatMessages/ChatLayout/ChatNickname'; +import { ReceiveMessageDto } from '@/api/endpoints/room/room.interface'; +import { formatDateToKorean } from '@/utils/dateUtils'; +import DefaultProfile from '@/assets/img/DefaultProfile.svg'; -interface IChat { - role: UserRole; - nickname: string; - time: string; - text: string; -} - -export const ChatLayout = (props: IChat) => { +export const ChatLayout = ({ message }: { message: ReceiveMessageDto }) => { return ( - + - <ChatNickname role={props.role} nickname={props.nickname} /> - <Title__Time>{props.time}</Title__Time> + <ChatNickname role={message.role} nickname={message.nickname ?? '알수없음'} /> + <Title__Time>{formatDateToKorean(message.timestamp ?? new Date().getTime())}</Title__Time> - {props.text} + {message.message} ); diff --git a/src/frontend/src/components/Sidebar/Chating/ChatMessages/index.css.ts b/src/frontend/src/components/Sidebar/Chating/ChatMessages/index.css.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/frontend/src/components/Sidebar/Chating/ChatMessages/index.tsx b/src/frontend/src/components/Sidebar/Chating/ChatMessages/index.tsx deleted file mode 100644 index 02f3ec62..00000000 --- a/src/frontend/src/components/Sidebar/Chating/ChatMessages/index.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { UserRole } from '@/types/enums/UserRole'; -// import { ChatLayout } from '@/components/Sidebar/Chating/ChatMessages/ChatLayout'; -import { ChatLayout } from '@/components/Sidebar/Chating/ChatMessages/ChatLayout'; - -interface IChatMessages { - chatData: { - role: UserRole; - nickname: string; - time: string; - text: string; - }[]; -} - -export const ChatMessages = (props: IChatMessages) => { - return ( - <> - {props.chatData.map((chat, index) => ( - - ))} - - ); -}; diff --git a/src/frontend/src/components/Sidebar/Chating/index.tsx b/src/frontend/src/components/Sidebar/Chating/index.tsx index aba31d50..245e91ad 100644 --- a/src/frontend/src/components/Sidebar/Chating/index.tsx +++ b/src/frontend/src/components/Sidebar/Chating/index.tsx @@ -1,255 +1,80 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; -import Stomp, { Client, Message } from 'stompjs'; -import SockJS from 'sockjs-client'; +import { useRef, useEffect, useCallback, useState } from 'react'; import { ChatInput } from '@/components/Sidebar/Chating/ChatInput'; -import { ChatMessages } from '@/components/Sidebar/Chating/ChatMessages'; -import { UserRole } from '@/types/enums/UserRole'; -import { ChatContainer, ChatScrollArea, Blank } from './index.css'; -import { DoublyLinkedList } from '@/hooks/utils/DoublyLinkedList'; +import { ChatContainer, ChatScrollArea } from './index.css'; +import { useCurrentRoomStore } from '@/stores/useCurrentRoomStore'; +import { ChatLayout } from './ChatMessages/ChatLayout'; -const BATCH_INTERVAL = 100; -const MAX_BATCH_SIZE = 20; - -const INITIAL_CHAT_NUM = 20; -const EXTRA_CHAT_NUM = 5; -const MAX_CHAT_NUM = 30; -const CHAT_HEIGHT = 60; - -const initialChatData = Array.from({ length: 100 }, (_, i) => ({ - role: i % 2 === 0 ? UserRole.MEMBER : UserRole.CREATOR, - nickname: `User${i}`, - time: `10:${(i % 60).toString().padStart(2, '0')}`, - text: `This is message number ${i}`, -})); - -export const ChatBox = () => { - const chatListRef = useRef(new DoublyLinkedList(initialChatData)); - const startIndexRef = useRef(Math.max(chatListRef.current.length - INITIAL_CHAT_NUM, 0)); - const [visibleChat, setVisibleChat] = useState( - chatListRef.current.slice( - Math.max(chatListRef.current.length - INITIAL_CHAT_NUM, 0), - chatListRef.current.length, - ), - ); - - const [extraTopNum, setExtraTopNum] = useState(0); - const [extraDownNum, setExtraDownNum] = useState(0); - const [status, setStatus] = useState('Disconnected'); +export const Chat = () => { + const messages = useCurrentRoomStore(state => state.messages); + const sendMessage = useCurrentRoomStore(state => state.sendMessage); + const fetchMessages = useCurrentRoomStore(state => state.fetchMessages); const chatContainerRef = useRef(null); - const stompClientRef = useRef(null); - const topSentinelRef = useRef(null); - const bottomSentinelRef = useRef(null); - const incomingQueueRef = useRef<{ content: string; userId: string }[]>([]); - - const roomId = '1'; - const userId = useRef(`user${Math.floor(Math.random() * 1000)}`); - - const isAtBottom = useCallback(() => { - if (!chatContainerRef.current) return false; - const { scrollTop, clientHeight, scrollHeight } = chatContainerRef.current; - return scrollHeight - scrollTop - clientHeight < 10; - }, []); - - const sendMessage = useCallback( - (message: string) => { - if (!stompClientRef.current || !stompClientRef.current.connected) { - console.warn('웹소켓 연결이 되지 않아 메시지 전송 불가'); - return; - } - stompClientRef.current.send('/app/sendMessage', {}, JSON.stringify({ roomId, message })); - }, - [roomId], - ); - - const addMessage = useCallback( - (message: string, sender: string = 'Me', _isBatch: boolean = false) => { - if (sender !== 'Me' && sender === userId.current) return; - - const newChat = { - role: sender === 'Me' ? UserRole.MEMBER : UserRole.CREATOR, - nickname: sender, - time: new Date().toLocaleTimeString().slice(0, 5), - text: message, - }; - - if (sender === 'Me') { - sendMessage(message); - } - - chatListRef.current.push(newChat); - const newLength = chatListRef.current.length; - - setTimeout(() => { - if (sender === 'Me' || isAtBottom()) { - const newStartIndex = newLength > MAX_CHAT_NUM ? newLength - MAX_CHAT_NUM : 0; - startIndexRef.current = newStartIndex; - setVisibleChat(chatListRef.current.slice(newStartIndex, newLength)); - scrollToBottom(); - } - }, 0); - }, - [isAtBottom, sendMessage], - ); - - useEffect(() => { - const intervalId = setInterval(() => { - if (incomingQueueRef.current.length > 0) { - const batch = incomingQueueRef.current.splice(0, MAX_BATCH_SIZE); - setTimeout(() => { - batch.forEach(msg => { - addMessage(msg.content, msg.userId, true); - }); - }, 0); - } - }, BATCH_INTERVAL); + const prevScrollHeightRef = useRef(0); + const [isInitialLoad, setIsInitialLoad] = useState(true); + const [isFetching, setIsFetching] = useState(false); + const [isMyMessage, setIsMyMessage] = useState(false); - return () => clearInterval(intervalId); - }, [addMessage]); - - // WebSocket 연결 - const connect = useCallback(() => { - if (stompClientRef.current) { - console.warn('웹소켓이 이미 연결되어 있습니다.'); - return; - } - const API_BASE_URL = 'http://localhost:8000'; - const socket = new SockJS(`${API_BASE_URL}/api/chat/ws`); - const client = Stomp.over(socket); - client.connect({}, () => { - console.log('WebSocket Connected'); - setStatus('Connected'); - stompClientRef.current = client; - - // 채팅방 구독 - const subscription = client.subscribe(`/topic/${roomId}`, (message: Message) => { - try { - const payload = JSON.parse(message.body); - incomingQueueRef.current.push({ content: payload.content, userId: payload.userId }); - } catch (error) { - console.error('❌: ', error); - } - }); - - client.send('/app/joinRoom', {}, JSON.stringify({ roomId, userId: userId.current })); - - return () => { - subscription.unsubscribe(); - }; - }); - }, [roomId]); - - // WebSocket 연결 해제 - const disconnect = useCallback(() => { - if (stompClientRef.current && stompClientRef.current.connected) { - stompClientRef.current.disconnect(() => { - console.log('❌ 웹소켓 연결이 해제되었습니다.'); - setStatus('Disconnected'); - stompClientRef.current = null; - }); - } else { - console.warn('웹소켓이 이미 연결되지 않았습니다.'); - } - }, []); - - // 상단 감지 Observer + // 최초 메시지 로딩 시 스크롤 이동 useEffect(() => { - const observerOptions = { root: chatContainerRef.current, threshold: 1.0 }; - const topObserver = new IntersectionObserver(entries => { - const entry = entries[0]; - if (entry.isIntersecting && startIndexRef.current > 0) { - const newStartIndex = Math.max(0, startIndexRef.current - EXTRA_CHAT_NUM); - const prevHeight = chatContainerRef.current?.scrollHeight || 0; - if (visibleChat.length === MAX_CHAT_NUM) { - setExtraDownNum(prev => prev + EXTRA_CHAT_NUM); - setExtraTopNum(prev => Math.max(prev - EXTRA_CHAT_NUM, 0)); - } - startIndexRef.current = newStartIndex; - setVisibleChat(chatListRef.current.slice(newStartIndex, newStartIndex + MAX_CHAT_NUM)); - requestAnimationFrame(() => { - if (chatContainerRef.current) { - const newHeight = chatContainerRef.current.scrollHeight + extraTopNum * CHAT_HEIGHT; - chatContainerRef.current.scrollTop = newHeight - prevHeight; - } - }); - } - }, observerOptions); + if (!chatContainerRef.current || !messages.length) return; - if (topSentinelRef.current) { - topObserver.observe(topSentinelRef.current); + if (isInitialLoad) { + chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight; + setIsInitialLoad(false); } - return () => { - if (topSentinelRef.current) { - topObserver.unobserve(topSentinelRef.current); - } - }; - }, [visibleChat]); + }, [messages, isInitialLoad]); - // 하단 감지 Observer + // 메시지 변경 시 스크롤 이동 useEffect(() => { - const observerOptions = { root: chatContainerRef.current, threshold: 1.0 }; - const bottomObserver = new IntersectionObserver(entries => { - const entry = entries[0]; - if ( - entry.isIntersecting && - startIndexRef.current < chatListRef.current.length - MAX_CHAT_NUM - ) { - const newStartIndex = Math.min( - startIndexRef.current + EXTRA_CHAT_NUM, - chatListRef.current.length - MAX_CHAT_NUM, - ); - const prevHeight = chatContainerRef.current?.scrollHeight || 0; - if (visibleChat.length === MAX_CHAT_NUM) { - setExtraDownNum(prev => Math.max(prev - EXTRA_CHAT_NUM, 0)); - setExtraTopNum(prev => prev + EXTRA_CHAT_NUM); - } - startIndexRef.current = newStartIndex; - setVisibleChat(chatListRef.current.slice(newStartIndex, newStartIndex + MAX_CHAT_NUM)); - requestAnimationFrame(() => { - if (chatContainerRef.current) { - const newHeight = chatContainerRef.current.scrollHeight; - chatContainerRef.current.scrollTop += - newHeight - prevHeight - CHAT_HEIGHT * EXTRA_CHAT_NUM; - } - }); - } - }, observerOptions); + if (!chatContainerRef.current) return; - if (bottomSentinelRef.current) { - bottomObserver.observe(bottomSentinelRef.current); - } - return () => { - if (bottomSentinelRef.current) { - bottomObserver.unobserve(bottomSentinelRef.current); - } - }; - }, [visibleChat]); - - const scrollToBottom = useCallback(() => { - if (chatContainerRef.current) { - setExtraTopNum(prev => prev + extraDownNum); - setExtraDownNum(0); + if (prevScrollHeightRef.current && isFetching) { + chatContainerRef.current.scrollTop = + chatContainerRef.current.scrollHeight - prevScrollHeightRef.current; + setIsFetching(false); + } else if (isMyMessage) { chatContainerRef.current.scrollTo({ top: chatContainerRef.current.scrollHeight, behavior: 'smooth', }); + setIsMyMessage(false); + } + }, [messages]); + + // 메시지 추가 + const addMessage = useCallback( + (message: string) => { + prevScrollHeightRef.current = 0; + setIsMyMessage(true); + sendMessage(message); + }, + [sendMessage], + ); + + const handleScroll = () => { + if (!chatContainerRef.current) return; + + prevScrollHeightRef.current = chatContainerRef.current.scrollTop; + if (chatContainerRef.current.scrollTop === 0) { + prevScrollHeightRef.current = chatContainerRef.current.scrollHeight; + console.log('fetchMessages'); + setIsFetching(true); + fetchMessages(useCurrentRoomStore.getState().messages[0]?.timestamp); } - }, [extraDownNum]); + }; return ( - - -
- -
- + + {messages.map((message, index) => ( + + ))} - addMessage(msg, 'Me')} /> - - -

Status: {status}

+ addMessage(msg)} /> ); }; + +// TODO: messages key={index} -> timestamp로 변경하기 diff --git a/src/frontend/src/components/Sidebar/index.tsx b/src/frontend/src/components/Sidebar/index.tsx index c1c3f708..b9d220be 100644 --- a/src/frontend/src/components/Sidebar/index.tsx +++ b/src/frontend/src/components/Sidebar/index.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { ChatBox } from './Chating'; +import { Chat } from './Chating'; import { UserList } from './UserList'; import { Playlist } from './Playlist'; import { VoiceChat } from './VoiceChat'; @@ -14,7 +14,7 @@ export const Sidebar = () => { const [interfaceType, setInterfaceType] = useState(SidebarType.CHAT); const contentComponents = { - [SidebarType.CHAT]: ChatBox, + [SidebarType.CHAT]: Chat, [SidebarType.PLAYLIST]: Playlist, [SidebarType.VOICECHAT]: VoiceChat, [SidebarType.USERLIST]: UserList, diff --git a/src/frontend/src/pages/RoomPage/index.tsx b/src/frontend/src/pages/RoomPage/index.tsx index de893097..34c44b97 100644 --- a/src/frontend/src/pages/RoomPage/index.tsx +++ b/src/frontend/src/pages/RoomPage/index.tsx @@ -14,13 +14,13 @@ export const RoomPage = () => { const { data: room } = useCurrentRoom(roomCode); console.log('RoomPage:', room); - const { messages } = useCurrentRoomStore(); // NOTE: 채팅 메시지 테스트용 - useEffect(() => { if (room) { useCurrentRoomStore.getState().setCurrentRoom(room); - useCurrentRoomStore.getState().subscribeChat(room.roomDetails.roomInfo[0]?.roomId); } + return () => { + useCurrentRoomStore.getState().clearCurrentRoom(); + }; }, [room]); return ( @@ -29,8 +29,6 @@ export const RoomPage = () => { - {/* NOTE: 채팅 메시지 테스트 용 */} -
    {messages && messages.map((message, i) =>
  • {message.message}
  • )}
diff --git a/src/frontend/src/stores/useCurrentRoomStore.ts b/src/frontend/src/stores/useCurrentRoomStore.ts index 45016652..68b67491 100644 --- a/src/frontend/src/stores/useCurrentRoomStore.ts +++ b/src/frontend/src/stores/useCurrentRoomStore.ts @@ -8,39 +8,101 @@ import { create } from 'zustand'; import { CurrentRoomDto } from '@/api/endpoints/room/room.interface'; import { useWebSocketStore } from './useWebSocketStore'; import { useUserStore } from './useUserStore'; -import { ReceiveMessageDto, SendMessageDto } from '@/types/dto/Message.dto'; +import { ReceiveMessageDto, SendMessageDto } from '@/api/endpoints/room/room.interface'; +import { roomApi } from '@/api/endpoints/room/room.api'; +import { UserRole } from '@/types/enums/UserRole'; interface CurrentRoomStore { + roomId?: number; currentRoom: CurrentRoomDto | null; messages: ReceiveMessageDto[]; - sendMessage: (messageDto: SendMessageDto) => void; - subscribeChat: (roomId: number) => void; + messageQueue: ReceiveMessageDto[]; + sendMessage: (message: string) => void; + subscribeChat: () => void; + fetchMessages: (cursor?: number, limit?: number) => void; addMessage: (message: ReceiveMessageDto) => void; setCurrentRoom: (room: CurrentRoomDto) => void; clearCurrentRoom: () => void; + processBatchMessages: () => void; } +const BATCH_SIZE = 20; +const BATCH_INTERVAL = 100; export const useCurrentRoomStore = create((set, get) => ({ + roomId: undefined, currentRoom: null, messages: [], + messageQueue: [], + setCurrentRoom: (room: CurrentRoomDto) => { + set({ currentRoom: room, roomId: room.roomDetails.roomInfo[0]?.roomId }); + get().fetchMessages(); + get().subscribeChat(); + }, + clearCurrentRoom: () => + set({ roomId: undefined, currentRoom: null, messages: [], messageQueue: [] }), - setCurrentRoom: (room: CurrentRoomDto) => set({ currentRoom: room }), - clearCurrentRoom: () => set({ currentRoom: null }), + subscribeChat: () => { + const roomId = get().roomId; + if (!roomId) return; - subscribeChat: (roomId: number) => { const destination = `/topic/room/${roomId}/chat`; useWebSocketStore.getState().subTopic(destination, (message: ReceiveMessageDto) => { - get().addMessage(message); + console.log('---------message', message); + set(state => ({ messageQueue: [...state.messageQueue, message] })); + }); + + const intervalId = setInterval(() => { + get().processBatchMessages(); + }, BATCH_INTERVAL); + + return () => clearInterval(intervalId); + }, + + processBatchMessages: () => { + set(state => { + if (state.messageQueue.length === 0) return state; + + // 큐에서 일정 개수만큼 메시지를 가져옴 + const batch = state.messageQueue.slice(0, BATCH_SIZE); + const remainingQueue = state.messageQueue.slice(BATCH_SIZE); + + // 메시지 배열과 큐 업데이트 + return { + ...state, + messages: [...state.messages, ...batch], + messageQueue: remainingQueue, + }; }); }, - sendMessage: (messageDto: SendMessageDto) => { + fetchMessages: async (cursor?: number, limit?: number) => { + const roomId = get().roomId; + if (!roomId) return; + const messageHistory = await roomApi.getMessages(roomId, cursor, limit ?? 30); + const sortedMessages = messageHistory.sort((a, b) => a.timestamp - b.timestamp); + set({ messages: [...sortedMessages, ...get().messages] }); + }, + + sendMessage: (message: string) => { const { client } = useWebSocketStore.getState(); if (!client) return; const { user } = useUserStore.getState(); if (!user) return; + const roomId = get().roomId; + const myRole = get().currentRoom?.myRole; + if (!roomId) return; + const messageDto: SendMessageDto = { + roomId: roomId, + userId: user.userId, + nickname: user.nickname, + role: myRole ?? UserRole.MEMBER, + profileImageUrl: user.profileImageUrl, + content: null, + message: message, + }; + console.log('---------messageDto', messageDto); client.send(`/app/send-message`, {}, JSON.stringify(messageDto)); }, diff --git a/src/frontend/src/stores/useMyRoomsStore.ts b/src/frontend/src/stores/useMyRoomsStore.ts index 0555d51d..68e90ce4 100644 --- a/src/frontend/src/stores/useMyRoomsStore.ts +++ b/src/frontend/src/stores/useMyRoomsStore.ts @@ -5,6 +5,7 @@ import { create } from 'zustand'; import { roomApi } from '@/api/endpoints/room/room.api'; import { persist } from 'zustand/middleware'; import { useWebSocketStore } from './useWebSocketStore'; +import { useCurrentRoomStore } from './useCurrentRoomStore'; interface MyRoomsStore { myRooms: MyRoomDto[]; @@ -28,6 +29,10 @@ export const useMyRoomsStore = create( set({ myRooms }); console.log('myRooms', myRooms); const myRoomIds = myRooms.map(room => room.roomId); + const currentRoomId = useCurrentRoomStore.getState().roomId; + if (currentRoomId) { // 현재 /rooom에서 방을 보고 있다면 구독을 currentRoomStore에서 처리 + myRoomIds.splice(myRoomIds.indexOf(currentRoomId), 1); + } useWebSocketStore.getState().subscribeRooms(myRoomIds); return myRooms; } catch (error) { diff --git a/src/frontend/src/stores/useWebSocketStore.ts b/src/frontend/src/stores/useWebSocketStore.ts index 895fcd09..2bd015fd 100644 --- a/src/frontend/src/stores/useWebSocketStore.ts +++ b/src/frontend/src/stores/useWebSocketStore.ts @@ -37,8 +37,8 @@ export const useWebSocketStore = create((set, get) => ({ connect: () => { if (get().client?.connected) { get().disconnect(); - return; } + // 기존 소켓 정리 if (get().socket) { get().socket?.close(); diff --git a/src/frontend/src/types/dto/Message.dto.ts b/src/frontend/src/types/dto/Message.dto.ts deleted file mode 100644 index e48073cd..00000000 --- a/src/frontend/src/types/dto/Message.dto.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface SendMessageDto { - roomId: number; - userId: number; - content?: string; - message?: string; -} - -export interface ReceiveMessageDto extends SendMessageDto { - timestamp: string; -} diff --git a/src/frontend/src/utils/dateUtils.ts b/src/frontend/src/utils/dateUtils.ts index 4cb8d7c7..44e1187d 100644 --- a/src/frontend/src/utils/dateUtils.ts +++ b/src/frontend/src/utils/dateUtils.ts @@ -1,11 +1,16 @@ export const formatDateToKorean = (timestamp: string | number | Date): string => { const date = new Date(timestamp); - return date.toLocaleString('ko-KR', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: false, - }); + const today = new Date(); + if (date.toDateString() === today.toDateString()) { + return date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false }); + } else { + return date.toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + } }; From a3d53d2657edae6ffbbbee0a02533378f13d7591 Mon Sep 17 00:00:00 2001 From: 42inshin Date: Tue, 18 Feb 2025 23:05:23 +0900 Subject: [PATCH 02/12] =?UTF-8?q?[FE]=20FEAT:=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80=20#193?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Sidebar/Chating/index.css.ts | 17 +++++++ .../src/components/Sidebar/Chating/index.tsx | 46 +++++++++++++++++-- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/components/Sidebar/Chating/index.css.ts b/src/frontend/src/components/Sidebar/Chating/index.css.ts index 3e4cf028..a69b658e 100644 --- a/src/frontend/src/components/Sidebar/Chating/index.css.ts +++ b/src/frontend/src/components/Sidebar/Chating/index.css.ts @@ -7,6 +7,7 @@ export const ChatContainer = styled.div` background-color: #ffffff; border: 1px solid #d4d4d4; border-radius: 10px; + position: relative; `; export const ChatScrollArea = styled.div` @@ -99,3 +100,19 @@ export const ChatInputButton = styled.button` export const Blank = styled.div<{ $blankPadding: string }>` padding-bottom: ${({ $blankPadding }) => $blankPadding}; `; + +export const ScrollButton = styled.button` + position: absolute; + bottom: 70px; + right: 15px; + z-index: 100; + background-color: var(--palette-primary); + box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1); + opacity: 0.9; + border: none; + border-radius: 50%; + padding: 5px 10px; + cursor: pointer; + width: 40px; + height: 40px; +`; diff --git a/src/frontend/src/components/Sidebar/Chating/index.tsx b/src/frontend/src/components/Sidebar/Chating/index.tsx index 245e91ad..ed425621 100644 --- a/src/frontend/src/components/Sidebar/Chating/index.tsx +++ b/src/frontend/src/components/Sidebar/Chating/index.tsx @@ -1,9 +1,9 @@ import { useRef, useEffect, useCallback, useState } from 'react'; import { ChatInput } from '@/components/Sidebar/Chating/ChatInput'; -import { ChatContainer, ChatScrollArea } from './index.css'; +import { ChatContainer, ChatScrollArea, ScrollButton } from './index.css'; import { useCurrentRoomStore } from '@/stores/useCurrentRoomStore'; import { ChatLayout } from './ChatMessages/ChatLayout'; - +import ArrowDown from '@/assets/img/ArrowDown.svg'; export const Chat = () => { const messages = useCurrentRoomStore(state => state.messages); const sendMessage = useCurrentRoomStore(state => state.sendMessage); @@ -15,6 +15,8 @@ export const Chat = () => { const [isInitialLoad, setIsInitialLoad] = useState(true); const [isFetching, setIsFetching] = useState(false); const [isMyMessage, setIsMyMessage] = useState(false); + const [showScrollButton, setShowScrollButton] = useState(false); + const [isAtBottom, setIsAtBottom] = useState(false); // 최초 메시지 로딩 시 스크롤 이동 useEffect(() => { @@ -34,15 +36,28 @@ export const Chat = () => { chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight - prevScrollHeightRef.current; setIsFetching(false); - } else if (isMyMessage) { + } else if (isMyMessage || isAtBottom) { chatContainerRef.current.scrollTo({ top: chatContainerRef.current.scrollHeight, behavior: 'smooth', }); setIsMyMessage(false); + setIsAtBottom(true); + } else { + // 새로운 메시지가 온 경우, 스크롤 버튼 띄우기 } }, [messages]); + const handleScrollToBottom = () => { + if (!chatContainerRef.current) return; + + chatContainerRef.current.scrollTo({ + top: chatContainerRef.current.scrollHeight, + behavior: 'smooth', + }); + setShowScrollButton(false); // 버튼 클릭 시 버튼 숨기기 + }; + // 메시지 추가 const addMessage = useCallback( (message: string) => { @@ -56,7 +71,25 @@ export const Chat = () => { const handleScroll = () => { if (!chatContainerRef.current) return; - prevScrollHeightRef.current = chatContainerRef.current.scrollTop; + const currentScrollTop = chatContainerRef.current.scrollTop; + const scrollHeight = chatContainerRef.current.scrollHeight; + const clientHeight = chatContainerRef.current.clientHeight; + + // 바텀에서 적당히 떨어진 거리 (예: 50px) + const threshold = 120; + + prevScrollHeightRef.current = currentScrollTop; + const isAtBottom = scrollHeight - clientHeight === currentScrollTop; + + if (isAtBottom) { + setShowScrollButton(false); + setIsAtBottom(true); + } else { + if (scrollHeight - currentScrollTop - clientHeight > threshold) { + setShowScrollButton(true); + } + setIsAtBottom(false); + } if (chatContainerRef.current.scrollTop === 0) { prevScrollHeightRef.current = chatContainerRef.current.scrollHeight; console.log('fetchMessages'); @@ -73,6 +106,11 @@ export const Chat = () => { ))} addMessage(msg)} /> + {showScrollButton && ( + + ArrowDown + + )} ); }; From 73307b4e526448550f907895fbba74a0d7838c0c Mon Sep 17 00:00:00 2001 From: 42inshin Date: Tue, 18 Feb 2025 23:33:18 +0900 Subject: [PATCH 03/12] =?UTF-8?q?[FE]=20FEAT:=20=EC=83=88=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20counter=20=EC=B6=94=EA=B0=80=20#193?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/Sidebar/Chating/index.css.ts | 15 +++++++++++++++ .../src/components/Sidebar/Chating/index.tsx | 5 ++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/components/Sidebar/Chating/index.css.ts b/src/frontend/src/components/Sidebar/Chating/index.css.ts index a69b658e..6bde1e99 100644 --- a/src/frontend/src/components/Sidebar/Chating/index.css.ts +++ b/src/frontend/src/components/Sidebar/Chating/index.css.ts @@ -115,4 +115,19 @@ export const ScrollButton = styled.button` cursor: pointer; width: 40px; height: 40px; + + & > span { + position: absolute; + right: -0.5rem; + top: -0.5rem; + background-color: var(--palette-accent-redOrange); + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + color: #fff; + font-weight: 500; + display: flex; + justify-content: center; + align-items: center; + } `; diff --git a/src/frontend/src/components/Sidebar/Chating/index.tsx b/src/frontend/src/components/Sidebar/Chating/index.tsx index ed425621..0540aee3 100644 --- a/src/frontend/src/components/Sidebar/Chating/index.tsx +++ b/src/frontend/src/components/Sidebar/Chating/index.tsx @@ -17,6 +17,7 @@ export const Chat = () => { const [isMyMessage, setIsMyMessage] = useState(false); const [showScrollButton, setShowScrollButton] = useState(false); const [isAtBottom, setIsAtBottom] = useState(false); + const [newMessageCount, setnewMessageCount] = useState(0); // 최초 메시지 로딩 시 스크롤 이동 useEffect(() => { @@ -45,6 +46,7 @@ export const Chat = () => { setIsAtBottom(true); } else { // 새로운 메시지가 온 경우, 스크롤 버튼 띄우기 + setnewMessageCount(prev => prev + 1); } }, [messages]); @@ -55,7 +57,6 @@ export const Chat = () => { top: chatContainerRef.current.scrollHeight, behavior: 'smooth', }); - setShowScrollButton(false); // 버튼 클릭 시 버튼 숨기기 }; // 메시지 추가 @@ -84,6 +85,7 @@ export const Chat = () => { if (isAtBottom) { setShowScrollButton(false); setIsAtBottom(true); + setnewMessageCount(0); } else { if (scrollHeight - currentScrollTop - clientHeight > threshold) { setShowScrollButton(true); @@ -109,6 +111,7 @@ export const Chat = () => { {showScrollButton && ( ArrowDown + {newMessageCount != 0 && {newMessageCount}} )} From f9b87f2e7a2b9a34fecc1870054fff206fa5c6e7 Mon Sep 17 00:00:00 2001 From: 42inshin Date: Wed, 19 Feb 2025 09:07:55 +0900 Subject: [PATCH 04/12] =?UTF-8?q?[FE]=20FEAT:=20=EC=B5=9C=EC=B4=88?= =?UTF-8?q?=EC=9D=98=20=EB=A9=94=EC=8B=9C=EC=A7=80=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?=EB=8F=84=EB=8B=AC=ED=95=9C=20=EA=B2=BD=EC=9A=B0=20fetch=20?= =?UTF-8?q?=EB=A7=89=EA=B8=B0=20#193?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/Sidebar/Chating/index.tsx | 14 ++++++++------ src/frontend/src/stores/useCurrentRoomStore.ts | 4 +++- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/frontend/src/components/Sidebar/Chating/index.tsx b/src/frontend/src/components/Sidebar/Chating/index.tsx index 0540aee3..f2ebb8ab 100644 --- a/src/frontend/src/components/Sidebar/Chating/index.tsx +++ b/src/frontend/src/components/Sidebar/Chating/index.tsx @@ -18,6 +18,7 @@ export const Chat = () => { const [showScrollButton, setShowScrollButton] = useState(false); const [isAtBottom, setIsAtBottom] = useState(false); const [newMessageCount, setnewMessageCount] = useState(0); + const [hasMoreMessages, setHasMoreMessages] = useState(true); // 최초 메시지 로딩 시 스크롤 이동 useEffect(() => { @@ -69,7 +70,7 @@ export const Chat = () => { [sendMessage], ); - const handleScroll = () => { + const handleScroll = async () => { if (!chatContainerRef.current) return; const currentScrollTop = chatContainerRef.current.scrollTop; @@ -77,7 +78,7 @@ export const Chat = () => { const clientHeight = chatContainerRef.current.clientHeight; // 바텀에서 적당히 떨어진 거리 (예: 50px) - const threshold = 120; + const bottomThreshold = 120; prevScrollHeightRef.current = currentScrollTop; const isAtBottom = scrollHeight - clientHeight === currentScrollTop; @@ -87,16 +88,17 @@ export const Chat = () => { setIsAtBottom(true); setnewMessageCount(0); } else { - if (scrollHeight - currentScrollTop - clientHeight > threshold) { + if (scrollHeight - currentScrollTop - clientHeight > bottomThreshold) { setShowScrollButton(true); } setIsAtBottom(false); } - if (chatContainerRef.current.scrollTop === 0) { - prevScrollHeightRef.current = chatContainerRef.current.scrollHeight; + if (currentScrollTop == 0 && hasMoreMessages) { + prevScrollHeightRef.current = scrollHeight; console.log('fetchMessages'); setIsFetching(true); - fetchMessages(useCurrentRoomStore.getState().messages[0]?.timestamp); + const count = await fetchMessages(useCurrentRoomStore.getState().messages[0]?.timestamp); + if (count == 0) setHasMoreMessages(false); } }; diff --git a/src/frontend/src/stores/useCurrentRoomStore.ts b/src/frontend/src/stores/useCurrentRoomStore.ts index 68b67491..d0430e0b 100644 --- a/src/frontend/src/stores/useCurrentRoomStore.ts +++ b/src/frontend/src/stores/useCurrentRoomStore.ts @@ -19,7 +19,7 @@ interface CurrentRoomStore { messageQueue: ReceiveMessageDto[]; sendMessage: (message: string) => void; subscribeChat: () => void; - fetchMessages: (cursor?: number, limit?: number) => void; + fetchMessages: (cursor?: number, limit?: number) => number; addMessage: (message: ReceiveMessageDto) => void; setCurrentRoom: (room: CurrentRoomDto) => void; clearCurrentRoom: () => void; @@ -81,6 +81,8 @@ export const useCurrentRoomStore = create((set, get) => ({ const messageHistory = await roomApi.getMessages(roomId, cursor, limit ?? 30); const sortedMessages = messageHistory.sort((a, b) => a.timestamp - b.timestamp); set({ messages: [...sortedMessages, ...get().messages] }); + + return messageHistory.length; }, sendMessage: (message: string) => { From 0b3f5d3de941e65652b2684cfa1adcea5aeab602 Mon Sep 17 00:00:00 2001 From: 42inshin Date: Wed, 19 Feb 2025 09:17:35 +0900 Subject: [PATCH 05/12] =?UTF-8?q?[FE]=20FIX:=20date=20format=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=ED=98=95=EC=8B=9D=20=EB=B3=80=EA=B2=BD=20#193?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/utils/dateUtils.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/utils/dateUtils.ts b/src/frontend/src/utils/dateUtils.ts index 44e1187d..151304ab 100644 --- a/src/frontend/src/utils/dateUtils.ts +++ b/src/frontend/src/utils/dateUtils.ts @@ -1,16 +1,20 @@ export const formatDateToKorean = (timestamp: string | number | Date): string => { const date = new Date(timestamp); const today = new Date(); + if (date.toDateString() === today.toDateString()) { return date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false }); } else { - return date.toLocaleString('ko-KR', { - year: 'numeric', - month: '2-digit', - day: '2-digit', + const isCurrentYear = date.getFullYear() === today.getFullYear(); + const formattedDate = isCurrentYear + ? `${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getDate().toString().padStart(2, '0')}` + : `${date.getFullYear()}.${(date.getMonth() + 1).toString().padStart(2, '0')}.${date.getDate().toString().padStart(2, '0')}`; + + const formattedTime = date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false, }); + return `${formattedDate} ${formattedTime}`; } }; From abb60404de9429c1a405faca9b3c902aeaa19334 Mon Sep 17 00:00:00 2001 From: 42inshin Date: Wed, 19 Feb 2025 09:53:59 +0900 Subject: [PATCH 06/12] =?UTF-8?q?[FE]=20FIX:=20CI=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EC=88=98=EC=A0=95=20#193?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/components/Sidebar/Chating/index.tsx | 1 - src/frontend/src/stores/useCurrentRoomStore.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/frontend/src/components/Sidebar/Chating/index.tsx b/src/frontend/src/components/Sidebar/Chating/index.tsx index f2ebb8ab..5a00ea1c 100644 --- a/src/frontend/src/components/Sidebar/Chating/index.tsx +++ b/src/frontend/src/components/Sidebar/Chating/index.tsx @@ -95,7 +95,6 @@ export const Chat = () => { } if (currentScrollTop == 0 && hasMoreMessages) { prevScrollHeightRef.current = scrollHeight; - console.log('fetchMessages'); setIsFetching(true); const count = await fetchMessages(useCurrentRoomStore.getState().messages[0]?.timestamp); if (count == 0) setHasMoreMessages(false); diff --git a/src/frontend/src/stores/useCurrentRoomStore.ts b/src/frontend/src/stores/useCurrentRoomStore.ts index d0430e0b..8f26edea 100644 --- a/src/frontend/src/stores/useCurrentRoomStore.ts +++ b/src/frontend/src/stores/useCurrentRoomStore.ts @@ -19,7 +19,7 @@ interface CurrentRoomStore { messageQueue: ReceiveMessageDto[]; sendMessage: (message: string) => void; subscribeChat: () => void; - fetchMessages: (cursor?: number, limit?: number) => number; + fetchMessages: (cursor?: number, limit?: number) => Promise; addMessage: (message: ReceiveMessageDto) => void; setCurrentRoom: (room: CurrentRoomDto) => void; clearCurrentRoom: () => void; From 0765ccfe912ec07bb7b26ae831323fa572d3b4f2 Mon Sep 17 00:00:00 2001 From: 42inshin Date: Wed, 19 Feb 2025 16:03:17 +0900 Subject: [PATCH 07/12] =?UTF-8?q?[FE]=20FIX:=20=EB=B0=A9=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=20=EB=B0=A9=20=EC=84=A4=EB=AA=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20#193?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/frontend/src/components/Modal/RoomCreateModal/index.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/components/Modal/RoomCreateModal/index.tsx b/src/frontend/src/components/Modal/RoomCreateModal/index.tsx index ddfbd838..f0207bfc 100644 --- a/src/frontend/src/components/Modal/RoomCreateModal/index.tsx +++ b/src/frontend/src/components/Modal/RoomCreateModal/index.tsx @@ -24,6 +24,7 @@ interface IRoomCreateModal { export const RoomCreateModal = ({ onCancel }: IRoomCreateModal) => { const titleRef = useRef(null); + const descriptionRef = useRef(null); const [isPublic, setIsPublic] = useState(true); const [titleLength, setTitleLength] = useState(0); const createRoom = useCreateRoom(); @@ -50,7 +51,7 @@ export const RoomCreateModal = ({ onCancel }: IRoomCreateModal) => { createRoom.mutate({ title: titleRef.current?.value || '', - description: '', + description: descriptionRef.current?.value || '', isPublic: isPublic, }); onCancel(); @@ -81,7 +82,7 @@ export const RoomCreateModal = ({ onCancel }: IRoomCreateModal) => { required /> {`${titleLength} / 60`} -