From 2ada5fb707a22f52bbc3139f52f02c48f0b10599 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Thu, 16 Apr 2026 17:11:15 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EA=B3=BC=20=EC=83=81=EC=84=B8=20=ED=9D=90=EB=A6=84=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 6 +- src/apis/chat/queries.ts | 25 +- .../layout/Header/components/ChatHeader.tsx | 124 ++++----- .../Header/components/ChatSearchHeader.tsx | 18 -- src/components/layout/Header/headerConfig.ts | 2 +- src/pages/Chat/ChatRoom.tsx | 23 +- src/pages/Chat/ChatRoomInfo.tsx | 254 ++++++++++++++++++ src/pages/Chat/ChatSearch.tsx | 113 ++++---- .../Chat/components/ChatRoomListItem.tsx | 123 ++++++--- src/pages/Chat/hooks/useChat.ts | 14 +- src/pages/Chat/hooks/useChatMutations.ts | 2 +- src/pages/Chat/hooks/useChatRoomScroll.ts | 26 +- 12 files changed, 537 insertions(+), 193 deletions(-) delete mode 100644 src/components/layout/Header/components/ChatSearchHeader.tsx create mode 100644 src/pages/Chat/ChatRoomInfo.tsx diff --git a/src/App.tsx b/src/App.tsx index 2a029ce..e4093e0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ const ChatListPage = lazy(() => import('./pages/Chat')); const ChatSearch = lazy(() => import('./pages/Chat/ChatSearch')); const ChatAdd = lazy(() => import('./pages/Chat/AddChatRoom')); const ChatRoom = lazy(() => import('./pages/Chat/ChatRoom')); +const ChatRoomInfo = lazy(() => import('./pages/Chat/ChatRoomInfo')); const ApplicationPage = lazy(() => import('./pages/Club/Application')); const ApplyCompletePage = lazy(() => import('./pages/Club/Application/applyCompletePage')); const ClubFeePage = lazy(() => import('./pages/Club/Application/clubFeePage')); @@ -88,6 +89,8 @@ function App() { }> } /> } /> + } /> + } /> } /> } /> @@ -122,9 +125,8 @@ function App() { } /> } /> } /> - } /> - } /> } /> + } /> diff --git a/src/apis/chat/queries.ts b/src/apis/chat/queries.ts index ecd86e6..9283903 100644 --- a/src/apis/chat/queries.ts +++ b/src/apis/chat/queries.ts @@ -2,10 +2,17 @@ import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query'; import { getChatMessages, getChatRooms, getSearchChat, getInvitableFriends } from '@/apis/chat'; import type { ChatMessagesResponse, SortBy } from '@/apis/chat/entity'; +interface ChatMessagesPageParam { + page: number; + useMessageId: boolean; +} + export const chatQueryKeys = { all: ['chat'] as const, rooms: () => [...chatQueryKeys.all, 'rooms'] as const, - messages: (chatRoomId: number) => [...chatQueryKeys.all, 'messages', chatRoomId] as const, + messagesByRoom: (chatRoomId: number) => [...chatQueryKeys.all, 'messages', chatRoomId] as const, + messages: (chatRoomId: number, messageId?: number) => + [...chatQueryKeys.messagesByRoom(chatRoomId), messageId ?? 'latest'] as const, disabledMessages: () => [...chatQueryKeys.all, 'messages', 'disabled'] as const, search: (keyword: string) => [...chatQueryKeys.all, 'search', keyword], invite: (query: string, sortBy: SortBy) => [...chatQueryKeys.all, 'invite', query, sortBy], @@ -19,17 +26,23 @@ export const chatQueries = { }), messages: (chatRoomId?: number, messageId?: number, limit = 20) => infiniteQueryOptions({ - queryKey: chatRoomId ? chatQueryKeys.messages(chatRoomId) : chatQueryKeys.disabledMessages(), + queryKey: chatRoomId ? chatQueryKeys.messages(chatRoomId, messageId) : chatQueryKeys.disabledMessages(), queryFn: ({ pageParam }) => getChatMessages({ chatRoomId: chatRoomId!, - messageId: messageId, - page: pageParam, + messageId: pageParam.useMessageId ? messageId : undefined, + page: pageParam.page, limit, }), - initialPageParam: 1, + initialPageParam: { page: 1, useMessageId: Boolean(messageId) } satisfies ChatMessagesPageParam, getNextPageParam: (lastPage: ChatMessagesResponse) => - lastPage.currentPage < lastPage.totalPage ? lastPage.currentPage + 1 : undefined, + lastPage.currentPage < lastPage.totalPage + ? ({ page: lastPage.currentPage + 1, useMessageId: false } satisfies ChatMessagesPageParam) + : undefined, + getPreviousPageParam: (firstPage: ChatMessagesResponse) => + firstPage.currentPage > 1 + ? ({ page: firstPage.currentPage - 1, useMessageId: false } satisfies ChatMessagesPageParam) + : undefined, enabled: Boolean(chatRoomId), }), search: (keyword: string) => diff --git a/src/components/layout/Header/components/ChatHeader.tsx b/src/components/layout/Header/components/ChatHeader.tsx index 41dd624..147de4f 100644 --- a/src/components/layout/Header/components/ChatHeader.tsx +++ b/src/components/layout/Header/components/ChatHeader.tsx @@ -1,97 +1,83 @@ import type { Ref } from 'react'; -import { useParams } from 'react-router-dom'; +import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'; import ChevronLeftIcon from '@/assets/svg/chevron-left.svg'; import HamburgerIcon from '@/assets/svg/hamburger.svg'; +import ToggleSwitch from '@/components/common/ToggleSwitch'; import useChat from '@/pages/Chat/hooks/useChat'; import { isGroupChatType } from '@/pages/Chat/utils/chatType'; -import useBooleanState from '@/utils/hooks/useBooleanState'; +import { useApiErrorToast } from '@/utils/hooks/error/useApiErrorToast'; import { useSmartBack } from '@/utils/hooks/useSmartBack'; import { cn } from '@/utils/ts/cn'; function ChatHeader({ headerRef }: { headerRef?: Ref }) { const smartBack = useSmartBack(); + const navigate = useNavigate(); + const location = useLocation(); + const { pathname, state } = location; const { chatRoomId } = useParams(); const numericRoomId = Number(chatRoomId); + const isInfoPage = pathname.endsWith('/info'); + const showApiErrorToast = useApiErrorToast(); const { chatRoomList, clubMembers, toggleMute, isTogglingMute } = useChat(numericRoomId); - const { value: open, setTrue: openSidebar, setFalse: closeSidebar } = useBooleanState(); - const chatRoom = chatRoomList.rooms.find((room) => room.roomId === numericRoomId); const isGroup = isGroupChatType(chatRoom?.chatType); const isMuted = chatRoom?.isMuted ?? false; - return ( - <> -
-
- + const handleBack = () => { + if (isInfoPage && chatRoomId) { + navigate(`/chats/${chatRoomId}`, { state }); + return; + } -
- {chatRoom?.roomName ?? ''} - {isGroup && {clubMembers.length}} -
-
- - -
+ smartBack(); + }; -
-
e.stopPropagation()} - > -
- 알림 + const handleToggleMute = async () => { + try { + await toggleMute(numericRoomId); + } catch (error) { + showApiErrorToast(error, '알림 설정 변경에 실패했습니다.'); + } + }; - -
+ return ( +
+
+ - {isGroup && ( - <> -
참여자 {clubMembers.length}명
-
- {clubMembers.map((member) => ( -
- -
- {member.name} - {member.studentNumber} -
-
- ))} -
- - )} +
+ {chatRoom?.roomName ?? ''} + {isGroup && {clubMembers.length}}
- + + {isInfoPage ? ( + void handleToggleMute()} + disabled={isTogglingMute} + ariaLabel="채팅방 알림 설정" + layout="horizontal" + variant="manager" + className="shrink-0" + /> + ) : ( + + + + )} +
); } diff --git a/src/components/layout/Header/components/ChatSearchHeader.tsx b/src/components/layout/Header/components/ChatSearchHeader.tsx deleted file mode 100644 index d433552..0000000 --- a/src/components/layout/Header/components/ChatSearchHeader.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { Ref } from 'react'; -import BackTitleHeader from '@/components/layout/Header/components/BackTitleHeader'; - -interface ChatListHeaderProps { - title: string; - headerRef?: Ref; -} - -export default function ChatSearchHeader({ title, headerRef }: ChatListHeaderProps) { - return ( - - ); -} diff --git a/src/components/layout/Header/headerConfig.ts b/src/components/layout/Header/headerConfig.ts index f3bdb35..25d3dcb 100644 --- a/src/components/layout/Header/headerConfig.ts +++ b/src/components/layout/Header/headerConfig.ts @@ -36,7 +36,7 @@ export const HEADER_CONFIGS: HeaderConfig[] = [ }, { type: 'chat', - match: (pathname) => /^\/chats\/\d+$/.test(pathname), + match: (pathname) => /^\/chats\/\d+(?:\/info)?$/.test(pathname), }, { type: 'normal', diff --git a/src/pages/Chat/ChatRoom.tsx b/src/pages/Chat/ChatRoom.tsx index b98f78a..ad03e41 100644 --- a/src/pages/Chat/ChatRoom.tsx +++ b/src/pages/Chat/ChatRoom.tsx @@ -85,21 +85,32 @@ function ChatRoom() { const [searchParams] = useSearchParams(); const targetMessageId = searchParams.get('messageId') ? Number(searchParams.get('messageId')) : undefined; - const { sendMessage, chatMessages, fetchNextPage, hasNextPage, isFetchingNextPage, isSendingMessage } = useChat( - Number(chatRoomId), - targetMessageId - ); + const { + sendMessage, + chatMessages, + fetchNextPage, + fetchPreviousPage, + hasNextPage, + hasPreviousPage, + isFetchingNextPage, + isFetchingPreviousPage, + isSendingMessage, + } = useChat(Number(chatRoomId), targetMessageId); const [value, setValue] = useState(''); const textareaRef = useRef(null); const baseTextareaHeightRef = useRef(0); - const { scrollContainerRef, topRef, scrollToBottom } = useChatRoomScroll({ + const { bottomRef, scrollContainerRef, topRef, scrollToBottom } = useChatRoomScroll({ chatRoomId, chatMessagesLength: chatMessages.length, latestMessageId: chatMessages[0]?.messageId, + targetMessageId, fetchNextPage, + fetchPreviousPage, hasNextPage, + hasPreviousPage, isFetchingNextPage, + isFetchingPreviousPage, }); useViewportHeightLock(scrollContainerRef); @@ -192,6 +203,8 @@ function ChatRoom() {
); })} + +
void; + onCreateDirectChat: (member: ClubMember) => void; + onShowUnsupportedAction: () => void; +} + +function MemberRow({ + member, + isCurrentUser, + isActive, + canOpenActions, + showKickAction, + isCreatingChatRoom, + onToggle, + onCreateDirectChat, + onShowUnsupportedAction, +}: MemberRowProps) { + const actionWidthClassName = showKickAction ? 'w-34.25' : 'w-18.75'; + + return ( +
+ + + {!isCurrentUser && ( +
+ {showKickAction && ( + + )} + +
+ )} +
+ ); +} + +function ChatRoomInfo() { + const navigate = useNavigate(); + const { chatRoomId } = useParams(); + const numericRoomId = Number(chatRoomId); + + const currentUser = useAuthStore((state) => state.user); + const { showToast } = useToastContext(); + const showApiErrorToast = useApiErrorToast(); + const { chatRoomList, clubMembers, createChatRoom, isCreatingChatRoom, deleteChatRoom, isDeletingChatRoom } = + useChat(numericRoomId); + const [activeMemberId, setActiveMemberId] = useState(null); + const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); + + const chatRoom = chatRoomList.rooms.find((room) => room.roomId === numericRoomId); + const chatType = chatRoom?.chatType; + const isGroupChat = isGroupChatType(chatRoom?.chatType); + const isClubGroupChat = chatType === 'CLUB_GROUP'; + const isGeneralGroupChat = chatType === 'GROUP'; + const canLeaveRoom = isDirectChatType(chatType) || isGeneralGroupChat; + const currentClubMember = currentUser + ? clubMembers.find( + (member) => member.name === currentUser.name && member.studentNumber === currentUser.studentNumber + ) + : null; + const isCurrentClubExecutive = Boolean(currentClubMember && currentClubMember.position !== 'MEMBER'); + const canManageMembers = isClubGroupChat ? isCurrentClubExecutive : false; + + const handleToggleMemberAction = (userId: number) => { + setActiveMemberId((previous) => (previous === userId ? null : userId)); + }; + + const handleAddMember = () => { + showToast('인원 추가 기능은 준비 중입니다.', 'info'); + }; + + const handleShowUnsupportedAction = () => { + showToast('멤버 관리 기능은 아직 연결되지 않았습니다.', 'info'); + }; + + const handleCreateDirectChat = async (member: ClubMember) => { + try { + const response = await createChatRoom(member.userId); + navigate(`/chats/${response.chatRoomId}`); + } catch (error) { + showApiErrorToast(error, '1:1 채팅방 생성에 실패했습니다.'); + } + }; + + const handleLeaveRoom = async () => { + try { + await deleteChatRoom(numericRoomId); + navigate('/chats', { replace: true }); + } catch (error) { + showApiErrorToast(error, '채팅방 나가기에 실패했습니다.'); + } finally { + setIsLeaveModalOpen(false); + } + }; + + return ( + <> +
+
+
+
+
+
+ {isGroupChat ? `친구 (${clubMembers.length})` : '채팅방 정보'} +
+ + {isGroupChat && canManageMembers && ( + + )} + + {clubMembers.length > 0 ? ( +
+ {clubMembers.map((member) => { + const isCurrentUser = currentUser + ? member.name === currentUser.name && member.studentNumber === currentUser.studentNumber + : false; + const canOpenActions = isGroupChat && !isCurrentUser; + const showKickAction = canManageMembers; + + return ( + + ); + })} +
+ ) : ( +

+ {isGroupChat ? '표시할 친구가 아직 없어요.' : '이 채팅방은 참여자 목록을 지원하지 않아요.'} +

+ )} +
+
+ + {canLeaveRoom && ( + + )} +
+
+
+ + setIsLeaveModalOpen(false)} + className="rounded-2xl px-4 py-5" + > +
+

