Skip to content

Commit c4d304a

Browse files
ParkSungju01ff1451
andauthored
[feat] 사용자 채팅방 이름 변경 및 채팅방 나가기 기능 추가 (#258)
* feat:contextMessage 구현 * fix: long press 후 스크롤 중단 버그 수정 * fix: 외부 클릭 시 처리 훅 추가 및 적용 * 채팅방 삭제 기능 구현 * fix: codeRabbit 수정사항 수정 * fix: 수정사항 수정 및 toggleMute 연결 * fix: coderabbit 리뷰 수정 --------- Co-authored-by: 이준영 <ff1451@gmail.com>
1 parent 822ad69 commit c4d304a

9 files changed

Lines changed: 441 additions & 13 deletions

File tree

src/apis/chat/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,18 @@ export const postAdminChatRoom = async () => {
4949
});
5050
return response;
5151
};
52+
53+
export const patchChatRoomName = async (chatRoomId: number, name: string) => {
54+
const response = await apiClient.patch(`chats/rooms/${chatRoomId}/name`, {
55+
body: { roomName: name },
56+
requiresAuth: true,
57+
});
58+
return response;
59+
};
60+
61+
export const deleteChatRoom = async (chatRoomId: number) => {
62+
const response = await apiClient.delete(`chats/rooms/${chatRoomId}`, {
63+
requiresAuth: true,
64+
});
65+
return response;
66+
};

src/apis/chat/mutations.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
import { mutationOptions } from '@tanstack/react-query';
2-
import { postAdminChatRoom, postChatMessage, postChatMute, postChatRooms } from '@/apis/chat';
2+
import {
3+
patchChatRoomName,
4+
postAdminChatRoom,
5+
postChatMessage,
6+
postChatMute,
7+
postChatRooms,
8+
deleteChatRoom,
9+
} from '@/apis/chat';
310

411
export const chatMutationKeys = {
512
createRoom: () => ['chat', 'createRoom'] as const,
613
createAdminRoom: () => ['chat', 'createAdminRoom'] as const,
714
sendMessage: () => ['chat', 'sendMessage'] as const,
815
toggleMute: (chatRoomId?: number) => ['chat', 'toggleMute', chatRoomId ?? 'unknown'] as const,
16+
updateRoomName: () => ['chat', 'updateRoomName'] as const,
17+
deleteRoom: () => ['chat', 'deleteRoom'] as const,
918
};
1019

1120
export const chatMutations = {
@@ -24,15 +33,25 @@ export const chatMutations = {
2433
mutationKey: chatMutationKeys.sendMessage(),
2534
mutationFn: postChatMessage,
2635
}),
27-
toggleMute: (chatRoomId?: number) =>
36+
toggleMute: () =>
2837
mutationOptions({
29-
mutationKey: chatMutationKeys.toggleMute(chatRoomId),
30-
mutationFn: async () => {
38+
mutationKey: chatMutationKeys.toggleMute(),
39+
mutationFn: async (chatRoomId?: number) => {
3140
if (!chatRoomId) {
3241
throw new Error('chatRoomId is missing');
3342
}
3443

3544
return postChatMute(chatRoomId);
3645
},
3746
}),
47+
updateRoomName: () =>
48+
mutationOptions({
49+
mutationKey: chatMutationKeys.updateRoomName(),
50+
mutationFn: ({ chatRoomId, name }: { chatRoomId: number; name: string }) => patchChatRoomName(chatRoomId, name),
51+
}),
52+
deleteRoom: () =>
53+
mutationOptions({
54+
mutationKey: chatMutationKeys.deleteRoom(),
55+
mutationFn: (chatRoomId: number) => deleteChatRoom(chatRoomId),
56+
}),
3857
};

