From df25cf58f61b2caa6d618a1d2b6829ca6d942504 Mon Sep 17 00:00:00 2001 From: chloe_choi Date: Fri, 20 Jun 2025 15:35:13 +0900 Subject: [PATCH 1/7] =?UTF-8?q?FLOW-35:=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EC=97=B0=EA=B2=B0,=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/service/feature/chat/api/chatAPI.ts | 6 +- src/service/feature/chat/hook/useChat.ts | 64 +++++++++----- .../feature/chat/hook/useMessageHistory.ts | 6 +- src/service/feature/chat/message.ts | 26 ++++++ .../feature/chat/schema/messageSchema.ts | 10 +-- src/view/pages/chat/ChatPage.tsx | 86 +++++++++++-------- .../pages/chat/components/layout/ChatView.tsx | 46 ++++------ .../components/message/ChatMessageItem.tsx | 3 +- 8 files changed, 152 insertions(+), 95 deletions(-) create mode 100644 src/service/feature/chat/message.ts diff --git a/src/service/feature/chat/api/chatAPI.ts b/src/service/feature/chat/api/chatAPI.ts index 924b676..03f7f61 100644 --- a/src/service/feature/chat/api/chatAPI.ts +++ b/src/service/feature/chat/api/chatAPI.ts @@ -7,9 +7,9 @@ export const fetchChannels = async () => { return res.data; }; -export const fetchMessages = async (channelId: string) => { - const res = await axios.get(`/channels/${channelId}/messages`); - return res.data; +export const fetchLatestMessages = async (channelId: string | undefined) => { + const res = await axios.get(`/message/latest?chatId=${channelId}`); + return Array.isArray(res.data) ? res.data : []; }; export const deleteMessage = async (messageId: string) => { diff --git a/src/service/feature/chat/hook/useChat.ts b/src/service/feature/chat/hook/useChat.ts index 96d819d..d411be0 100644 --- a/src/service/feature/chat/hook/useChat.ts +++ b/src/service/feature/chat/hook/useChat.ts @@ -1,36 +1,58 @@ -import { useEffect } from 'react'; +import { useEffect, useCallback } from 'react'; import { useSocket } from '../context/useSocket'; import { ChatMessage } from '@service/feature/chat/schema/messageSchema.ts'; -export const useChat = (onMessage: (msg: ChatMessage) => void) => { +export const useChat = (chatId: string | undefined, onMessage: (msg: ChatMessage) => void) => { const { client, isConnected } = useSocket(); - const chatId = '25ffc7bf-874f-444e-b331-26ed864a76ba'; useEffect(() => { - if (!client || !isConnected) return; + let subscription: any; + + const setupSubscription = () => { + if (!client || !isConnected) return; + + try { + const subscribeUrl = `/sub/message/${chatId}`; + subscription = client.subscribe(subscribeUrl, (message) => { + const parsed: ChatMessage = JSON.parse(message.body); + onMessage(parsed); + }); + } catch (error) { + console.error('STOMP 구독 중 오류 발생:', error); + } + }; - const subscribeUrl = `/sub/message/${chatId}`; - const subscription = client.subscribe(subscribeUrl, (message) => { - const parsed: ChatMessage = JSON.parse(message.body); - onMessage(parsed); - }); + setupSubscription(); return () => { - subscription.unsubscribe(); + if (subscription) { + try { + subscription.unsubscribe(); + } catch (error) { + console.error('구독 해제 중 오류 발생:', error); + } + } }; }, [client, isConnected, onMessage]); - const sendMessage = (content: string, attachments?: { type: string; url: string }[]) => { - if (!client || !isConnected) return; + const sendMessage = useCallback((content: string, attachments?: { type: string; url: string }[]) => { + if (!client || !isConnected) { + console.warn('메시지를 보낼 수 없습니다: STOMP 연결이 없습니다'); + return; + } const sendUrl = `/pub/message/${chatId}`; - const message = { chatId, content, attachments, createdAt: new Date().toISOString()}; - - client.publish({ - destination: sendUrl, - body: JSON.stringify(message), - }); - }; - - return { sendMessage }; + const message = { chatId, content, attachments, createdAt: new Date().toISOString() }; + + try { + client.publish({ + destination: sendUrl, + body: JSON.stringify(message), + }); + } catch (error) { + console.error('메시지 전송 중 오류 발생:', error); + } + }, [client, isConnected, chatId]); + + return { sendMessage, isConnected }; }; \ No newline at end of file diff --git a/src/service/feature/chat/hook/useMessageHistory.ts b/src/service/feature/chat/hook/useMessageHistory.ts index 0845ea7..529fc08 100644 --- a/src/service/feature/chat/hook/useMessageHistory.ts +++ b/src/service/feature/chat/hook/useMessageHistory.ts @@ -1,10 +1,10 @@ import { useQuery } from '@tanstack/react-query'; -import { fetchMessages } from '../api/chatAPI'; +import { fetchLatestMessages } from '../api/chatAPI'; -export const useMessageHistory = (channelId: string) => { +export const useMessageHistory = (channelId: string | undefined) => { return useQuery({ queryKey: ['messages', channelId], - queryFn: () => fetchMessages(channelId), + queryFn: () => fetchLatestMessages(channelId), enabled: !!channelId, }); }; \ No newline at end of file diff --git a/src/service/feature/chat/message.ts b/src/service/feature/chat/message.ts new file mode 100644 index 0000000..a889f19 --- /dev/null +++ b/src/service/feature/chat/message.ts @@ -0,0 +1,26 @@ +// "messageId": 13, +// "sender": { +// "memberId": "fc810ff3-a156-410c-80db-939440507dc3", +// "name": "최승은", +// "avatarUrl": "" +// }, +// "content": "ccc", +// "createdAt": "2025-06-03T22:34:07.542118", +// "isUpdated": false, +// "isDeleted": false, +// "attachments": [] +// }, + +export interface Message { + messageId : number; + sender: { + memberId: string; + name: string; + avatarUrl: string; + }, + content: string, + createdAt: string, + isUpdated: boolean, + isDeleted: boolean, + attachment:[] +} \ No newline at end of file diff --git a/src/service/feature/chat/schema/messageSchema.ts b/src/service/feature/chat/schema/messageSchema.ts index a9f7481..a315f35 100644 --- a/src/service/feature/chat/schema/messageSchema.ts +++ b/src/service/feature/chat/schema/messageSchema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; const senderSchema = z.object({ memberId: z.string(), - username: z.string(), + name: z.string(), avatarUrl: z.string(), }); @@ -12,13 +12,13 @@ const attachmentSchema = z.object({ }); export const messageSchema = z.object({ - chatId: z.string(), + messageId: z.number(), sender: senderSchema, content: z.string().min(1, '메시지를 입력해주세요'), + createdAt: z.string().datetime({ message: '올바른 날짜/시간 형식이 아닙니다' }), + isUpdated: z.boolean(), + isDeleted: z.boolean(), attachments: z.array(attachmentSchema).optional(), - createdAt: z - .string() - .datetime({ message: '올바른 날짜/시간 형식이 아닙니다' }), }); export type ChatMessage = z.infer; \ No newline at end of file diff --git a/src/view/pages/chat/ChatPage.tsx b/src/view/pages/chat/ChatPage.tsx index 68715f3..04499a6 100644 --- a/src/view/pages/chat/ChatPage.tsx +++ b/src/view/pages/chat/ChatPage.tsx @@ -1,67 +1,85 @@ -import { useState } from 'react'; -import { useChat } from '@service/feature/chat/hook/useChat.ts'; -import { ChatMessage } from '@service/feature/chat/schema/messageSchema.ts'; -import { ChannelHeader } from './components/layout/ChannelHeader'; -import { ChatInput } from '@pages/chat/components/layout/ChatInput.tsx'; -import { ChatView } from '@pages/chat/components/layout/ChatView.tsx'; +import { useMessageHistory } from "@service/feature/chat"; +import { useChat } from "@service/feature/chat/hook/useChat.ts"; +import { ChatMessage } from "@service/feature/chat/schema/messageSchema.ts"; +import { useState, useCallback, useEffect } from "react"; +import { ChannelHeader } from "./components/layout/ChannelHeader"; +import { ChatInput } from "@pages/chat/components/layout/ChatInput.tsx"; +import { ChatView } from "@pages/chat/components/layout/ChatView.tsx"; +import { postImage } from "@service/feature/image/imageApi.ts"; +import {useParams} from "react-router-dom"; -const CHAT_ID = '25ffc7bf-874f-444e-b331-26ed864a76ba'; -const MY_ID = 'tester'; +const MY_ID = "tests"; export function ChatPage() { - const [messages, setMessages] = useState([]); + const { channelId } = useParams<{ channelId: string }>(); + const { data: messagesData = [], isLoading, error } = useMessageHistory(channelId); + const messages = Array.isArray(messagesData) ? messagesData : []; + const [localMessages, setLocalMessages] = useState(messages); - const { sendMessage } = useChat((msg) => { - setMessages((prev) => [...prev, msg]); - }); + useEffect(() => { + console.log('channelId:', channelId); + console.log('messagesData:', messagesData); + console.log('isLoading:', isLoading); + console.log('error:', error); + }, [channelId, messagesData, isLoading, error]); + + useEffect(() => { + if (Array.isArray(messages)) { + setLocalMessages(messages); + } + }, [messages]); + + const handleNewMessage = useCallback((msg: ChatMessage) => { + setLocalMessages((prev) => [...prev, msg]); + }, []); + + const { sendMessage } = useChat(channelId, handleNewMessage); const uploadImage = async (file: File): Promise => { const formData = new FormData(); - formData.append('file', file); + formData.append("file", file); try { - const response = await fetch('/api/upload', { - method: 'POST', - body: formData, - }); - const data = await response.json(); - return data.url; + return await postImage(formData); } catch (error) { - console.error('이미지 업로드 실패:', error); + console.error("이미지 업로드 실패:", error); throw error; } }; const handleSend = async (text: string, files?: File[]) => { - let imageUrls: string[] = []; + let imageUrls: string[] = []; - if (files && files.length > 0) { - const uploadPromises = files.map(file => uploadImage(file)); - imageUrls = await Promise.all(uploadPromises); - } + if (files && files.length > 0) { + const uploadPromises = files.map((file) => uploadImage(file)); + imageUrls = await Promise.all(uploadPromises); + } - const msg: ChatMessage = { - chatId: CHAT_ID, + const msg: Omit = { sender: { memberId: MY_ID, - username: 'tester', - avatarUrl: '', + name: "tester", + avatarUrl: "", }, content: text, createdAt: new Date().toISOString(), + isUpdated: false, + isDeleted: false, attachments: - imageUrls.length > 0 - ? imageUrls.map((url) => ({ type: 'image', url })) - : undefined, + imageUrls.length > 0 + ? imageUrls.map((url) => ({ type: "image" as const, url })) + : [], }; - sendMessage(msg.content, msg.attachments); }; + if (isLoading) return
로딩 중...
; + if (error) return
에러 발생: {error.message}
; + return (
- +
); diff --git a/src/view/pages/chat/components/layout/ChatView.tsx b/src/view/pages/chat/components/layout/ChatView.tsx index 41380e3..7e815d1 100644 --- a/src/view/pages/chat/components/layout/ChatView.tsx +++ b/src/view/pages/chat/components/layout/ChatView.tsx @@ -3,10 +3,7 @@ import { ChatMessage } from '@service/feature/chat/schema/messageSchema.ts'; import { DateDivider } from '@pages/chat/components/message/DateDivider.tsx'; import { ChatMessageItem } from '@pages/chat/components/message/ChatMessageItem.tsx'; -export const ChatView = ({ - messages, - myId, -}: { +export const ChatView = ({messages = [], myId }: { messages: ChatMessage[]; myId: string; }) => { @@ -16,37 +13,30 @@ export const ChatView = ({ bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); + const messageList = Array.isArray(messages) ? messages : []; + const shouldShowDateDivider = (currentMsg: ChatMessage, prevMsg?: ChatMessage) => { if (!prevMsg) return true; - const currentDate = new Date(currentMsg.createdAt); const prevDate = new Date(prevMsg.createdAt); - return currentDate.toDateString() !== prevDate.toDateString(); }; return ( -
- {messages.map((msg, index) => { - const prev = messages[index - 1]; - const isSameSender = prev?.sender === msg.sender; - const showMeta = !isSameSender || shouldShowDateDivider(msg, prev); +
+ {messageList.map((msg, index) => { + const prev = messageList[index - 1]; + const isSameSender = prev?.sender?.memberId === msg.sender?.memberId; + const showMeta = !isSameSender || shouldShowDateDivider(msg, prev); - return ( - <> - {shouldShowDateDivider(msg, prev) && ( - - )} - - - ); - })} -
-
+ return ( +
+ {shouldShowDateDivider(msg, prev) && ()} + +
+ ); + })} +
+
); -}; \ No newline at end of file +}; diff --git a/src/view/pages/chat/components/message/ChatMessageItem.tsx b/src/view/pages/chat/components/message/ChatMessageItem.tsx index a0147c1..c051574 100644 --- a/src/view/pages/chat/components/message/ChatMessageItem.tsx +++ b/src/view/pages/chat/components/message/ChatMessageItem.tsx @@ -1,6 +1,7 @@ import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; import { FileIcon } from 'lucide-react'; +import fallbackIcon from '@assets/img/logo/chatflow.png'; import { ChatMessage } from '@service/feature/chat/schema/messageSchema.ts'; dayjs.extend(relativeTime); @@ -43,7 +44,7 @@ export const ChatMessageItem = ({ msg, isMine, showMeta }: Props) => {
{!isMine && showMeta && ( {msg.sender.username} From d351844680f92ed5c19436438faf2d9fa446a679 Mon Sep 17 00:00:00 2001 From: chloe_choi Date: Fri, 20 Jun 2025 16:26:20 +0900 Subject: [PATCH 2/7] =?UTF-8?q?FLOW-35:=20=EC=84=9C=EB=B2=84=20=EC=B1=84?= =?UTF-8?q?=EB=84=90=20=EC=82=AC=EC=9D=B4=EB=93=9C=20=EB=B0=94=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../channel/hook/query/useChannelQuery.ts | 10 +--- src/service/feature/channel/types/channel.ts | 56 +++++++++---------- .../components/channel/ChannelCategory.tsx | 6 +- .../components/channel/ChannelItem.tsx | 2 +- .../components/channel/InviteFriendModal.tsx | 7 ++- .../components/channel/ServerChannelList.tsx | 30 ++++------ .../components/channel/SidebarLayout.tsx | 4 +- src/view/pages/chat/ChatPage.tsx | 26 +++------ 8 files changed, 59 insertions(+), 82 deletions(-) diff --git a/src/service/feature/channel/hook/query/useChannelQuery.ts b/src/service/feature/channel/hook/query/useChannelQuery.ts index 2a3299f..3b99034 100644 --- a/src/service/feature/channel/hook/query/useChannelQuery.ts +++ b/src/service/feature/channel/hook/query/useChannelQuery.ts @@ -1,13 +1,9 @@ import { useQuery } from '@tanstack/react-query'; -import { - getChannelList, - getDMList, -} from '@service/feature/channel/api/channelAPI.ts'; -import { Channel } from '@service/feature/channel/types/channel.ts'; +import { getChannelList, getDMList } from '@service/feature/channel/api/channelAPI.ts'; +import {ChannelResponse} from '@service/feature/channel/types/channel.ts'; -// response 형식이 다름. 임시로 설정하여 사용중 export const useChannelListQuery = (serverId: string) => { - return useQuery({ + return useQuery({ queryKey: ['serverChannels', serverId], queryFn: () => getChannelList(serverId), enabled: !!serverId, diff --git a/src/service/feature/channel/types/channel.ts b/src/service/feature/channel/types/channel.ts index 785f346..ad8492e 100644 --- a/src/service/feature/channel/types/channel.ts +++ b/src/service/feature/channel/types/channel.ts @@ -1,19 +1,11 @@ export type ChannelType = 'text' | 'voice' | 'event'; -// export interface Channel { -// id: string; -// name: string; -// type: ChannelType; -// category: string; -// [key: string]: unknown; -// } - export interface DMDetail { - channel: Channel2; + channel: Channel; channelMembers: ChannelMember[]; } -export interface DMList extends Channel2 { +export interface DMList extends Channel { channelMembers: ChannelMember[]; } @@ -26,8 +18,7 @@ export interface ChannelMember { createdAt: string; } -// 팀 서버 상세 조회에서 불러오는 channel 타입도 이것. 추후 아래 Channel 타입에서 이걸로 변경해야 할 듯 -export interface Channel2 { +export interface Channel { id: number; name: string; position: number; @@ -36,29 +27,36 @@ export interface Channel2 { chatId: string; } -export interface Channel { - categoriesView: CategoriesView[]; - team: Team; - teamMembers: TeamMembers[]; -} - -export interface CategoriesView { +export interface CategoryView { category: { id: number; name: string; position: number; }; -} + channels: Channel[]; -export interface Team { - id: string; - name: string; - masterId: string; - iconUrl: string; } -export interface TeamMembers { - id: number; - role: 'OWNER' | 'MEMBER'; - memberInfo: ChannelMember; +export interface ChannelResponse { + team: { + id: string; + name: string; + masterId: string; + iconUrl: string; + }; + categoriesView: CategoryView[]; + teamMembers: { + id: number; + role: 'OWNER' | 'MEMBER'; + memberInfo: ChannelMember; + }[]; } + +export interface ChannelMember { + id: string; + nickname: string; + name: string; + avatarUrl: string; + state: 'ONLINE' | 'OFFLINE'; + createdAt: string; +} \ No newline at end of file diff --git a/src/view/layout/sidebar/components/channel/ChannelCategory.tsx b/src/view/layout/sidebar/components/channel/ChannelCategory.tsx index 4e8283b..d1a54b5 100644 --- a/src/view/layout/sidebar/components/channel/ChannelCategory.tsx +++ b/src/view/layout/sidebar/components/channel/ChannelCategory.tsx @@ -9,11 +9,7 @@ import { ChevronDown, ChevronRight, Plus } from 'lucide-react'; import ChannelItem from './ChannelItem.tsx'; import { Channel } from '@service/feature/channel/types/channel.ts'; -const ChannelCategory = ({ - title, - type, - defaultItems, - }: { +const ChannelCategory = ({title, type, defaultItems,}: { title: string; type: 'text' | 'voice' | 'event'; defaultItems: Channel[]; diff --git a/src/view/layout/sidebar/components/channel/ChannelItem.tsx b/src/view/layout/sidebar/components/channel/ChannelItem.tsx index 92cf477..e2e3485 100644 --- a/src/view/layout/sidebar/components/channel/ChannelItem.tsx +++ b/src/view/layout/sidebar/components/channel/ChannelItem.tsx @@ -3,7 +3,7 @@ import { Hash, Radio, Volume2 } from 'lucide-react'; import { clsx } from 'clsx'; const ChannelItem = ({ id, name, type = 'text', selected = false, }: { - id: string; + id: number; name: string; type?: 'text' | 'voice' | 'event'; selected?: boolean; diff --git a/src/view/layout/sidebar/components/channel/InviteFriendModal.tsx b/src/view/layout/sidebar/components/channel/InviteFriendModal.tsx index 73c80f2..6a07f48 100644 --- a/src/view/layout/sidebar/components/channel/InviteFriendModal.tsx +++ b/src/view/layout/sidebar/components/channel/InviteFriendModal.tsx @@ -1,4 +1,5 @@ import Item from './Item'; +import { v4 as uuidv4 } from 'uuid'; import SearchFriends from '@pages/Friends/components/SearchFriends'; import { useEffect, useMemo, useState } from 'react'; import Modal from '@components/common/Modal'; @@ -45,7 +46,9 @@ const InviteFriendModal = ({ return ( - + @@ -62,7 +65,7 @@ const InviteFriendModal = ({
{searchData?.map((member) => ( - + ))}
diff --git a/src/view/layout/sidebar/components/channel/ServerChannelList.tsx b/src/view/layout/sidebar/components/channel/ServerChannelList.tsx index 22d07ee..6198933 100644 --- a/src/view/layout/sidebar/components/channel/ServerChannelList.tsx +++ b/src/view/layout/sidebar/components/channel/ServerChannelList.tsx @@ -1,38 +1,30 @@ import { useParams } from 'react-router-dom'; -import ChannelCategory from './ChannelCategory.tsx'; import { useChannelListQuery } from '@service/feature/channel/hook/query/useChannelQuery.ts'; -import { Channel } from '@service/feature/channel/types/channel.ts'; import InviteFriendModal from './InviteFriendModal.tsx'; +import ChannelCategory from "./ChannelCategory.tsx"; const ServerChannelList = () => { const { serverId } = useParams<{ serverId: string }>(); const { data: channels, isLoading, error } = useChannelListQuery(serverId!); - console.log('channels: ', channels); - if (isLoading) return
Loading...
; if (error) return
에러 발생
; - - // const categories = - // channels?.reduce((acc: Record, channel: Channel) => { - // if (!acc[channel.category]) acc[channel.category] = []; - // acc[channel.category].push(channel); - // return acc; - // }, {}) ?? {}; + + if (!channels?.categoriesView) return null; return ( -
- {/* {Object.entries(categories).map(([categoryName, categoryChannels]) => ( +
+ {channels.categoriesView.map((categoryView) => ( - ))} */} - + ))} +
); }; -export default ServerChannelList; +export default ServerChannelList; \ No newline at end of file diff --git a/src/view/layout/sidebar/components/channel/SidebarLayout.tsx b/src/view/layout/sidebar/components/channel/SidebarLayout.tsx index 07a1536..db6a77b 100644 --- a/src/view/layout/sidebar/components/channel/SidebarLayout.tsx +++ b/src/view/layout/sidebar/components/channel/SidebarLayout.tsx @@ -1,6 +1,6 @@ export const SidebarLayout = ({ children }: { children: React.ReactNode }) => ( -
-
+
+
{children}
diff --git a/src/view/pages/chat/ChatPage.tsx b/src/view/pages/chat/ChatPage.tsx index 04499a6..9ba28e4 100644 --- a/src/view/pages/chat/ChatPage.tsx +++ b/src/view/pages/chat/ChatPage.tsx @@ -6,28 +6,20 @@ import { ChannelHeader } from "./components/layout/ChannelHeader"; import { ChatInput } from "@pages/chat/components/layout/ChatInput.tsx"; import { ChatView } from "@pages/chat/components/layout/ChatView.tsx"; import { postImage } from "@service/feature/image/imageApi.ts"; -import {useParams} from "react-router-dom"; +import { useParams } from "react-router-dom"; const MY_ID = "tests"; export function ChatPage() { const { channelId } = useParams<{ channelId: string }>(); const { data: messagesData = [], isLoading, error } = useMessageHistory(channelId); - const messages = Array.isArray(messagesData) ? messagesData : []; - const [localMessages, setLocalMessages] = useState(messages); + const [localMessages, setLocalMessages] = useState([]); useEffect(() => { - console.log('channelId:', channelId); - console.log('messagesData:', messagesData); - console.log('isLoading:', isLoading); - console.log('error:', error); - }, [channelId, messagesData, isLoading, error]); - - useEffect(() => { - if (Array.isArray(messages)) { - setLocalMessages(messages); + if (Array.isArray(messagesData) && messagesData.length > 0) { + setLocalMessages(messagesData); } - }, [messages]); + }, [messagesData]); const handleNewMessage = useCallback((msg: ChatMessage) => { setLocalMessages((prev) => [...prev, msg]); @@ -66,9 +58,9 @@ export function ChatPage() { isUpdated: false, isDeleted: false, attachments: - imageUrls.length > 0 - ? imageUrls.map((url) => ({ type: "image" as const, url })) - : [], + imageUrls.length > 0 + ? imageUrls.map((url) => ({ type: "image" as const, url })) + : [], }; sendMessage(msg.content, msg.attachments); }; @@ -79,7 +71,7 @@ export function ChatPage() { return (
- +
); From c8f11d8fb9ab0c231373e78c54c7dd409756c227 Mon Sep 17 00:00:00 2001 From: chloe_choi Date: Fri, 20 Jun 2025 16:45:56 +0900 Subject: [PATCH 3/7] =?UTF-8?q?FLOW-35:=20=EC=84=9C=EB=B2=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=A7=88=EB=8B=A4=20=EC=B1=84=EB=84=90=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=A4=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/channel/ChannelCategory.tsx | 7 +-- .../components/channel/ChannelItem.tsx | 50 +++++++++++-------- .../components/channel/ServerChannelList.tsx | 18 +++++-- 3 files changed, 49 insertions(+), 26 deletions(-) diff --git a/src/view/layout/sidebar/components/channel/ChannelCategory.tsx b/src/view/layout/sidebar/components/channel/ChannelCategory.tsx index d1a54b5..2f96c2d 100644 --- a/src/view/layout/sidebar/components/channel/ChannelCategory.tsx +++ b/src/view/layout/sidebar/components/channel/ChannelCategory.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { DndContext } from '@dnd-kit/core'; +import {DndContext, DragEndEvent} from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy, @@ -17,9 +17,9 @@ const ChannelCategory = ({title, type, defaultItems,}: { const [isOpen, setIsOpen] = useState(true); const [items, setItems] = useState(defaultItems); - const handleDragEnd = (event: any) => { + const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; - if (active.id !== over?.id) { + if (over && active.id !== over.id) { const oldIndex = items.findIndex((i) => i.id === active.id); const newIndex = items.findIndex((i) => i.id === over.id); setItems((items) => arrayMove(items, oldIndex, newIndex)); @@ -49,6 +49,7 @@ const ChannelCategory = ({title, type, defaultItems,}: { id={item.id} name={item.name} type={type} + chatId={item.chatId} /> ))}
diff --git a/src/view/layout/sidebar/components/channel/ChannelItem.tsx b/src/view/layout/sidebar/components/channel/ChannelItem.tsx index e2e3485..fcac574 100644 --- a/src/view/layout/sidebar/components/channel/ChannelItem.tsx +++ b/src/view/layout/sidebar/components/channel/ChannelItem.tsx @@ -1,31 +1,41 @@ -import { useDraggable } from '@dnd-kit/core'; -import { Hash, Radio, Volume2 } from 'lucide-react'; -import { clsx } from 'clsx'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Hash } from 'lucide-react'; -const ChannelItem = ({ id, name, type = 'text', selected = false, }: { +interface ChannelItemProps { id: number; name: string; type?: 'text' | 'voice' | 'event'; - selected?: boolean; -}) => { - const icon = - type === 'voice' ? : - type === 'event' ? : - ; + chatId: string; +} - const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id, }); +const ChannelItem = ({ id, name, chatId }: ChannelItemProps) => { + const navigate = useNavigate(); + const { serverId } = useParams<{ serverId: string }>(); + + const {attributes, listeners, setNodeRef, transform, transition,} = useSortable({ id }); - const style = transform - ? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, opacity: 0.8, } - : undefined; + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + const handleClick = () => { + navigate(`/channels/${serverId}/${chatId}`); + }; return ( -
- {icon} - {name} +
+
+ + {name} +
); }; diff --git a/src/view/layout/sidebar/components/channel/ServerChannelList.tsx b/src/view/layout/sidebar/components/channel/ServerChannelList.tsx index 6198933..94c1486 100644 --- a/src/view/layout/sidebar/components/channel/ServerChannelList.tsx +++ b/src/view/layout/sidebar/components/channel/ServerChannelList.tsx @@ -1,15 +1,27 @@ -import { useParams } from 'react-router-dom'; +import { useParams, useNavigate, useLocation } from 'react-router-dom'; +import { useEffect } from 'react'; import { useChannelListQuery } from '@service/feature/channel/hook/query/useChannelQuery.ts'; import InviteFriendModal from './InviteFriendModal.tsx'; import ChannelCategory from "./ChannelCategory.tsx"; const ServerChannelList = () => { - const { serverId } = useParams<{ serverId: string }>(); + const { serverId, channelId } = useParams<{ serverId: string; channelId: string }>(); + const navigate = useNavigate(); + useLocation(); const { data: channels, isLoading, error } = useChannelListQuery(serverId!); + useEffect(() => { + if (channels?.categoriesView && channels.categoriesView.length > 0 && !channelId) { + const firstCategory = channels.categoriesView[0]; + if (firstCategory.channels && firstCategory.channels.length > 0) { + const firstChannel = firstCategory.channels[0]; + navigate(`/channels/${serverId}/${firstChannel.chatId}`, { replace: true }); + } + } + }, [channels, serverId, channelId, navigate]); + if (isLoading) return
Loading...
; if (error) return
에러 발생
; - if (!channels?.categoriesView) return null; return ( From 12af127c2be41158743e6ea421718743ac42ef45 Mon Sep 17 00:00:00 2001 From: chloe_choi Date: Fri, 20 Jun 2025 17:04:31 +0900 Subject: [PATCH 4/7] =?UTF-8?q?FLOW-35:=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EB=82=99=EA=B4=80=EC=A0=81=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/service/feature/chat/hook/useChat.ts | 54 +++++++++++++------ .../feature/chat/hook/useMessageHistory.ts | 1 + .../feature/chat/schema/messageSchema.ts | 2 + .../feature/chat/{ => type}/message.ts | 6 ++- src/view/pages/chat/ChatPage.tsx | 53 +++++++++++++++++- src/view/pages/chat/components/ChatView.tsx | 37 ------------- .../components/message/ChatMessageItem.tsx | 22 ++++++-- 7 files changed, 116 insertions(+), 59 deletions(-) rename src/service/feature/chat/{ => type}/message.ts (78%) delete mode 100644 src/view/pages/chat/components/ChatView.tsx diff --git a/src/service/feature/chat/hook/useChat.ts b/src/service/feature/chat/hook/useChat.ts index d411be0..ddc0c0c 100644 --- a/src/service/feature/chat/hook/useChat.ts +++ b/src/service/feature/chat/hook/useChat.ts @@ -1,3 +1,4 @@ +import { v4 as uuidv4 } from 'uuid'; import { useEffect, useCallback } from 'react'; import { useSocket } from '../context/useSocket'; import { ChatMessage } from '@service/feature/chat/schema/messageSchema.ts'; @@ -9,14 +10,21 @@ export const useChat = (chatId: string | undefined, onMessage: (msg: ChatMessage let subscription: any; const setupSubscription = () => { - if (!client || !isConnected) return; + if (!client || !isConnected || !chatId) { + console.log('채팅 구독 조건이 충족되지 않음:', { client: !!client, isConnected, chatId }); + return; + } try { const subscribeUrl = `/sub/message/${chatId}`; + console.log('채팅 구독 시도:', subscribeUrl); + subscription = client.subscribe(subscribeUrl, (message) => { const parsed: ChatMessage = JSON.parse(message.body); onMessage(parsed); }); + + console.log('채팅 구독 성공'); } catch (error) { console.error('STOMP 구독 중 오류 발생:', error); } @@ -28,30 +36,46 @@ export const useChat = (chatId: string | undefined, onMessage: (msg: ChatMessage if (subscription) { try { subscription.unsubscribe(); + console.log('채팅 구독 해제'); } catch (error) { console.error('구독 해제 중 오류 발생:', error); } } }; - }, [client, isConnected, onMessage]); + }, [client, isConnected, chatId, onMessage]); - const sendMessage = useCallback((content: string, attachments?: { type: string; url: string }[]) => { - if (!client || !isConnected) { - console.warn('메시지를 보낼 수 없습니다: STOMP 연결이 없습니다'); - return; + const sendMessage = useCallback(async (content: string, attachments?: { type: string; url: string }[]) => { + if (!client || !isConnected || !chatId) { + console.warn('메시지를 보낼 수 없습니다:', { + client: !!client, + isConnected, + chatId + }); + return Promise.reject(new Error('연결 상태가 올바르지 않습니다.')); } + const tempId = uuidv4(); const sendUrl = `/pub/message/${chatId}`; - const message = { chatId, content, attachments, createdAt: new Date().toISOString() }; + const message = { + chatId, + content, + attachments, + createdAt: new Date().toISOString(), + tempId + }; - try { - client.publish({ - destination: sendUrl, - body: JSON.stringify(message), - }); - } catch (error) { - console.error('메시지 전송 중 오류 발생:', error); - } + return new Promise((resolve, reject) => { + try { + client.publish({ + destination: sendUrl, + body: JSON.stringify(message), + }); + resolve(tempId); + } catch (error) { + console.error('메시지 전송 중 오류 발생:', error); + reject(error); + } + }); }, [client, isConnected, chatId]); return { sendMessage, isConnected }; diff --git a/src/service/feature/chat/hook/useMessageHistory.ts b/src/service/feature/chat/hook/useMessageHistory.ts index 529fc08..e804dc6 100644 --- a/src/service/feature/chat/hook/useMessageHistory.ts +++ b/src/service/feature/chat/hook/useMessageHistory.ts @@ -6,5 +6,6 @@ export const useMessageHistory = (channelId: string | undefined) => { queryKey: ['messages', channelId], queryFn: () => fetchLatestMessages(channelId), enabled: !!channelId, + staleTime: 1000*30 }); }; \ No newline at end of file diff --git a/src/service/feature/chat/schema/messageSchema.ts b/src/service/feature/chat/schema/messageSchema.ts index a315f35..9a3e2f4 100644 --- a/src/service/feature/chat/schema/messageSchema.ts +++ b/src/service/feature/chat/schema/messageSchema.ts @@ -19,6 +19,8 @@ export const messageSchema = z.object({ isUpdated: z.boolean(), isDeleted: z.boolean(), attachments: z.array(attachmentSchema).optional(), + status: z.enum(['pending', 'sent', 'error']).optional(), + tempId: z.string().optional(), }); export type ChatMessage = z.infer; \ No newline at end of file diff --git a/src/service/feature/chat/message.ts b/src/service/feature/chat/type/message.ts similarity index 78% rename from src/service/feature/chat/message.ts rename to src/service/feature/chat/type/message.ts index a889f19..4dbd025 100644 --- a/src/service/feature/chat/message.ts +++ b/src/service/feature/chat/type/message.ts @@ -11,7 +11,7 @@ // "attachments": [] // }, -export interface Message { +export interface ChatMessage { messageId : number; sender: { memberId: string; @@ -22,5 +22,7 @@ export interface Message { createdAt: string, isUpdated: boolean, isDeleted: boolean, - attachment:[] + attachments?: { type: string; url: string }[]; + status?: 'pending' | 'sent' | 'error'; + tempId?: string; } \ No newline at end of file diff --git a/src/view/pages/chat/ChatPage.tsx b/src/view/pages/chat/ChatPage.tsx index 9ba28e4..8181c2a 100644 --- a/src/view/pages/chat/ChatPage.tsx +++ b/src/view/pages/chat/ChatPage.tsx @@ -7,6 +7,7 @@ import { ChatInput } from "@pages/chat/components/layout/ChatInput.tsx"; import { ChatView } from "@pages/chat/components/layout/ChatView.tsx"; import { postImage } from "@service/feature/image/imageApi.ts"; import { useParams } from "react-router-dom"; +import {v4 as uuidv4} from "uuid"; const MY_ID = "tests"; @@ -15,6 +16,10 @@ export function ChatPage() { const { data: messagesData = [], isLoading, error } = useMessageHistory(channelId); const [localMessages, setLocalMessages] = useState([]); + useEffect(() => { + setLocalMessages([]); + }, [channelId]); + useEffect(() => { if (Array.isArray(messagesData) && messagesData.length > 0) { setLocalMessages(messagesData); @@ -22,9 +27,19 @@ export function ChatPage() { }, [messagesData]); const handleNewMessage = useCallback((msg: ChatMessage) => { - setLocalMessages((prev) => [...prev, msg]); + setLocalMessages(prev => { + if (msg.tempId) { + return prev.map(m => + m.tempId === msg.tempId + ? { ...msg, status: 'sent' as const } + : m + ); + } + return [...prev, msg]; + }); }, []); + const { sendMessage } = useChat(channelId, handleNewMessage); const uploadImage = async (file: File): Promise => { @@ -47,7 +62,40 @@ export function ChatPage() { imageUrls = await Promise.all(uploadPromises); } - const msg: Omit = { + const tempMessage: ChatMessage = { + tempId: uuidv4(), + sender: { + memberId: MY_ID, + name: "tester", + avatarUrl: "", + }, + content: text, + createdAt: new Date().toISOString(), + isUpdated: false, + isDeleted: false, + status: 'pending', + attachments: imageUrls.length > 0 + ? imageUrls.map((url) => ({type: "image" as const, url})) + : [], + messageId: 0 + }; + + setLocalMessages(prev => [...prev, tempMessage]); + + try { + await sendMessage(text, tempMessage.attachments); + } catch (error) { + setLocalMessages(prev => + prev.map(msg => + msg.tempId === tempMessage.tempId + ? { ...msg, status: 'error' as const } + : msg + ) + ); + console.error('메시지 전송 실패:', error); + } + + const msg: Omit = { sender: { memberId: MY_ID, name: "tester", @@ -65,6 +113,7 @@ export function ChatPage() { sendMessage(msg.content, msg.attachments); }; + if (!channelId) return
채널 ID가 유효하지 않습니다.
; if (isLoading) return
로딩 중...
; if (error) return
에러 발생: {error.message}
; diff --git a/src/view/pages/chat/components/ChatView.tsx b/src/view/pages/chat/components/ChatView.tsx deleted file mode 100644 index 2d5bfc5..0000000 --- a/src/view/pages/chat/components/ChatView.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { ChatMessage } from '@service/feature/chat/schema/messageSchema.ts'; -import { ChatMessageItem } from '@pages/chat/components/message/ChatMessageItem.tsx'; - -export const ChatView = ({ - messages, - myId, - }: { - messages: ChatMessage[]; - myId: string; -}) => { - const bottomRef = useRef(null); - - useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages]); - - return ( -
- {messages.map((msg, index) => { - const prev = messages[index - 1]; - const isSameSender = prev?.sender === msg.sender; - const showMeta = !isSameSender; - - return ( - - ); - })} -
-
- ); -}; \ No newline at end of file diff --git a/src/view/pages/chat/components/message/ChatMessageItem.tsx b/src/view/pages/chat/components/message/ChatMessageItem.tsx index c051574..9ef86d9 100644 --- a/src/view/pages/chat/components/message/ChatMessageItem.tsx +++ b/src/view/pages/chat/components/message/ChatMessageItem.tsx @@ -2,7 +2,7 @@ import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; import { FileIcon } from 'lucide-react'; import fallbackIcon from '@assets/img/logo/chatflow.png'; -import { ChatMessage } from '@service/feature/chat/schema/messageSchema.ts'; +import { type ChatMessage } from '@service/feature/chat/schema/messageSchema.ts'; dayjs.extend(relativeTime); @@ -12,6 +12,21 @@ interface Props { showMeta: boolean; } +const MessageStatus = ({ status }: { status?: string }) => { + if (!status || status === 'sent') return null; + + return ( + + {status === 'pending' && ( + + )} + {status === 'error' && ( + ⚠️ + )} + + ); +}; + export const ChatMessageItem = ({ msg, isMine, showMeta }: Props) => { const renderAttachment = (attachment: { type: string; url: string }) => { if (attachment.type === 'image') { @@ -45,7 +60,7 @@ export const ChatMessageItem = ({ msg, isMine, showMeta }: Props) => { {!isMine && showMeta && ( {msg.sender.username} )} @@ -53,13 +68,14 @@ export const ChatMessageItem = ({ msg, isMine, showMeta }: Props) => { {showMeta && (
- {msg.sender.username} + {msg.sender.name} {dayjs(msg.createdAt).fromNow()}
)} +
{msg.content && (

{msg.content}

From 2cdf95547b320f507e3cb6830f5d5e16aa765ea9 Mon Sep 17 00:00:00 2001 From: Choi Seung-eun <138289674+xeunnie@users.noreply.github.com> Date: Fri, 20 Jun 2025 22:51:05 +0900 Subject: [PATCH 5/7] Update src/view/pages/chat/components/message/ChatMessageItem.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/view/pages/chat/components/message/ChatMessageItem.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/view/pages/chat/components/message/ChatMessageItem.tsx b/src/view/pages/chat/components/message/ChatMessageItem.tsx index 9ef86d9..b071784 100644 --- a/src/view/pages/chat/components/message/ChatMessageItem.tsx +++ b/src/view/pages/chat/components/message/ChatMessageItem.tsx @@ -59,9 +59,10 @@ export const ChatMessageItem = ({ msg, isMine, showMeta }: Props) => {
{!isMine && showMeta && ( {msg.sender.name} { e.currentTarget.src = fallbackIcon; }} /> )}
From 37c78790cbfe3555188f8d660f068ae0acd54859 Mon Sep 17 00:00:00 2001 From: Choi Seung-eun <138289674+xeunnie@users.noreply.github.com> Date: Fri, 20 Jun 2025 22:51:43 +0900 Subject: [PATCH 6/7] Update src/view/layout/sidebar/components/channel/ServerChannelList.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../layout/sidebar/components/channel/ServerChannelList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/layout/sidebar/components/channel/ServerChannelList.tsx b/src/view/layout/sidebar/components/channel/ServerChannelList.tsx index 94c1486..8f88e1a 100644 --- a/src/view/layout/sidebar/components/channel/ServerChannelList.tsx +++ b/src/view/layout/sidebar/components/channel/ServerChannelList.tsx @@ -7,7 +7,7 @@ import ChannelCategory from "./ChannelCategory.tsx"; const ServerChannelList = () => { const { serverId, channelId } = useParams<{ serverId: string; channelId: string }>(); const navigate = useNavigate(); - useLocation(); +// Line 10 removed const { data: channels, isLoading, error } = useChannelListQuery(serverId!); useEffect(() => { From 45b02bb21ebe70c1d35276221eecd362e65649c9 Mon Sep 17 00:00:00 2001 From: Choi Seung-eun <138289674+xeunnie@users.noreply.github.com> Date: Fri, 20 Jun 2025 22:51:50 +0900 Subject: [PATCH 7/7] Update src/view/layout/sidebar/components/channel/InviteFriendModal.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../layout/sidebar/components/channel/InviteFriendModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/layout/sidebar/components/channel/InviteFriendModal.tsx b/src/view/layout/sidebar/components/channel/InviteFriendModal.tsx index 6a07f48..b07af92 100644 --- a/src/view/layout/sidebar/components/channel/InviteFriendModal.tsx +++ b/src/view/layout/sidebar/components/channel/InviteFriendModal.tsx @@ -65,7 +65,7 @@ const InviteFriendModal = ({
{searchData?.map((member) => ( - + ))}