Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ function App() {
<Route path="profile" element={<Profile />} />
<Route path="chats/:chatRoomId" element={<ChatRoom />} />
<Route path="chats/:chatRoomId/info" element={<ChatRoomInfo />} />
<Route path="chats/:chatRoomId/invite" element={<ChatAdd />} />
</Route>
</Route>

Expand Down
12 changes: 12 additions & 0 deletions src/apis/chat/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ export interface CreateChatRoomResponse {
chatRoomId: number;
}

export interface ChatRoomMember {
userId: number;
name: string;
profileImageUrl: string | null;
isOwner: boolean;
joinedAt: string;
}

export interface ChatRoomMembersResponse {
members: ChatRoomMember[];
}

export interface Messages {
roomId: number;
chatType: ChatType;
Expand Down
20 changes: 18 additions & 2 deletions src/apis/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import type {
ChatMessage,
ChatMessageRequestParam,
ChatMessagesResponse,
ChatRoomMembersResponse,
ChatRoomsResponse,
CreateChatRoomResponse,
SendChatMessageRequest,
InvitableFriendsRequestParams,
InvitableFriendsResponse,
MatchResponse,
MatchedRequestParams,
MatchResponse,
SendChatMessageRequest,
} from './entity';

export const getChatRooms = async () => {
Expand All @@ -35,6 +36,21 @@ export const postChatRoomsGroup = async (userIds: number[]) => {
return response;
};

export const getChatRoomMembers = async (chatRoomId: number) => {
const response = await apiClient.get<ChatRoomMembersResponse>(`chats/rooms/${chatRoomId}/members`, {
requiresAuth: true,
});
return response;
};

export const postChatRoomMembers = async (chatRoomId: number, userIds: number[]) => {
const response = await apiClient.post<void>(`chats/rooms/${chatRoomId}/members`, {
body: { userIds },
requiresAuth: true,
});
return response;
};

export const postChatMessage = async ({ chatRoomId, content }: SendChatMessageRequest) => {
return apiClient.post<ChatMessage>(`chats/rooms/${chatRoomId}/messages`, {
body: { content },
Expand Down
12 changes: 10 additions & 2 deletions src/apis/chat/mutations.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { mutationOptions } from '@tanstack/react-query';
import {
deleteChatRoom,
patchChatRoomName,
postChatRoomsGroup,
postAdminChatRoom,
postChatRoomMembers,
postChatMessage,
postChatMute,
postChatRooms,
deleteChatRoom,
postChatRoomsGroup,
} from '@/apis/chat';

export const chatMutationKeys = {
createRoom: () => ['chat', 'createRoom'] as const,
createAdminRoom: () => ['chat', 'createAdminRoom'] as const,
createRoomGroup: () => ['chat', 'createRoomGroup'] as const,
inviteMembers: () => ['chat', 'inviteMembers'] as const,
sendMessage: () => ['chat', 'sendMessage'] as const,
toggleMute: (chatRoomId?: number) => ['chat', 'toggleMute', chatRoomId ?? 'unknown'] as const,
updateRoomName: () => ['chat', 'updateRoomName'] as const,
Expand All @@ -35,6 +37,12 @@ export const chatMutations = {
mutationKey: chatMutationKeys.createRoomGroup(),
mutationFn: postChatRoomsGroup,
}),
inviteMembers: () =>
mutationOptions({
mutationKey: chatMutationKeys.inviteMembers(),
mutationFn: ({ chatRoomId, userIds }: { chatRoomId: number; userIds: number[] }) =>
postChatRoomMembers(chatRoomId, userIds),
}),
sendMessage: () =>
mutationOptions({
mutationKey: chatMutationKeys.sendMessage(),
Expand Down
21 changes: 20 additions & 1 deletion src/apis/chat/queries.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query';
import { getChatMessages, getChatRooms, getSearchChat, getInvitableFriends } from '@/apis/chat';
import { getChatMessages, getChatRoomMembers, getChatRooms, getInvitableFriends, getSearchChat } from '@/apis/chat';
import type { ChatMessagesResponse, SortBy } from '@/apis/chat/entity';

interface ChatMessagesPageParam {
Expand All @@ -19,9 +19,13 @@ interface ChatMessagesQueryParams {
limit?: number;
}

const hasValidChatRoomId = (chatRoomId?: number): chatRoomId is number => Number.isFinite(chatRoomId);

export const chatQueryKeys = {
all: ['chat'] as const,
rooms: () => [...chatQueryKeys.all, 'rooms'] as const,
members: (chatRoomId: number) => [...chatQueryKeys.all, 'members', chatRoomId] as const,
membersDisabled: () => [...chatQueryKeys.all, 'members', 'disabled'] as const,
messagesByRoom: (chatRoomId: number) => [...chatQueryKeys.all, 'messages', chatRoomId] as const,
messages: ({ chatRoomId, messageId, limit }: ChatMessagesQueryKeyParams) =>
[...chatQueryKeys.messagesByRoom(chatRoomId), messageId ?? 'latest', limit] as const,
Expand All @@ -36,6 +40,21 @@ export const chatQueries = {
queryKey: chatQueryKeys.rooms(),
queryFn: getChatRooms,
}),
members: (chatRoomId?: number) => {
const isEnabled = hasValidChatRoomId(chatRoomId);

return queryOptions({
queryKey: isEnabled ? chatQueryKeys.members(chatRoomId) : chatQueryKeys.membersDisabled(),
queryFn: () => {
if (!hasValidChatRoomId(chatRoomId)) {
throw new Error('채팅방 ID가 필요합니다.');
}

return getChatRoomMembers(chatRoomId);
},
enabled: isEnabled,
});
},
messages: ({ chatRoomId, messageId, limit = 20 }: ChatMessagesQueryParams) =>
infiniteQueryOptions({
queryKey: chatRoomId
Expand Down
8 changes: 7 additions & 1 deletion src/components/layout/Header/components/ChatHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Ref } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
import { chatQueries } from '@/apis/chat/queries';
import ChevronLeftIcon from '@/assets/svg/chevron-left.svg';
import HamburgerIcon from '@/assets/svg/hamburger.svg';
import ToggleSwitch from '@/components/common/ToggleSwitch';
Expand All @@ -25,7 +27,11 @@ function ChatHeader({ headerRef }: { headerRef?: Ref<HTMLElement> }) {

const chatRoom = chatRoomList.rooms.find((room) => room.roomId === numericRoomId);
const isGroup = isGroupChatType(chatRoom?.chatType);
const { data: chatRoomMembersData } = useQuery(
chatQueries.members(isGroup && chatRoom?.chatType === 'GROUP' ? numericRoomId : undefined)
);
const isMuted = chatRoom?.isMuted ?? false;
const memberCount = chatRoom?.chatType === 'GROUP' ? (chatRoomMembersData?.members.length ?? 0) : clubMembers.length;

const handleBack = () => {
if (isInfoPage && chatRoomId) {
Expand Down Expand Up @@ -59,7 +65,7 @@ function ChatHeader({ headerRef }: { headerRef?: Ref<HTMLElement> }) {

<div className="flex min-w-0 items-center gap-1">
<span className="truncate leading-5 font-bold text-indigo-700">{chatRoom?.roomName ?? ''}</span>
{isGroup && <span className="text-text-700 text-[13px] leading-5">{clubMembers.length}</span>}
{isGroup && <span className="text-text-700 text-[13px] leading-5">{memberCount}</span>}
</div>
</div>

Expand Down
4 changes: 4 additions & 0 deletions src/components/layout/Header/headerConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export const HEADER_CONFIGS: HeaderConfig[] = [
type: 'none',
match: (pathname) => pathname === '/chats/add',
},
{
type: 'none',
match: (pathname) => /^\/chats\/\d+\/invite$/.test(pathname),
},
{
type: 'chatSearch',
match: (pathname) => pathname === '/chats/search',
Expand Down
4 changes: 4 additions & 0 deletions src/components/layout/Header/routeTitles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ export const ROUTE_TITLES: RouteTitle[] = [
match: (pathname) => pathname === '/chats/add',
title: '채팅방 추가',
},
{
match: (pathname) => /^\/chats\/\d+\/invite$/.test(pathname),
title: '인원 추가하기',
},
{
match: (pathname) => pathname === '/chats/search',
title: '채팅방 검색',
Expand Down
86 changes: 74 additions & 12 deletions src/pages/Chat/AddChatRoom.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { startTransition, useState } from 'react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useLocation, useNavigate } from 'react-router-dom';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import type { InvitableSection, InvitableUser, SortBy } from '@/apis/chat/entity';
import { chatQueries } from '@/apis/chat/queries';
import SearchIcon from '@/assets/svg/big-search-icon.svg';
Expand All @@ -10,7 +10,8 @@ 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 { useCreateChatRoomGroupMutation } from '@/pages/Chat/hooks/useChatMutations';
import { useCreateChatRoomGroupMutation, useInviteChatRoomMembersMutation } from '@/pages/Chat/hooks/useChatMutations';
import { useApiErrorToast } from '@/utils/hooks/error/useApiErrorToast';
import useDebouncedCallback from '@/utils/hooks/useDebounce';
import { isApiError } from '@/utils/ts/error/apiError';
import { isServerErrorStatus, redirectToServerErrorPage } from '@/utils/ts/error/errorRedirect';
Expand Down Expand Up @@ -67,8 +68,14 @@ const SORT_OPTIONS = [
export default function AddChatRoom() {
const navigate = useNavigate();
const { pathname } = useLocation();
const { chatRoomId } = useParams<{ chatRoomId?: string }>();
const { title } = getHeaderPresentation(pathname);
const numericRoomId = chatRoomId ? Number(chatRoomId) : undefined;
const isInviteMode = numericRoomId != null && Number.isFinite(numericRoomId);
const showApiErrorToast = useApiErrorToast();
const { mutateAsync: createRoomGroup, isPending: isCreatingRoomGroup } = useCreateChatRoomGroupMutation();
const { mutateAsync: inviteChatRoomMembers, isPending: isInvitingChatRoomMembers } =
useInviteChatRoomMembersMutation();

const [keyword, setKeyword] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
Expand All @@ -79,37 +86,69 @@ export default function AddChatRoom() {
...chatQueries.invite(debouncedQuery, sortBy),
placeholderData: keepPreviousData,
});
const { data: chatRoomMembersData, isPending: isChatRoomMembersPending } = useQuery(
chatQueries.members(isInviteMode ? numericRoomId : undefined)
);
const hasData = data != null;
const isCurrentRoomMembersReady = !isInviteMode || chatRoomMembersData != null;
const currentRoomMemberIds = isCurrentRoomMembersReady
? new Set(chatRoomMembersData?.members.map((member) => member.userId) ?? [])
: null;
const filteredSections =
data?.grouped === true && currentRoomMemberIds != null
? data.sections
.map((section) => ({
...section,
users: section.users.filter((user) => !currentRoomMemberIds.has(user.userId)),
}))
.filter((section) => section.users.length > 0)
: [];
const filteredUsers =
data?.grouped === false && currentRoomMemberIds != null
? data.users.filter((user) => !currentRoomMemberIds.has(user.userId))
: [];
const visibleSelectedUserIds = (() => {
if (!data) {
if (!data || currentRoomMemberIds == null) {
return [];
}

if (data.grouped) {
return Array.from(selectedUserIds).filter((selectedUserId) =>
data.sections.some((section) => section.users.some((user) => user.userId === selectedUserId))
filteredSections.some((section) => section.users.some((user) => user.userId === selectedUserId))
);
}

return Array.from(selectedUserIds).filter((selectedUserId) =>
data.users.some((user) => user.userId === selectedUserId)
filteredUsers.some((user) => user.userId === selectedUserId)
);
})();
const isSubmitting = isCreatingRoomGroup || isInvitingChatRoomMembers;
const isInviteMembersLoading = isInviteMode && (!isCurrentRoomMembersReady || isChatRoomMembersPending);

const onConfirm = async () => {
if (visibleSelectedUserIds.length === 0 || isCreatingRoomGroup) {
if (visibleSelectedUserIds.length === 0 || isSubmitting || isInviteMembersLoading) {
return;
}

try {
if (isInviteMode && numericRoomId != null) {
await inviteChatRoomMembers({
chatRoomId: numericRoomId,
userIds: visibleSelectedUserIds,
});
navigate(`/chats/${numericRoomId}/info`, { replace: true });
return;
}

const result = await createRoomGroup(visibleSelectedUserIds);
navigate(`/chats/${result.chatRoomId}`);
} catch (error) {
if (isApiError(error) && isServerErrorStatus(error.status)) {
redirectToServerErrorPage();
return;
}
throw error;

showApiErrorToast(error, isInviteMode ? '멤버 초대에 실패했습니다.' : '채팅방 생성에 실패했습니다.');
}
};

Expand All @@ -134,6 +173,10 @@ export default function AddChatRoom() {
};

const toggleUser = (userId: number) => {
if (isInviteMembersLoading) {
return;
}

setSelectedUserIds((prev) => {
const next = new Set(prev);
if (next.has(userId)) {
Expand All @@ -151,7 +194,7 @@ export default function AddChatRoom() {
}

if (data.grouped) {
return data.sections.map((section) => (
return filteredSections.map((section) => (
<InvitableSectionList
key={section.clubId}
{...section}
Expand All @@ -161,10 +204,21 @@ export default function AddChatRoom() {
));
}

return <InvitableUserList users={data.users} onToggle={toggleUser} selectedUserIds={selectedUserIds} />;
return <InvitableUserList users={filteredUsers} onToggle={toggleUser} selectedUserIds={selectedUserIds} />;
})();
const selectedCount = visibleSelectedUserIds.length;
const isConfirmDisabled = selectedCount === 0 || isCreatingRoomGroup;
const isInvitableListEmpty =
!isInviteMembersLoading && data
? data.grouped
? filteredSections.length === 0
: filteredUsers.length === 0
: false;
const emptyMessage = keyword.trim()
? '검색 결과가 없어요.'
: isInviteMode
? '초대할 수 있는 친구가 없어요.'
: '선택할 수 있는 친구가 없어요.';
const isConfirmDisabled = selectedCount === 0 || isSubmitting || isInviteMembersLoading;

return (
<div className="flex h-full flex-col items-center px-5 pt-19">
Expand Down Expand Up @@ -193,11 +247,19 @@ export default function AddChatRoom() {
/>
</div>

{isPending && !hasData ? (
{isInviteMembersLoading ? (
<RouteLoadingFallback />
) : isPending && !hasData ? (
<RouteLoadingFallback />
) : (
<>
{invitableListContent}
{isInvitableListEmpty ? (
<div className="text-text-400 flex flex-1 items-center justify-center text-[14px] leading-[1.6]">
{emptyMessage}
</div>
) : (
invitableListContent
)}
{isFetching && hasData && (
<div className="text-text-400 flex justify-center text-xs leading-[1.6]">불러오는 중...</div>
)}
Expand Down
Loading
Loading