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/entity.ts b/src/apis/chat/entity.ts index 5b5f87e..4ed8bff 100644 --- a/src/apis/chat/entity.ts +++ b/src/apis/chat/entity.ts @@ -56,6 +56,8 @@ export interface Messages { matchedMessage: string; matchedMessageSentAt: string; matchedMessageId: number; + unreadCount: number; + isMuted: boolean; } export interface RoomMatched extends PaginationResponse { @@ -75,27 +77,40 @@ export interface MatchResponse { messageMatches?: MessageMatched; } -export interface User { +export interface InvitableUser { userId: number; name: string; imageUrl: string; studentNumber: string; } -export interface Section { +export interface InvitableSection { clubId: number; clubName: string; - users: User[]; + users: InvitableUser[]; } -export interface InvitableFriendRequestParams extends PaginationParams { +export interface InvitableFriendsRequestParams extends PaginationParams { query: string; sortBy: SortBy; } -export interface InvitableFriend extends PaginationResponse { +interface InvitableFriendsBase extends PaginationResponse { sortBy: SortBy; - grouped: boolean; - users?: User[]; - sections?: Section[]; } + +export interface GroupedInvitableFriendsResponse extends InvitableFriendsBase { + sortBy: 'CLUB'; + grouped: true; + users: []; + sections: InvitableSection[]; +} + +export interface FlatInvitableFriendsResponse extends InvitableFriendsBase { + sortBy: 'NAME'; + grouped: false; + users: InvitableUser[]; + sections: []; +} + +export type InvitableFriendsResponse = GroupedInvitableFriendsResponse | FlatInvitableFriendsResponse; diff --git a/src/apis/chat/index.ts b/src/apis/chat/index.ts index 5eae7fe..bbe1090 100644 --- a/src/apis/chat/index.ts +++ b/src/apis/chat/index.ts @@ -6,10 +6,10 @@ import type { ChatRoomsResponse, CreateChatRoomResponse, SendChatMessageRequest, + InvitableFriendsRequestParams, + InvitableFriendsResponse, MatchResponse, MatchedRequestParams, - InvitableFriendRequestParams, - InvitableFriend, } from './entity'; export const getChatRooms = async () => { @@ -85,8 +85,8 @@ export const getSearchChat = async ({ ...query }: MatchedRequestParams) => { return response; }; -export const getInvitableFriends = async ({ ...query }: InvitableFriendRequestParams) => { - const response = await apiClient.get('chats/rooms/invitables', { +export const getInvitableFriends = async ({ ...query }: InvitableFriendsRequestParams) => { + const response = await apiClient.get('chats/rooms/invitables', { params: query, requiresAuth: true, }); 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/common/Dropdown.tsx b/src/components/common/Dropdown.tsx index 138d502..fac291e 100644 --- a/src/components/common/Dropdown.tsx +++ b/src/components/common/Dropdown.tsx @@ -31,10 +31,6 @@ export default function Dropdown({ useClickTouchOutside(dropdownRef, () => setIsOpen(false)); const selectedOption = options.find((opt) => opt.value === value); - const orderedOptions = [ - ...options.filter((option) => option.value !== value), - ...options.filter((option) => option.value === value), - ]; const handleSelect = (optionValue: T) => { onChange(optionValue); @@ -47,7 +43,7 @@ export default function Dropdown({ type="button" onClick={() => setIsOpen(!isOpen)} className={cn( - 'inline-flex h-7.25 items-center justify-evenly overflow-hidden rounded-full bg-[#69BFDF] px-2.5 text-[13px] leading-[1.6] font-medium text-white transition-opacity active:opacity-90', + 'bg-primary-500 inline-flex h-7.25 items-center justify-evenly overflow-hidden rounded-full px-2.5 text-[13px] leading-[1.6] font-medium text-white transition-opacity active:opacity-90', isOpen && 'opacity-95', triggerClassName )} @@ -67,7 +63,7 @@ export default function Dropdown({ )} >
- {orderedOptions.map((option) => ( + {options.map((option) => (
+ ); +} diff --git a/src/components/layout/Header/components/ChatAddHeader.tsx b/src/components/layout/Header/components/ChatAddHeader.tsx index 8f7db2b..8c7caf5 100644 --- a/src/components/layout/Header/components/ChatAddHeader.tsx +++ b/src/components/layout/Header/components/ChatAddHeader.tsx @@ -1,21 +1,27 @@ import BackTitleHeader from '@/components/layout/Header/components/BackTitleHeader'; interface ChatListHeaderProps { + disabled?: boolean; title: string; onConfirm: () => void; } -export default function ChatAddHeader({ title, onConfirm }: ChatListHeaderProps) { - const confirm = ( - ); return ( 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/AddChatRoom.tsx b/src/pages/Chat/AddChatRoom.tsx index 3826ffa..58ddf91 100644 --- a/src/pages/Chat/AddChatRoom.tsx +++ b/src/pages/Chat/AddChatRoom.tsx @@ -1,56 +1,60 @@ -import { Fragment, useState } from 'react'; -import { useSuspenseQuery } from '@tanstack/react-query'; +import { startTransition, useState } from 'react'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { useLocation, useNavigate } from 'react-router-dom'; -import type { User, Section, SortBy } from '@/apis/chat/entity'; +import type { InvitableSection, InvitableUser, SortBy } from '@/apis/chat/entity'; import { chatQueries } from '@/apis/chat/queries'; -import Search from '@/assets/svg/big-search-icon.svg'; -import Check from '@/assets/svg/check_color.svg'; +import SearchIcon from '@/assets/svg/big-search-icon.svg'; +import CheckIcon from '@/assets/svg/check_color.svg'; import Dropdown from '@/components/common/Dropdown'; +import { MemberAvatar } from '@/components/common/MemberAvatar'; +import RouteLoadingFallback from '@/components/common/RouteLoadingFallback'; import ChatAddHeader from '@/components/layout/Header/components/ChatAddHeader'; import { getHeaderPresentation } from '@/components/layout/Header/presentation'; -import useChat from '@/pages/Chat/hooks/useChat'; +import { useCreateChatRoomGroupMutation } from '@/pages/Chat/hooks/useChatMutations'; import useDebouncedCallback from '@/utils/hooks/useDebounce'; import { isApiError } from '@/utils/ts/error/apiError'; import { isServerErrorStatus, redirectToServerErrorPage } from '@/utils/ts/error/errorRedirect'; -type UserListProps = { +interface UserListProps { onToggle: (userId: number) => void; selectedUserIds: Set; -}; +} + +interface InvitableUserItemProps extends UserListProps, Pick {} + +function InvitableUserItem({ userId, name, studentNumber, onToggle, selectedUserIds }: InvitableUserItemProps) { + const isSelected = selectedUserIds.has(userId); -function InvitableSectionList({ clubName, users, onToggle, selectedUserIds }: Section & UserListProps) { return ( -
- {clubName} - {users.map((user) => ( - -
onToggle(user.userId)}> -
- {user.name[0]} -
- - {user.name} ({user.studentNumber}) - - {selectedUserIds.has(user.userId) && } -
-
- ))} -
+ ); } -function InvitableUserList({ userId, name, studentNumber, onToggle, selectedUserIds }: User & UserListProps) { +function InvitableSectionList({ clubName, users, onToggle, selectedUserIds }: InvitableSection & UserListProps) { return ( -
onToggle(userId)}> -
-
- {name[0]} -
- - {name} ({studentNumber}) - - {selectedUserIds.has(userId) && } +
+

{clubName}

+
+ {users.map((user) => ( + + ))}
+
+ ); +} + +function InvitableUserList({ users, onToggle, selectedUserIds }: { users: InvitableUser[] } & UserListProps) { + return ( +
+ {users.map((user) => ( + + ))}
); } @@ -61,22 +65,44 @@ const SORT_OPTIONS = [ ] as const; export default function AddChatRoom() { + const navigate = useNavigate(); const { pathname } = useLocation(); const { title } = getHeaderPresentation(pathname); + const { mutateAsync: createRoomGroup, isPending: isCreatingRoomGroup } = useCreateChatRoomGroupMutation(); + const [keyword, setKeyword] = useState(''); const [debouncedQuery, setDebouncedQuery] = useState(''); const [sortBy, setSortBy] = useState('CLUB'); const [selectedUserIds, setSelectedUserIds] = useState>(new Set()); - const navigate = useNavigate(); - const { createRoomGroup } = useChat(); - const { data } = useSuspenseQuery({ + const { data, isFetching, isPending } = useQuery({ ...chatQueries.invite(debouncedQuery, sortBy), + placeholderData: keepPreviousData, }); + const hasData = Boolean(data); + const visibleSelectedUserIds = (() => { + if (!data) { + return []; + } + + if (data.grouped) { + return Array.from(selectedUserIds).filter((selectedUserId) => + data.sections.some((section) => section.users.some((user) => user.userId === selectedUserId)) + ); + } + + return Array.from(selectedUserIds).filter((selectedUserId) => + data.users.some((user) => user.userId === selectedUserId) + ); + })(); const onConfirm = async () => { + if (visibleSelectedUserIds.length === 0 || isCreatingRoomGroup) { + return; + } + try { - const result = await createRoomGroup(Array.from(selectedUserIds)); + const result = await createRoomGroup(visibleSelectedUserIds); navigate(`/chats/${result.chatRoomId}`); } catch (error) { if (isApiError(error) && isServerErrorStatus(error.status)) { @@ -89,12 +115,24 @@ export default function AddChatRoom() { const updateDebouncedQuery = useDebouncedCallback((value: string) => { const trimmed = value.trim(); - setDebouncedQuery(trimmed); + startTransition(() => { + setSelectedUserIds(new Set()); + setDebouncedQuery(trimmed); + }); }, 300); + const handleChange = (value: string) => { setKeyword(value); updateDebouncedQuery(value); }; + + const handleSortChange = (value: SortBy) => { + startTransition(() => { + setSelectedUserIds(new Set()); + setSortBy(value); + }); + }; + const toggleUser = (userId: number) => { setSelectedUserIds((prev) => { const next = new Set(prev); @@ -107,42 +145,64 @@ export default function AddChatRoom() { }); }; + const invitableListContent = (() => { + if (!data) { + return null; + } + + if (data.grouped) { + return data.sections.map((section) => ( + + )); + } + + return ; + })(); + const selectedCount = visibleSelectedUserIds.length; + const isConfirmDisabled = selectedCount === 0 || isCreatingRoomGroup; + return ( -
- -