채팅방 나가기

+

{chatRoom?.roomName ?? ''} 채팅방을 나가시겠어요?

+
+ + +
+
+
+ + ); +} + +export default ChatRoomInfo; diff --git a/src/pages/Chat/ChatSearch.tsx b/src/pages/Chat/ChatSearch.tsx index ef84477..f208917 100644 --- a/src/pages/Chat/ChatSearch.tsx +++ b/src/pages/Chat/ChatSearch.tsx @@ -1,69 +1,68 @@ -import { useState, Fragment } from 'react'; -import { useSuspenseQuery } from '@tanstack/react-query'; -import { Link } from 'react-router-dom'; -import type { Messages } from '@/apis/chat/entity'; +import { startTransition, useState } from 'react'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useSearchParams } from 'react-router-dom'; import { chatQueries } from '@/apis/chat/queries'; -import Search from '@/assets/svg/big-search-icon.svg'; +import SearchIcon from '@/assets/svg/big-search-icon.svg'; +import RouteLoadingFallback from '@/components/common/RouteLoadingFallback'; import useDebouncedCallback from '@/utils/hooks/useDebounce'; -import { ChatRoomAvatar, ChatRoomListItem } from './components/ChatRoomListItem'; -import { formatTime } from './utils/formatTime'; - -function MessageListItem({ message, keyword }: { message: Messages; keyword: string }) { - const parts = message.matchedMessage.split(keyword); - return ( - - -
-
-
- {message.roomName} -
- {message.matchedMessageSentAt && ( - - {formatTime(message.matchedMessageSentAt)} - - )} -
-
-

- {parts.map((part, index) => ( - - {part} - {index < parts.length - 1 && {keyword}} - - ))} -

-
-
- - ); -} +import { ChatMessageListItem, ChatRoomListItem } from './components/ChatRoomListItem'; function ChatSearchResults({ keyword }: { keyword: string }) { - const { data } = useSuspenseQuery(chatQueries.search(keyword)); + const { data, isFetching, isPending } = useQuery({ + ...chatQueries.search(keyword), + placeholderData: keepPreviousData, + }); + + const backPath = keyword ? `/chats/search?keyword=${encodeURIComponent(keyword)}` : '/chats/search'; + const navigationState = { backPath }; + const hasRoomMatches = Boolean(data?.roomMatches?.rooms?.length); + const hasMessageMatches = Boolean(data?.messageMatches?.messages?.length); + const hasResults = hasRoomMatches || hasMessageMatches; return ( -
- {data?.roomMatches?.rooms?.map((room) => ( - - ))} - {data?.messageMatches?.messages?.map((message, index) => ( - - ))} +
+ {isPending && !data ? ( + + ) : hasResults ? ( + <> + {data?.roomMatches?.rooms?.map((room) => ( + + ))} + {data?.messageMatches?.messages?.map((message, index) => ( + + ))} + + ) : ( +
+ 검색 결과가 없어요 +
+ )} + + {isFetching && data && ( +
검색 중...
+ )}
); } export default function ChatSearch() { - const [keyword, setKeyword] = useState(''); - const [debouncedQuery, setDebouncedQuery] = useState(''); + const [searchParams, setSearchParams] = useSearchParams(); + const initialKeyword = searchParams.get('keyword') ?? ''; + + const [keyword, setKeyword] = useState(initialKeyword); + const [debouncedQuery, setDebouncedQuery] = useState(initialKeyword.trim()); const updateDebouncedQuery = useDebouncedCallback((value: string) => { const trimmed = value.trim(); - setDebouncedQuery(trimmed); + startTransition(() => { + setDebouncedQuery(trimmed); + setSearchParams(trimmed ? { keyword: trimmed } : {}, { replace: true }); + }); }, 300); const handleChange = (value: string) => { @@ -72,17 +71,17 @@ export default function ChatSearch() { }; return ( -
-