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/service/feature/chat/api/chatAPI.ts b/src/service/feature/chat/api/chatAPI.ts index 03f7f61..22f9a91 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,9 @@ 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 : []; + return Array.isArray(res.data.data) ? res.data.data : []; }; export const deleteMessage = async (messageId: string) => { 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/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/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/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 8181c2a..f920950 100644 --- a/src/view/pages/chat/ChatPage.tsx +++ b/src/view/pages/chat/ChatPage.tsx @@ -1,21 +1,33 @@ 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"; - -const MY_ID = "tests"; +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 currentUser = useMemo(() => { + return teamData?.teamMembers?.find( + (member: { memberInfo: { id: string }; }) => member.memberInfo.id === myInfo?.userId + )?.memberInfo; + }, [teamData, myInfo]); + useEffect(() => { setLocalMessages([]); }, [channelId]); @@ -26,20 +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 => { @@ -49,79 +61,92 @@ export function ChatPage() { try { return await postImage(formData); } catch (error) { - console.error("이미지 업로드 실패:", error); + toast.error("이미지 업로드 실패:"); throw error; } }; - 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: MY_ID, - name: "tester", - 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); } - - 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가 유효하지 않습니다.
; - 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 f2079f2..e13a9c4 100644 --- a/src/view/pages/chat/components/layout/ChatInput.tsx +++ b/src/view/pages/chat/components/layout/ChatInput.tsx @@ -1,99 +1,117 @@ -import { useState, useRef } from 'react'; -import { ImageIcon } from 'lucide-react'; +import React, { useState, useEffect, useRef } from 'react'; +import { ChannelMember } from '@service/feature/channel/types/channel.ts'; interface ChatInputProps { - onSend: (text: string, files?: File[]) => void; + onSend: (text: string, mentions: string[]) => void; + users: ChannelMember[]; } -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 [mentionMap, setMentionMap] = useState>(new Map()); + const dropdownRef = useRef(null); - 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) || user.nickname.toLowerCase().includes(query) + ); + setMentionList(filteredUsers); + setShowMentionList(true); + } else { + setMentionList([]); + setShowMentionList(true); + } + } 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: 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); + }; - validFiles.forEach(file => { - const reader = new FileReader(); - reader.onload = (e) => { - setPreviewUrls(prev => [...prev, e.target?.result as string]); - }; - reader.readAsDataURL(file); - }); + const handleOutsideClick = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setShowMentionList(false); + } }; - const removeImage = (index: number) => { - setSelectedFiles(prev => prev.filter((_, i) => i !== index)); - setPreviewUrls(prev => prev.filter((_, i) => i !== index)); + 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]) + .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 ( -
- {previewUrls.length > 0 && ( -
- {previewUrls.map((url, index) => ( -
- preview - -
- ))} -
- )} -
- - +
+ setText(e.target.value)} + onChange={handleInputChange} placeholder="메시지 입력..." - className="flex-1 bg-chat rounded px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" + className="flex-1 bg-sidebar rounded px-4 py-2 focus:outline-none" /> - -
- + + + {showMentionList && ( +
+ {mentionList.length > 0 ? ( + mentionList.map(user => ( +
addMention(user)} + > + {user.nickname} + {user.name} ({user.nickname}) +
+ )) + ) : ( +
+ 멘션 가능한 멤버가 없습니다. +
+ )} +
+ )} +
); }; \ No newline at end of file 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 7e815d1..73e521a 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); @@ -32,7 +34,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..b35aaa9 100644 --- a/src/view/pages/chat/components/message/ChatMessageItem.tsx +++ b/src/view/pages/chat/components/message/ChatMessageItem.tsx @@ -10,24 +10,49 @@ 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} + + ); + } + if (part === '@everyone') { + return ( + + {part} + + ); + } + return part; + }); +}; + +export const ChatMessageItem = ({ msg, isMine, showMeta, mentions }: Props) => { const renderAttachment = (attachment: { type: string; url: string }) => { if (attachment.type === 'image') { return ( @@ -48,9 +73,7 @@ export const ChatMessageItem = ({ msg, isMine, showMeta }: Props) => { className="flex items-center gap-2 p-2 bg-gray-700 rounded-md hover:bg-gray-600 transition-colors" > - - 첨부 파일 다운로드 - + 첨부 파일 다운로드 ); }; @@ -62,7 +85,9 @@ export const ChatMessageItem = ({ msg, isMine, showMeta }: Props) => { src={msg.sender.avatarUrl || fallbackIcon} alt={msg.sender.name} className="w-10 h-10 rounded-full shrink-0" - onError={(e) => { e.currentTarget.src = fallbackIcon; }} + onError={e => { + e.currentTarget.src = fallbackIcon; + }} /> )}
@@ -71,23 +96,23 @@ export const ChatMessageItem = ({ msg, isMine, showMeta }: Props) => { {msg.sender.name} - - {dayjs(msg.createdAt).fromNow()} - + {dayjs(msg.createdAt).fromNow()}
)} - -
- {msg.content && ( -

{msg.content}

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

{parseMentions(msg.content, mentions)}

+ )} + {msg.attachments && ( +
+ {msg.attachments.map((attachment, index) => ( +
{renderAttachment(attachment)}
+ ))} +
+ )} +
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