src/components/layout/Header/components/ChatHeader.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ function ChatHeader() {
5858
<button
5959
type="button"
6060
disabled={isTogglingMute}
61-
onClick={() => void toggleMute()}
61+
onClick={() => void toggleMute(numericRoomId)}
6262
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
6363
isMuted ? 'bg-gray-300' : 'bg-primary'
6464
} disabled:cursor-not-allowed disabled:opacity-60`}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { useRef } from 'react';
2+
import useOutsideTapDismiss from '@/utils/hooks/useOutsideTapDismiss';
3+
import { cn } from '@/utils/ts/cn';
4+
5+
interface MenuItem {
6+
label: string;
7+
onClick: () => void;
8+
danger?: boolean;
9+
}
10+
11+
interface ChatRoomContextMenuProps {
12+
x: number;
13+
y: number;
14+
title: string;
15+
items: MenuItem[];
16+
onClose: () => void;
17+
}
18+
const MENU_WIDTH = 161;
19+
const MENU_ITEM_HEIGHT = 44;
20+
const MENU_HEADER_HEIGHT = 27;
21+
const MENU_VERTICAL_PADDING = 24;
22+
23+
export default function ChatRoomContextMenu({ x, y, title, items, onClose }: ChatRoomContextMenuProps) {
24+
const menuRef = useRef<HTMLDivElement>(null);
25+
useOutsideTapDismiss(menuRef, onClose);
26+
27+
const menuHeight = MENU_HEADER_HEIGHT + MENU_VERTICAL_PADDING + items.length * MENU_ITEM_HEIGHT;
28+
29+
const adjustedX = x + MENU_WIDTH > window.innerWidth ? x - MENU_WIDTH : x;
30+
const adjustedY = y + menuHeight > window.innerHeight ? y - menuHeight : y;
31+
32+
return (
33+
<div
34+
ref={menuRef}
35+
className="bg-text-100/80 fixed z-50 w-[161px] overflow-hidden rounded-xl py-3 shadow-lg"
36+
style={{ left: adjustedX, top: adjustedY, height: menuHeight }}
37+
>
38+
<div className="truncate px-4 py-3 text-[14px] font-bold text-indigo-900">{title}</div>
39+
{items.map((item) => (
40+
<button
41+
key={item.label}
42+
type="button"
43+
onClick={() => {
44+
item.onClick();
45+
onClose();
46+
}}
47+
className={cn(
48+
'active:bg-indigo-5 w-full px-4 py-2.5 text-left text-[14px] font-medium',
49+
item.danger ? 'text-red-500' : 'text-indigo-700'
50+
)}
51+
>
52+
{item.label}
53+
</button>
54+
))}
55+
</div>
56+
);
57+
}

src/pages/Chat/hooks/useChat.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
useCreateChatRoomMutation,
66
useSendChatMessageMutation,
77
useToggleChatMuteMutation,
8+
useUpdateChatRoomNameMutation,
9+
useDeleteChatRoomMutation,
810
} from '@/pages/Chat/hooks/useChatMutations';
911

1012
const useChat = (chatRoomId?: number) => {
@@ -35,7 +37,11 @@ const useChat = (chatRoomId?: number) => {
3537

3638
const { data: clubMembersData } = useQuery(clubQueries.members(clubId));
3739

38-
const toggleMuteMutation = useToggleChatMuteMutation(chatRoomId);
40+
const toggleMuteMutation = useToggleChatMuteMutation();
41+
42+
const updateRoomNameMutation = useUpdateChatRoomNameMutation();
43+
44+
const deleteChatRoomMutation = useDeleteChatRoomMutation();
3945

4046
return {
4147
chatRoomList,
@@ -51,6 +57,10 @@ const useChat = (chatRoomId?: number) => {
5157
clubMembers: clubMembersData?.clubMembers ?? [],
5258
toggleMute: toggleMuteMutation.mutateAsync,
5359
isTogglingMute: toggleMuteMutation.isPending,
60+
updateRoomName: updateRoomNameMutation.mutateAsync,
61+
isUpdatingRoomName: updateRoomNameMutation.isPending,
62+
deleteChatRoom: deleteChatRoomMutation.mutateAsync,
63+
isDeletingChatRoom: deleteChatRoomMutation.isPending,
5464
};
5565
};
5666

src/pages/Chat/hooks/useChatMutations.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,33 @@ export const useSendChatMessageMutation = () => {
2727
});
2828
};
2929

30-
export const useToggleChatMuteMutation = (chatRoomId?: number) => {
30+
export const useUpdateChatRoomNameMutation = () => {
3131
const queryClient = useQueryClient();
3232

3333
return useMutation({
34-
...chatMutations.toggleMute(chatRoomId),
34+
...chatMutations.updateRoomName(),
35+
onSuccess: async () => {
36+
await queryClient.invalidateQueries({ queryKey: chatQueryKeys.rooms() });
37+
},
38+
});
39+
};
40+
41+
export const useToggleChatMuteMutation = () => {
42+
const queryClient = useQueryClient();
43+
44+
return useMutation({
45+
...chatMutations.toggleMute(),
46+
onSuccess: async () => {
47+
await queryClient.invalidateQueries({ queryKey: chatQueryKeys.rooms() });
48+
},
49+
});
50+
};
51+
52+
export const useDeleteChatRoomMutation = () => {
53+
const queryClient = useQueryClient();
54+
55+
return useMutation({
56+
...chatMutations.deleteRoom(),
3557
onSuccess: async () => {
3658
await queryClient.invalidateQueries({ queryKey: chatQueryKeys.rooms() });
3759
},

src/pages/Chat/index.tsx

Lines changed: 128 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1-
import { Fragment } from 'react';
1+
import { Fragment, useState } from 'react';
22
import { Link } from 'react-router-dom';
33
import type { Advertisement } from '@/apis/advertisement/entity';
44
import type { Room } from '@/apis/chat/entity';
55
import BellOffIcon from '@/assets/svg/bell-off.svg';
6+
import ChevronLeftIcon from '@/assets/svg/chevron-left.svg';
67
import PersonIcon from '@/assets/svg/person.svg';
8+
import BottomModal from '@/components/common/BottomModal';
9+
import Modal from '@/components/common/Modal';
710
import BottomOverlaySpacer from '@/components/layout/BottomOverlaySpacer';
811
import { useAdvertisements } from '@/utils/hooks/useAdvertisements';
12+
import { useLongPress } from '@/utils/hooks/useLongPress';
13+
import ChatRoomContextMenu from './components/ChatRoomContextMenu';
914
import useChat from './hooks/useChat';
1015

1116
const DEFAULT_LAST_MESSAGE = '동아리에 궁금한 점을 물어보세요';
@@ -70,15 +75,24 @@ function ChatRoomAvatar({ roomImageUrl }: Pick<Room, 'roomImageUrl'>) {
7075
);
7176
}
7277

73-
function ChatRoomListItem({ room }: { room: Room }) {
78+
interface ChatRoomListItemProps {
79+
room: Room;
80+
onLongPress: (x: number, y: number, room: Room) => void;
81+
}
82+
83+
function ChatRoomListItem({ room, onLongPress }: ChatRoomListItemProps) {
7484
const isGroup = room.chatType === 'GROUP';
7585
const hasUnreadMessage = room.unreadCount > 0;
7686
const previewMessage = room.lastMessage?.trim() || DEFAULT_LAST_MESSAGE;
87+
const longPress = useLongPress({
88+
onLongPress: (x: number, y: number) => onLongPress(x, y, room),
89+
});
7790

7891
return (
7992
<Link
93+
{...longPress}
8094
to={`${room.roomId}`}
81-
className="active:bg-indigo-5 flex items-center gap-3 bg-white px-5 py-3 transition-colors"
95+
className="active:bg-indigo-5 flex touch-pan-y items-center gap-3 bg-white px-5 py-3 transition-colors select-none"
8296
>
8397
<ChatRoomAvatar roomImageUrl={room.roomImageUrl} />
8498

@@ -187,15 +201,69 @@ function ChatAdvertisementListItemSkeleton() {
187201
);
188202
}
189203

204+
interface ContextMenuProps {
205+
x: number;
206+
y: number;
207+
room: Room;
208+
}
209+
190210
function ChatListPage() {
191-
const { chatRoomList } = useChat();
211+
const { chatRoomList, updateRoomName, deleteChatRoom, toggleMute } = useChat();
192212
const rooms = chatRoomList.rooms;
193213
const advertisementCount = getAdvertisementCount(rooms.length);
194214
const { advertisements, isLoadingAdvertisements, trackAdvertisementClick } = useAdvertisements({
195215
advertisementCount,
196216
scope: 'chat-list',
197217
});
198218

219+
const [contextMenu, setContextMenu] = useState<ContextMenuProps | null>(null);
220+
const [leaveRoom, setLeaveRoom] = useState<Room | null>(null);
221+
const [changeRoomName, setChangeRoomName] = useState<Room | null>(null);
222+
const [newRoomName, setNewRoomName] = useState('');
223+
224+
const changeName = async () => {
225+
if (!changeRoomName) return;
226+
const roomId = changeRoomName.roomId;
227+
const normalizedName = newRoomName.trim();
228+
try {
229+
await updateRoomName({ chatRoomId: roomId, name: normalizedName });
230+
} catch (error) {
231+
console.error('Error updating room name:', error);
232+
}
233+
setChangeRoomName(null);
234+
};
235+
236+
const contextMenuItems = (room: Room) => [
237+
{
238+
label: '채팅방 이름 변경',
239+
onClick: () => {
240+
setChangeRoomName(room);
241+
setNewRoomName(room.roomName);
242+
},
243+
},
244+
{
245+
label: room.isMuted ? '알림 켜기' : '알림 끄기',
246+
onClick: () => {
247+
toggleMute(room.roomId);
248+
setContextMenu(null);
249+
},
250+
},
251+
...(room.chatType === 'DIRECT'
252+
? [{ label: '채팅방 나가기', onClick: () => setLeaveRoom(room), danger: true }]
253+
: []),
254+
];
255+
256+
const deleteChat = async () => {
257+
if (!leaveRoom) return;
258+
const roomId = leaveRoom.roomId;
259+
setLeaveRoom(null);
260+
try {
261+
await deleteChatRoom(roomId);
262+
} catch (error) {
263+
console.error('Error leaving chat room:', error);
264+
}
265+
};
266+
199267
if (rooms.length === 0) {
200268
return (
201269
<div className="bg-indigo-0 flex min-h-full flex-col items-center justify-center px-6 py-3 text-center">
@@ -215,7 +283,7 @@ function ChatListPage() {
215283

216284
return (
217285
<Fragment key={room.roomId}>
218-
<ChatRoomListItem room={room} />
286+
<ChatRoomListItem room={room} onLongPress={(x, y, room) => setContextMenu({ x, y, room })} />
219287
{advertisement && (
220288
<ChatAdvertisementListItem advertisement={advertisement} onClick={trackAdvertisementClick} />
221289
)}
@@ -227,6 +295,61 @@ function ChatListPage() {
227295
})}
228296
<BottomOverlaySpacer gap={24} />
229297
</div>
298+
<Modal isOpen={leaveRoom !== null} onClose={() => setLeaveRoom(null)} className="h-[172px] w-[341px] rounded-2xl">
299+
<div className="px-6 py-6 text-center">
300+
<p className="text-text-700 mb-5 text-[16px] font-bold">채팅방 나가기</p>
301+
<p className="text-text-500 mt-2 text-[14px]">{leaveRoom?.roomName} 채팅방을 나가시겠어요?</p>
302+
</div>
303+
<div className="flex gap-2">
304+
<button
305+
type="button"
306+
className="ml-4 h-11 w-37 flex-1 cursor-pointer rounded-[10px] border border-[#69BFDF] py-4 text-[14px] font-bold text-[#69BFDF]"
307+
onClick={() => setLeaveRoom(null)}
308+
>
309+
취소
310+
</button>
311+
<button
312+
type="button"
313+
className="bg-primary-500 mr-4 flex-1 cursor-pointer rounded-[10px] py-4 text-[14px] font-medium text-white"
314+
onClick={deleteChat}
315+
>
316+
나가기
317+
</button>
318+
</div>
319+
</Modal>
320+
<BottomModal isOpen={changeRoomName !== null} onClose={() => setChangeRoomName(null)} className="h-59">
321+
<div className="flex items-center px-4 py-4">
322+
<button type="button" aria-label="닫기" onClick={() => setChangeRoomName(null)}>
323+
<ChevronLeftIcon />
324+
</button>
325+
<div className="px-30 text-center font-semibold">이름 변경</div>
326+
</div>
327+
<div className="flex w-full flex-col items-center gap-6">
328+
<input
329+
type="text"
330+
value={newRoomName}
331+
onChange={(e) => setNewRoomName(e.target.value)}
332+
className="text-text-700 mt-11 h-[50px] w-[343px] rounded-2xl border border-indigo-50 text-center"
333+
placeholder="변경할 채팅방명을 입력해주세요."
334+
/>
335+
<button
336+
type="button"
337+
className="bg-primary-500 w-[343px] flex-1 cursor-pointer rounded-[10px] py-4 text-[14px] font-medium text-white"
338+
onClick={changeName}
339+
>
340+
확인
341+
</button>
342+
</div>
343+
</BottomModal>
344+
{contextMenu && (
345+
<ChatRoomContextMenu
346+
x={contextMenu.x}
347+
y={contextMenu.y}
348+
title={contextMenu.room.roomName}
349+
items={contextMenuItems(contextMenu.room)}
350+
onClose={() => setContextMenu(null)}
351+
/>
352+
)}
230353
</div>
231354
);
232355
}

0 commit comments

Comments
 (0)