From 03bd958b08ffbad301599d5822fb0dd055155121 Mon Sep 17 00:00:00 2001 From: chloe_choi Date: Sat, 28 Jun 2025 22:48:58 +0900 Subject: [PATCH 1/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=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95,?= =?UTF-8?q?=20=EB=91=90=EB=B2=88=20=EC=A0=80=EC=9E=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/chat/context/SocketProvider.tsx | 29 +++++-------------- src/service/feature/chat/hook/useChat.ts | 3 -- .../feature/common/axios/axiosInstance.ts | 2 +- src/view/pages/chat/ChatPage.tsx | 17 ----------- 4 files changed, 8 insertions(+), 43 deletions(-) diff --git a/src/service/feature/chat/context/SocketProvider.tsx b/src/service/feature/chat/context/SocketProvider.tsx index 4e059f2..3e7ff23 100644 --- a/src/service/feature/chat/context/SocketProvider.tsx +++ b/src/service/feature/chat/context/SocketProvider.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { CompatClient, Stomp } from '@stomp/stompjs'; import SockJS from 'sockjs-client'; import { SocketContext } from './SocketContext'; - +import {getCookie} from "@service/feature/auth/lib/getCookie.ts"; type Props = { children: React.ReactNode; @@ -13,13 +13,11 @@ export const SocketProvider = ({ children }: Props) => { const clientRef = useRef(null); useEffect(() => { - // const token = getTokenFromCookie(); - // if (token === null) { - // console.error('토큰을 찾을 수 없습니다.'); - // return; - // } - - const token = 'eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJmYzgxMGZmMy1hMTU2LTQxMGMtODBkYi05Mzk0NDA1MDdkYzM6TUVNQkVSIiwiaXNzIjoiamVycnkwMzM5IiwiaWF0IjoxNzQ4NjE5MDY0LCJleHAiOjE3ODEwMTkwNjR9.IO6VIqIfoS1EosqkAMTDd8Xv34HhmTbtp-CDppDcidjnsOOIpMyjNTnHgDS-2RsmFLIWQzvZjBKd-rvdebxkBA'; + const token = getCookie("accessToken"); + if (!token) { + console.error('토큰을 찾을 수 없습니다.'); + return; + } const socket = new SockJS(`http://flowchat.shop:30100/ws/chat?token=${token}`); const stompClient = Stomp.over(socket); @@ -53,17 +51,4 @@ export const SocketProvider = ({ children }: Props) => { {children} ); -}; - -// function getTokenFromCookie(): string | null { -// const cookies = document.cookie; -// const cookieArray = cookies.split('; '); -// -// for (const cookie of cookieArray) { -// const [name, value] = cookie.split('='); -// if (name === 'accessToken' && value && value.startsWith('ey')) { -// return value; -// } -// } -// return null; -// } +}; \ No newline at end of file diff --git a/src/service/feature/chat/hook/useChat.ts b/src/service/feature/chat/hook/useChat.ts index ddc0c0c..c186fe9 100644 --- a/src/service/feature/chat/hook/useChat.ts +++ b/src/service/feature/chat/hook/useChat.ts @@ -57,11 +57,8 @@ export const useChat = (chatId: string | undefined, onMessage: (msg: ChatMessage const tempId = uuidv4(); const sendUrl = `/pub/message/${chatId}`; const message = { - chatId, content, attachments, - createdAt: new Date().toISOString(), - tempId }; return new Promise((resolve, reject) => { diff --git a/src/service/feature/common/axios/axiosInstance.ts b/src/service/feature/common/axios/axiosInstance.ts index a99037d..793e542 100644 --- a/src/service/feature/common/axios/axiosInstance.ts +++ b/src/service/feature/common/axios/axiosInstance.ts @@ -6,7 +6,7 @@ import { getCookie } from '../../auth/lib/getCookie'; export type ServiceType = 'members' | 'teams' | 'dialog'; const API_CONFIG = { - BASE_DOMAIN: 'https://flowchat.shop:30200', + BASE_DOMAIN: 'http://flowchat.shop:30100', HEADERS: { JSON: 'application/json', }, diff --git a/src/view/pages/chat/ChatPage.tsx b/src/view/pages/chat/ChatPage.tsx index 8181c2a..f2bbf68 100644 --- a/src/view/pages/chat/ChatPage.tsx +++ b/src/view/pages/chat/ChatPage.tsx @@ -94,23 +94,6 @@ export function ChatPage() { ); console.error('메시지 전송 실패:', error); } - - const msg: Omit = { - sender: { - memberId: MY_ID, - name: "tester", - avatarUrl: "", - }, - content: text, - createdAt: new Date().toISOString(), - isUpdated: false, - isDeleted: false, - attachments: - imageUrls.length > 0 - ? imageUrls.map((url) => ({ type: "image" as const, url })) - : [], - }; - sendMessage(msg.content, msg.attachments); }; if (!channelId) return
채널 ID가 유효하지 않습니다.
; From 22e80aa3b1385455df07924637f18408e28e9e59 Mon Sep 17 00:00:00 2001 From: chloe_choi Date: Tue, 8 Jul 2025 21:29:13 +0900 Subject: [PATCH 2/7] =?UTF-8?q?FLOW-35:=20=EB=A9=98=EC=85=98=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/service/feature/auth/types/user.ts | 5 + src/view/pages/chat/ChatPage.tsx | 25 ++-- .../chat/components/layout/ChatInput.tsx | 132 +++++++----------- .../pages/chat/components/layout/ChatView.tsx | 2 +- .../components/message/ChatMessageItem.tsx | 59 ++++---- .../chat/components/message/MentionText.tsx | 0 6 files changed, 96 insertions(+), 127 deletions(-) create mode 100644 src/service/feature/auth/types/user.ts delete mode 100644 src/view/pages/chat/components/message/MentionText.tsx diff --git a/src/service/feature/auth/types/user.ts b/src/service/feature/auth/types/user.ts new file mode 100644 index 0000000..e3fc222 --- /dev/null +++ b/src/service/feature/auth/types/user.ts @@ -0,0 +1,5 @@ +export interface User { + id: string; + name: string; + avatarUrl: string; +} \ No newline at end of file diff --git a/src/view/pages/chat/ChatPage.tsx b/src/view/pages/chat/ChatPage.tsx index f2bbf68..5c18097 100644 --- a/src/view/pages/chat/ChatPage.tsx +++ b/src/view/pages/chat/ChatPage.tsx @@ -8,13 +8,15 @@ 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"; +import {toast} from "sonner"; +import {useSelector} from "react-redux"; +import {RootState} from "../../../app/store.ts"; export function ChatPage() { const { channelId } = useParams<{ channelId: string }>(); const { data: messagesData = [], isLoading, error } = useMessageHistory(channelId); const [localMessages, setLocalMessages] = useState([]); + const userProfile = useSelector((state: RootState) => state.auth.profile); useEffect(() => { setLocalMessages([]); @@ -29,11 +31,7 @@ export function ChatPage() { const handleNewMessage = useCallback((msg: ChatMessage) => { setLocalMessages(prev => { if (msg.tempId) { - return prev.map(m => - m.tempId === msg.tempId - ? { ...msg, status: 'sent' as const } - : m - ); + return prev.map(m => m.tempId === msg.tempId ? { ...msg, status: 'sent' as const } : m); } return [...prev, msg]; }); @@ -49,7 +47,7 @@ export function ChatPage() { try { return await postImage(formData); } catch (error) { - console.error("이미지 업로드 실패:", error); + toast.error("이미지 업로드 실패:"); throw error; } }; @@ -65,9 +63,9 @@ export function ChatPage() { const tempMessage: ChatMessage = { tempId: uuidv4(), sender: { - memberId: MY_ID, - name: "tester", - avatarUrl: "", + memberId: userProfile.userId, + name: userProfile.nickname || userProfile.name, + avatarUrl: userProfile.avatarUrl || '', }, content: text, createdAt: new Date().toISOString(), @@ -103,8 +101,9 @@ export function ChatPage() { return (
- - + +
); } \ No newline at end of file diff --git a/src/view/pages/chat/components/layout/ChatInput.tsx b/src/view/pages/chat/components/layout/ChatInput.tsx index f2079f2..554c00a 100644 --- a/src/view/pages/chat/components/layout/ChatInput.tsx +++ b/src/view/pages/chat/components/layout/ChatInput.tsx @@ -1,99 +1,75 @@ -import { useState, useRef } from 'react'; -import { ImageIcon } from 'lucide-react'; +import React, { useState } from 'react'; +import { User } from '@service/feature/auth/types/user'; interface ChatInputProps { - onSend: (text: string, files?: File[]) => void; + onSend: (text: string, mentions: string[]) => void; + users: User[]; } -export const ChatInput = ({ onSend }: ChatInputProps) => { +export const ChatInput = ({ onSend, users }: ChatInputProps) => { const [text, setText] = useState(''); - const [previewUrls, setPreviewUrls] = useState([]); - const [selectedFiles, setSelectedFiles] = useState([]); - const fileInputRef = useRef(null); + const [mentionList, setMentionList] = useState([]); + const [showMentionList, setShowMentionList] = useState(false); - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if ((!text.trim() && selectedFiles.length === 0) || text.length > 2000) return; + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setText(value); - onSend(text, selectedFiles); - setText(''); - setSelectedFiles([]); - setPreviewUrls([]); + const mentionTriggerIndex = value.lastIndexOf('@'); + if (mentionTriggerIndex !== -1) { + const query = value.slice(mentionTriggerIndex + 1).toLowerCase(); + if (query.trim()) { + const filteredUsers = users.filter(user => + user.name.toLowerCase().includes(query) + ); + setMentionList(filteredUsers); + setShowMentionList(filteredUsers.length > 0); + } else { + setShowMentionList(false); + } + } else { + setShowMentionList(false); + } }; - const handleFileChange = (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []); - const validFiles = files.filter(file => file.type.startsWith('image/')); - - setSelectedFiles(prev => [...prev, ...validFiles]); + const addMention = (user: User) => { + const mentionTriggerIndex = text.lastIndexOf('@'); + const prefix = text.slice(0, mentionTriggerIndex); + const withMention = `${prefix}@${user.name} `; + setText(withMention); + setShowMentionList(false); + }; - validFiles.forEach(file => { - const reader = new FileReader(); - reader.onload = (e) => { - setPreviewUrls(prev => [...prev, e.target?.result as string]); - }; - reader.readAsDataURL(file); - }); + const extractMentions = (text: string): string[] => { + const mentionRegex = /@([^\s]+)/g; + const matches = [...text.matchAll(mentionRegex)]; + return matches.map(match => match[1]); }; - const removeImage = (index: number) => { - setSelectedFiles(prev => prev.filter((_, i) => i !== index)); - setPreviewUrls(prev => prev.filter((_, i) => i !== index)); + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!text.trim()) return; + const mentions = extractMentions(text); + onSend(text, mentions); + setText(''); }; return ( -
- {previewUrls.length > 0 && ( -
- {previewUrls.map((url, index) => ( -
- preview - +
+ + + + + {showMentionList && ( +
+ {mentionList.map(user => ( +
addMention(user)}> + {user.name} + {user.name}
))}
)} -
- - - setText(e.target.value)} - placeholder="메시지 입력..." - className="flex-1 bg-chat rounded px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" - /> - -
- +
); }; \ No newline at end of file diff --git a/src/view/pages/chat/components/layout/ChatView.tsx b/src/view/pages/chat/components/layout/ChatView.tsx index 7e815d1..e0ba0b4 100644 --- a/src/view/pages/chat/components/layout/ChatView.tsx +++ b/src/view/pages/chat/components/layout/ChatView.tsx @@ -32,7 +32,7 @@ export const ChatView = ({messages = [], myId }: { return (
{shouldShowDateDivider(msg, prev) && ()} - +
); })} diff --git a/src/view/pages/chat/components/message/ChatMessageItem.tsx b/src/view/pages/chat/components/message/ChatMessageItem.tsx index b071784..35c98d7 100644 --- a/src/view/pages/chat/components/message/ChatMessageItem.tsx +++ b/src/view/pages/chat/components/message/ChatMessageItem.tsx @@ -10,47 +10,43 @@ interface Props { msg: ChatMessage; isMine: boolean; showMeta: boolean; + mentions: string[]; } const MessageStatus = ({ status }: { status?: string }) => { if (!status || status === 'sent') return null; return ( - - {status === 'pending' && ( - - )} - {status === 'error' && ( - ⚠️ - )} + + {status === 'pending' && ()} + {status === 'error' && (⚠️)} ); }; -export const ChatMessageItem = ({ msg, isMine, showMeta }: Props) => { +const parseMentions = (text: string, mentions: string[]) => { + return text.split(/(\@[^\s]+)/g).map((part, index) => { + if (part.startsWith('@') && mentions.includes(part.slice(1))) { + return ( + {part} + ); + } + return part; + }); +}; + +export const ChatMessageItem = ({ msg, isMine, showMeta, mentions }: Props) => { const renderAttachment = (attachment: { type: string; url: string }) => { if (attachment.type === 'image') { return ( - 첨부 이미지 + 첨부 이미지 ); } return ( - + - - 첨부 파일 다운로드 - + 첨부 파일 다운로드 ); }; @@ -58,28 +54,21 @@ export const ChatMessageItem = ({ msg, isMine, showMeta }: Props) => { return (
{!isMine && showMeta && ( - {msg.sender.name} { e.currentTarget.src = fallbackIcon; }} + {msg.sender.name} {e.currentTarget.src = fallbackIcon;}} /> )}
{showMeta && (
- - {msg.sender.name} - - - {dayjs(msg.createdAt).fromNow()} - + {msg.sender.name} + {dayjs(msg.createdAt).fromNow()}
)}
{msg.content && ( -

{msg.content}

+

{parseMentions(msg.content, mentions)}

)} {msg.attachments && (
diff --git a/src/view/pages/chat/components/message/MentionText.tsx b/src/view/pages/chat/components/message/MentionText.tsx deleted file mode 100644 index e69de29..0000000 From f20d10b3fe48b198980fa77077f1ed9f7eb02e34 Mon Sep 17 00:00:00 2001 From: chloe_choi Date: Fri, 11 Jul 2025 17:09:22 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat(chat):=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0,=20=EC=A0=84=EC=86=A1?= =?UTF-8?q?=EC=A4=91,=20=EC=A0=84=EC=86=A1,=20=EC=B0=B8=EC=97=AC=20?= =?UTF-8?q?=EB=A9=A4=EB=B2=84=20=EB=A6=AC=EC=8A=BD=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EC=96=BC=EB=A1=9C=EA=B7=B8,=20=EB=A9=98=EC=85=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80(=EA=B0=81=EA=B0=81,=20everyone)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/service/feature/chat/api/chatAPI.ts | 6 +- .../feature/chat/hook/useMessageHistory.ts | 2 +- .../feature/chat/schema/messageSchema.ts | 1 + src/service/feature/chat/type/message.ts | 28 ----- src/service/feature/chat/type/messages.ts | 9 ++ src/service/feature/member/types/memberAPI.ts | 1 + .../feature/team/api/teamsServiceAPI.ts | 4 +- .../team/hook/query/useTeamServiceQuery.ts | 2 +- src/service/feature/team/types/team.ts | 18 +++ src/view/pages/chat/ChatPage.tsx | 115 ++++++++++++------ .../chat/components/layout/ChannelHeader.tsx | 42 +++++-- .../chat/components/layout/ChatInput.tsx | 76 +++++++++--- .../components/layout/ChatMemberDialog.tsx | 65 ++++++++++ .../pages/chat/components/layout/ChatView.tsx | 7 +- .../components/message/ChatMessageItem.tsx | 78 ++++++++---- 15 files changed, 333 insertions(+), 121 deletions(-) delete mode 100644 src/service/feature/chat/type/message.ts create mode 100644 src/service/feature/chat/type/messages.ts create mode 100644 src/view/pages/chat/components/layout/ChatMemberDialog.tsx diff --git a/src/service/feature/chat/api/chatAPI.ts b/src/service/feature/chat/api/chatAPI.ts index 03f7f61..8f2652d 100644 --- a/src/service/feature/chat/api/chatAPI.ts +++ b/src/service/feature/chat/api/chatAPI.ts @@ -1,4 +1,5 @@ import { createAxiosInstance } from '../../common/axios/axiosInstance'; +import { Chat } from '../type/messages.ts' const axios = createAxiosInstance(); @@ -7,9 +8,10 @@ export const fetchChannels = async () => { return res.data; }; -export const fetchLatestMessages = async (channelId: string | undefined) => { +export const fetchLatestMessages = async (channelId: string | undefined): Promise => { const res = await axios.get(`/message/latest?chatId=${channelId}`); - return Array.isArray(res.data) ? res.data : []; + console.log(res.data.data); + return Array.isArray(res.data.data) ? res.data.data : []; }; export const deleteMessage = async (messageId: string) => { diff --git a/src/service/feature/chat/hook/useMessageHistory.ts b/src/service/feature/chat/hook/useMessageHistory.ts index e804dc6..d35ab3f 100644 --- a/src/service/feature/chat/hook/useMessageHistory.ts +++ b/src/service/feature/chat/hook/useMessageHistory.ts @@ -6,6 +6,6 @@ export const useMessageHistory = (channelId: string | undefined) => { queryKey: ['messages', channelId], queryFn: () => fetchLatestMessages(channelId), enabled: !!channelId, - staleTime: 1000*30 + 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 9a3e2f4..c78a778 100644 --- a/src/service/feature/chat/schema/messageSchema.ts +++ b/src/service/feature/chat/schema/messageSchema.ts @@ -19,6 +19,7 @@ export const messageSchema = z.object({ isUpdated: z.boolean(), isDeleted: z.boolean(), attachments: z.array(attachmentSchema).optional(), + mentions: z.array(z.string()).optional(), status: z.enum(['pending', 'sent', 'error']).optional(), tempId: z.string().optional(), }); diff --git a/src/service/feature/chat/type/message.ts b/src/service/feature/chat/type/message.ts deleted file mode 100644 index 4dbd025..0000000 --- a/src/service/feature/chat/type/message.ts +++ /dev/null @@ -1,28 +0,0 @@ -// "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 ChatMessage { - messageId : number; - sender: { - memberId: string; - name: string; - avatarUrl: string; - }, - content: string, - createdAt: string, - isUpdated: boolean, - isDeleted: boolean, - attachments?: { type: string; url: string }[]; - status?: 'pending' | 'sent' | 'error'; - tempId?: string; -} \ No newline at end of file diff --git a/src/service/feature/chat/type/messages.ts b/src/service/feature/chat/type/messages.ts new file mode 100644 index 0000000..64309e9 --- /dev/null +++ b/src/service/feature/chat/type/messages.ts @@ -0,0 +1,9 @@ +export interface Chat { + messageId: number; + sender: Sender; + content: string; + createdAt: string; + isUpdated: boolean; + isDeleted: boolean; + attachments: any[]; +} diff --git a/src/service/feature/member/types/memberAPI.ts b/src/service/feature/member/types/memberAPI.ts index 89109f9..98fff08 100644 --- a/src/service/feature/member/types/memberAPI.ts +++ b/src/service/feature/member/types/memberAPI.ts @@ -14,6 +14,7 @@ export interface MemberInfo { nickname: string; avatarUrl?: string; birth?: string; + state?: MemberState; } export interface UpdateMemberStatusRequest { diff --git a/src/service/feature/team/api/teamsServiceAPI.ts b/src/service/feature/team/api/teamsServiceAPI.ts index 4cfba0f..76a0eba 100644 --- a/src/service/feature/team/api/teamsServiceAPI.ts +++ b/src/service/feature/team/api/teamsServiceAPI.ts @@ -21,9 +21,9 @@ export const getTeamList = async () => { return response.data; }; -export const getTeamById = async (teamId: string) => { +export const getTeamById = async (teamId: string | undefined) => { const response = await axios.get(`/teams/${teamId}`); - return response.data; + return response.data.data; }; export const deleteTeam = async (teamId: string) => { diff --git a/src/service/feature/team/hook/query/useTeamServiceQuery.ts b/src/service/feature/team/hook/query/useTeamServiceQuery.ts index 1349de7..bda91a8 100644 --- a/src/service/feature/team/hook/query/useTeamServiceQuery.ts +++ b/src/service/feature/team/hook/query/useTeamServiceQuery.ts @@ -13,7 +13,7 @@ export const useTeamListQuery = () => { }); }; -export const useTeamDetailQuery = (teamId: string) => { +export const useTeamDetailQuery = (teamId: string | undefined) => { return useQuery({ queryKey: ['teamDetail', teamId], queryFn: () => getTeamById(teamId), diff --git a/src/service/feature/team/types/team.ts b/src/service/feature/team/types/team.ts index ca41b1e..5605657 100644 --- a/src/service/feature/team/types/team.ts +++ b/src/service/feature/team/types/team.ts @@ -1,7 +1,25 @@ +import {Channel} from "@service/feature/channel/types/channel.ts"; +import {MemberInfo} from "@service/feature/member/types/memberAPI.ts"; + export interface Team { id: string; name: string; iconUrl?: string; masterId?: string; [key: string]: any; +} + +export interface CategoriesView { + category: { + id: number, + name: string, + position: number, + }, + channels: Channel[], +} + +export interface TeamMembers { + id: number, + role: "ADMIN", + memberInfo: MemberInfo, } \ No newline at end of file diff --git a/src/view/pages/chat/ChatPage.tsx b/src/view/pages/chat/ChatPage.tsx index 5c18097..f920950 100644 --- a/src/view/pages/chat/ChatPage.tsx +++ b/src/view/pages/chat/ChatPage.tsx @@ -1,22 +1,32 @@ 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 { useState, useCallback, useEffect, useMemo } 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"; -import {v4 as uuidv4} from "uuid"; -import {toast} from "sonner"; -import {useSelector} from "react-redux"; -import {RootState} from "../../../app/store.ts"; +import { v4 as uuidv4 } from "uuid"; +import { toast } from "sonner"; +import { useTeamDetailQuery } from "@service/feature/team/hook/query/useTeamServiceQuery.ts"; +import { useSelector } from "react-redux"; +import { ChannelMember } from "@service/feature/channel/types/channel.ts"; export function ChatPage() { - const { channelId } = useParams<{ channelId: string }>(); + const { serverId, channelId } = useParams<{ serverId: string; channelId: string }>(); + const { data: messagesData = [], isLoading, error } = useMessageHistory(channelId); + const { data: teamData, isLoading: isTeamLoading, error: teamError } = useTeamDetailQuery(serverId); + + const myInfo = useSelector((state: any) => state.auth.user); const [localMessages, setLocalMessages] = useState([]); - const userProfile = useSelector((state: RootState) => state.auth.profile); + + const currentUser = useMemo(() => { + return teamData?.teamMembers?.find( + (member: { memberInfo: { id: string }; }) => member.memberInfo.id === myInfo?.userId + )?.memberInfo; + }, [teamData, myInfo]); useEffect(() => { setLocalMessages([]); @@ -28,16 +38,20 @@ export function ChatPage() { } }, [messagesData]); + console.log(useMessageHistory(channelId)); + console.log(messagesData); + const handleNewMessage = useCallback((msg: ChatMessage) => { - setLocalMessages(prev => { + setLocalMessages((prev) => { if (msg.tempId) { - return prev.map(m => m.tempId === msg.tempId ? { ...msg, status: 'sent' as const } : m); + 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 => { @@ -52,58 +66,87 @@ export function ChatPage() { } }; - const handleSend = async (text: string, files?: File[]) => { - let imageUrls: string[] = []; + const handleSend = async (text: string, mentionsOrFiles?: string[] | File[]) => { + let mentionList: string[] = []; + let fileList: File[] = []; - if (files && files.length > 0) { - const uploadPromises = files.map((file) => uploadImage(file)); + if (Array.isArray(mentionsOrFiles)) { + if (typeof mentionsOrFiles[0] === "string") { + mentionList = mentionsOrFiles as string[]; + } else { + fileList = mentionsOrFiles as File[]; + } + } + + let imageUrls: string[] = []; + if (fileList.length > 0) { + const uploadPromises = fileList.map((file) => uploadImage(file)); imageUrls = await Promise.all(uploadPromises); } const tempMessage: ChatMessage = { tempId: uuidv4(), sender: { - memberId: userProfile.userId, - name: userProfile.nickname || userProfile.name, - avatarUrl: userProfile.avatarUrl || '', + memberId: currentUser?.id || "", + name: currentUser?.name || "알 수 없음", + avatarUrl: currentUser?.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})) + status: "pending", + attachments: + imageUrls.length > 0 + ? imageUrls.map((url) => ({ type: "image" as const, url })) : [], - messageId: 0 + mentions: mentionList, + messageId: 0, }; - setLocalMessages(prev => [...prev, tempMessage]); + 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 - ) + setLocalMessages((prev) => + prev.map((msg) => + msg.tempId === tempMessage.tempId + ? { ...msg, status: "error" as const } + : msg + ) ); - console.error('메시지 전송 실패:', error); + console.error("메시지 전송 실패:", error); } }; - if (!channelId) return
채널 ID가 유효하지 않습니다.
; - if (isLoading) return
로딩 중...
; - if (error) return
에러 발생: {error.message}
; + if (isLoading || isTeamLoading) return ( +
+
+ 로딩 중... +
+ ); + if (error || teamError) return ( +
+ + + + 에러 발생: {error?.message || teamError?.message} +
+ ); + + const teamIcon = teamData?.team?.iconUrl; + const teamName = teamData?.team?.name || "기본 서버 이름"; + const categories = teamData?.categoriesView || []; + const members = + teamData?.teamMembers?.map((member: { memberInfo: ChannelMember }) => member.memberInfo) || []; return (
- - - + + +
); } \ No newline at end of file diff --git a/src/view/pages/chat/components/layout/ChannelHeader.tsx b/src/view/pages/chat/components/layout/ChannelHeader.tsx index 229d107..5b9f73e 100644 --- a/src/view/pages/chat/components/layout/ChannelHeader.tsx +++ b/src/view/pages/chat/components/layout/ChannelHeader.tsx @@ -1,25 +1,45 @@ -import { Hash, Bell, Pin, Users, Info, Video, Search, } from 'lucide-react'; - -export const ChannelHeader = ({ - channelName = '일반', - }: { - channelName?: string; -}) => { - return ( +import {useState} from "react"; +import {useParams} from "react-router-dom"; +import {useTeamDetailQuery} from "@service/feature/team/hook/query/useTeamServiceQuery.ts"; +import { Bell, Pin, Users, Info, Video, Search, } from 'lucide-react'; +import {ChatMembersDialog} from "@pages/chat/components/layout/ChatMemberDialog.tsx"; + +interface ChannelHeaderProps { + channelName: string; + iconUrl?: string; +} + +export const ChannelHeader = ({channelName = '일반', iconUrl}: ChannelHeaderProps) => { + const { serverId, channelId } = useParams<{ serverId: string; channelId: string }>(); + const { data: teamData, isLoading: isTeamLoading } = useTeamDetailQuery(serverId); + + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const members = teamData?.teamMembers?.map((member: { memberInfo: ChannelMember }) => member.memberInfo) || []; + + const handleDialogOpen = () => setIsDialogOpen(true); + const handleDialogClose = () => setIsDialogOpen(false); + + if (isTeamLoading) return
로딩 중...
; + + return (
- - {channelName} + {iconUrl && ( + {`${channelName} + )} + {channelName}
- +
+
); }; \ No newline at end of file diff --git a/src/view/pages/chat/components/layout/ChatInput.tsx b/src/view/pages/chat/components/layout/ChatInput.tsx index 554c00a..e13a9c4 100644 --- a/src/view/pages/chat/components/layout/ChatInput.tsx +++ b/src/view/pages/chat/components/layout/ChatInput.tsx @@ -1,15 +1,17 @@ -import React, { useState } from 'react'; -import { User } from '@service/feature/auth/types/user'; +import React, { useState, useEffect, useRef } from 'react'; +import { ChannelMember } from '@service/feature/channel/types/channel.ts'; interface ChatInputProps { onSend: (text: string, mentions: string[]) => void; - users: User[]; + users: ChannelMember[]; } export const ChatInput = ({ onSend, users }: ChatInputProps) => { const [text, setText] = useState(''); - const [mentionList, setMentionList] = useState([]); + const [mentionList, setMentionList] = useState([]); const [showMentionList, setShowMentionList] = useState(false); + const [mentionMap, setMentionMap] = useState>(new Map()); + const dropdownRef = useRef(null); const handleInputChange = (e: React.ChangeEvent) => { const value = e.target.value; @@ -20,54 +22,94 @@ export const ChatInput = ({ onSend, users }: ChatInputProps) => { const query = value.slice(mentionTriggerIndex + 1).toLowerCase(); if (query.trim()) { const filteredUsers = users.filter(user => - user.name.toLowerCase().includes(query) + user.name.toLowerCase().includes(query) || user.nickname.toLowerCase().includes(query) ); setMentionList(filteredUsers); - setShowMentionList(filteredUsers.length > 0); + setShowMentionList(true); } else { - setShowMentionList(false); + setMentionList([]); + setShowMentionList(true); } } else { setShowMentionList(false); } }; - const addMention = (user: User) => { + const addMention = (user: ChannelMember) => { const mentionTriggerIndex = text.lastIndexOf('@'); const prefix = text.slice(0, mentionTriggerIndex); const withMention = `${prefix}@${user.name} `; setText(withMention); + setMentionMap(prev => new Map(prev).set(user.name, user.id)); setShowMentionList(false); }; + const handleOutsideClick = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setShowMentionList(false); + } + }; + + useEffect(() => { + document.addEventListener('mousedown', handleOutsideClick); + return () => document.removeEventListener('mousedown', handleOutsideClick); + }, []); + const extractMentions = (text: string): string[] => { const mentionRegex = /@([^\s]+)/g; const matches = [...text.matchAll(mentionRegex)]; - return matches.map(match => match[1]); + return matches + .map(match => match[1]) + .filter(mention => mentionMap.has(mention)) + .map(mention => mentionMap.get(mention)!); }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!text.trim()) return; + const mentions = extractMentions(text); onSend(text, mentions); setText(''); + setMentionMap(new Map()); }; return (
- - + +
+ {showMentionList && ( -
- {mentionList.map(user => ( -
addMention(user)}> - {user.name} - {user.name} +
+ {mentionList.length > 0 ? ( + mentionList.map(user => ( +
addMention(user)} + > + {user.nickname} + {user.name} ({user.nickname}) +
+ )) + ) : ( +
+ 멘션 가능한 멤버가 없습니다.
- ))} + )}
)}
diff --git a/src/view/pages/chat/components/layout/ChatMemberDialog.tsx b/src/view/pages/chat/components/layout/ChatMemberDialog.tsx new file mode 100644 index 0000000..80f717a --- /dev/null +++ b/src/view/pages/chat/components/layout/ChatMemberDialog.tsx @@ -0,0 +1,65 @@ +import React, { useState, useMemo } from 'react'; +import { ChannelMember } from '@service/feature/channel/types/channel.ts'; +import { X } from 'lucide-react'; +import fallbackImg from '@assets/img/chatflow.png' + +interface ChatMembersDialogProps { + isOpen: boolean; + onClose: () => void; + members: ChannelMember[]; +} + +export const ChatMembersDialog = ({ isOpen, onClose, members }: ChatMembersDialogProps) => { + const [searchTerm, setSearchTerm] = useState(''); + + const filteredMembers = useMemo(() => { + return members.filter(member => + member.name.toLowerCase().includes(searchTerm.toLowerCase()) || + member.nickname.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }, [searchTerm, members]); + + if (!isOpen) return null; + + return ( +
+
+

팀 멤버 목록

+ setSearchTerm(e.target.value)} + placeholder="멤버 검색" + className="w-full p-2 mb-4 text-sm rounded bg-gray-700 text-white focus:outline-none" + /> + +
+ {filteredMembers.length > 0 ? ( + filteredMembers.map((member) => ( +
+ {member.nickname} +
+

{member.name}

+

{member.nickname}

+
+
+ )) + ) : ( +

검색 결과가 없습니다.

+ )} +
+ + +
+
+ ); +}; \ No newline at end of file diff --git a/src/view/pages/chat/components/layout/ChatView.tsx b/src/view/pages/chat/components/layout/ChatView.tsx index e0ba0b4..ee295a3 100644 --- a/src/view/pages/chat/components/layout/ChatView.tsx +++ b/src/view/pages/chat/components/layout/ChatView.tsx @@ -1,11 +1,13 @@ import { useEffect, useRef } from 'react'; -import { ChatMessage } from '@service/feature/chat/schema/messageSchema.ts'; +import { Chat } from '@service/feature/chat/type/messages.ts' import { DateDivider } from '@pages/chat/components/message/DateDivider.tsx'; import { ChatMessageItem } from '@pages/chat/components/message/ChatMessageItem.tsx'; +import {CategoryView} from "@service/feature/channel/types/channel.ts"; export const ChatView = ({messages = [], myId }: { - messages: ChatMessage[]; + messages: Chat[]; myId: string; + categories: CategoryView }) => { const bottomRef = useRef(null); @@ -14,6 +16,7 @@ export const ChatView = ({messages = [], myId }: { }, [messages]); const messageList = Array.isArray(messages) ? messages : []; + console.log("messageList", messageList); const shouldShowDateDivider = (currentMsg: ChatMessage, prevMsg?: ChatMessage) => { if (!prevMsg) return true; diff --git a/src/view/pages/chat/components/message/ChatMessageItem.tsx b/src/view/pages/chat/components/message/ChatMessageItem.tsx index 35c98d7..b35aaa9 100644 --- a/src/view/pages/chat/components/message/ChatMessageItem.tsx +++ b/src/view/pages/chat/components/message/ChatMessageItem.tsx @@ -17,9 +17,17 @@ const MessageStatus = ({ status }: { status?: string }) => { if (!status || status === 'sent') return null; return ( - - {status === 'pending' && ()} - {status === 'error' && (⚠️)} + + {status === 'pending' && + + 전송중 + } + {status === 'error' && + + + + 전송 실패 + } ); }; @@ -28,7 +36,16 @@ const parseMentions = (text: string, mentions: string[]) => { return text.split(/(\@[^\s]+)/g).map((part, index) => { if (part.startsWith('@') && mentions.includes(part.slice(1))) { return ( - {part} + + {part} + + ); + } + if (part === '@everyone') { + return ( + + {part} + ); } return part; @@ -39,12 +56,22 @@ export const ChatMessageItem = ({ msg, isMine, showMeta, mentions }: Props) => { const renderAttachment = (attachment: { type: string; url: string }) => { if (attachment.type === 'image') { return ( - 첨부 이미지 + 첨부 이미지 ); } return ( - + 첨부 파일 다운로드 @@ -54,29 +81,38 @@ export const ChatMessageItem = ({ msg, isMine, showMeta, mentions }: Props) => { return (
{!isMine && showMeta && ( - {msg.sender.name} {e.currentTarget.src = fallbackIcon;}} + {msg.sender.name} { + e.currentTarget.src = fallbackIcon; + }} /> )}
{showMeta && (
- {msg.sender.name} + + {msg.sender.name} + {dayjs(msg.createdAt).fromNow()}
)} - -
- {msg.content && ( -

{parseMentions(msg.content, mentions)}

- )} - {msg.attachments && ( -
- {msg.attachments.map((attachment, index) => ( -
{renderAttachment(attachment)}
- ))} -
- )} +
+ +
+ {msg.content && ( +

{parseMentions(msg.content, mentions)}

+ )} + {msg.attachments && ( +
+ {msg.attachments.map((attachment, index) => ( +
{renderAttachment(attachment)}
+ ))} +
+ )} +
From 75451e25c469fa76d328484e94b4fc90a493c118 Mon Sep 17 00:00:00 2001 From: Choi Seung-eun <138289674+xeunnie@users.noreply.github.com> Date: Fri, 11 Jul 2025 22:08:44 +0900 Subject: [PATCH 4/7] Update src/view/pages/chat/components/layout/ChatView.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/view/pages/chat/components/layout/ChatView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/pages/chat/components/layout/ChatView.tsx b/src/view/pages/chat/components/layout/ChatView.tsx index ee295a3..82c424a 100644 --- a/src/view/pages/chat/components/layout/ChatView.tsx +++ b/src/view/pages/chat/components/layout/ChatView.tsx @@ -7,7 +7,7 @@ import {CategoryView} from "@service/feature/channel/types/channel.ts"; export const ChatView = ({messages = [], myId }: { messages: Chat[]; myId: string; - categories: CategoryView + categories: CategoryView[] }) => { const bottomRef = useRef(null); From c5a0e7c3e2cab2dcc4c619e03bbba9aa5ab81b35 Mon Sep 17 00:00:00 2001 From: Choi Seung-eun <138289674+xeunnie@users.noreply.github.com> Date: Fri, 11 Jul 2025 22:08:57 +0900 Subject: [PATCH 5/7] Update src/view/pages/chat/components/layout/ChatView.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/view/pages/chat/components/layout/ChatView.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/view/pages/chat/components/layout/ChatView.tsx b/src/view/pages/chat/components/layout/ChatView.tsx index 82c424a..167240c 100644 --- a/src/view/pages/chat/components/layout/ChatView.tsx +++ b/src/view/pages/chat/components/layout/ChatView.tsx @@ -16,7 +16,6 @@ export const ChatView = ({messages = [], myId }: { }, [messages]); const messageList = Array.isArray(messages) ? messages : []; - console.log("messageList", messageList); const shouldShowDateDivider = (currentMsg: ChatMessage, prevMsg?: ChatMessage) => { if (!prevMsg) return true; From ac7c4070cf26416f0a6be3ba9f311754ca810cde Mon Sep 17 00:00:00 2001 From: Choi Seung-eun <138289674+xeunnie@users.noreply.github.com> Date: Fri, 11 Jul 2025 22:09:19 +0900 Subject: [PATCH 6/7] Update src/service/feature/chat/api/chatAPI.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/service/feature/chat/api/chatAPI.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/service/feature/chat/api/chatAPI.ts b/src/service/feature/chat/api/chatAPI.ts index 8f2652d..22f9a91 100644 --- a/src/service/feature/chat/api/chatAPI.ts +++ b/src/service/feature/chat/api/chatAPI.ts @@ -10,7 +10,6 @@ export const fetchChannels = async () => { export const fetchLatestMessages = async (channelId: string | undefined): Promise => { const res = await axios.get(`/message/latest?chatId=${channelId}`); - console.log(res.data.data); return Array.isArray(res.data.data) ? res.data.data : []; }; From b1a97748527b1d20df3495d6afe1d8bb8cd23a19 Mon Sep 17 00:00:00 2001 From: Choi Seung-eun <138289674+xeunnie@users.noreply.github.com> Date: Fri, 11 Jul 2025 22:09:27 +0900 Subject: [PATCH 7/7] Update src/view/pages/chat/components/layout/ChatView.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/view/pages/chat/components/layout/ChatView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/pages/chat/components/layout/ChatView.tsx b/src/view/pages/chat/components/layout/ChatView.tsx index 167240c..73e521a 100644 --- a/src/view/pages/chat/components/layout/ChatView.tsx +++ b/src/view/pages/chat/components/layout/ChatView.tsx @@ -34,7 +34,7 @@ export const ChatView = ({messages = [], myId }: { return (
{shouldShowDateDivider(msg, prev) && ()} - +
); })}