diff --git a/src/apis/chat/index.ts b/src/apis/chat/index.ts index ff5e827..8158c3e 100644 --- a/src/apis/chat/index.ts +++ b/src/apis/chat/index.ts @@ -49,3 +49,18 @@ export const postAdminChatRoom = async () => { }); return response; }; + +export const patchChatRoomName = async (chatRoomId: number, name: string) => { + const response = await apiClient.patch(`chats/rooms/${chatRoomId}/name`, { + body: { roomName: name }, + requiresAuth: true, + }); + return response; +}; + +export const deleteChatRoom = async (chatRoomId: number) => { + const response = await apiClient.delete(`chats/rooms/${chatRoomId}`, { + requiresAuth: true, + }); + return response; +}; diff --git a/src/apis/chat/mutations.ts b/src/apis/chat/mutations.ts index 055b801..49885d1 100644 --- a/src/apis/chat/mutations.ts +++ b/src/apis/chat/mutations.ts @@ -1,11 +1,20 @@ import { mutationOptions } from '@tanstack/react-query'; -import { postAdminChatRoom, postChatMessage, postChatMute, postChatRooms } from '@/apis/chat'; +import { + patchChatRoomName, + postAdminChatRoom, + postChatMessage, + postChatMute, + postChatRooms, + deleteChatRoom, +} from '@/apis/chat'; export const chatMutationKeys = { createRoom: () => ['chat', 'createRoom'] as const, createAdminRoom: () => ['chat', 'createAdminRoom'] as const, sendMessage: () => ['chat', 'sendMessage'] as const, toggleMute: (chatRoomId?: number) => ['chat', 'toggleMute', chatRoomId ?? 'unknown'] as const, + updateRoomName: () => ['chat', 'updateRoomName'] as const, + deleteRoom: () => ['chat', 'deleteRoom'] as const, }; export const chatMutations = { @@ -24,10 +33,10 @@ export const chatMutations = { mutationKey: chatMutationKeys.sendMessage(), mutationFn: postChatMessage, }), - toggleMute: (chatRoomId?: number) => + toggleMute: () => mutationOptions({ - mutationKey: chatMutationKeys.toggleMute(chatRoomId), - mutationFn: async () => { + mutationKey: chatMutationKeys.toggleMute(), + mutationFn: async (chatRoomId?: number) => { if (!chatRoomId) { throw new Error('chatRoomId is missing'); } @@ -35,4 +44,14 @@ export const chatMutations = { return postChatMute(chatRoomId); }, }), + updateRoomName: () => + mutationOptions({ + mutationKey: chatMutationKeys.updateRoomName(), + mutationFn: ({ chatRoomId, name }: { chatRoomId: number; name: string }) => patchChatRoomName(chatRoomId, name), + }), + deleteRoom: () => + mutationOptions({ + mutationKey: chatMutationKeys.deleteRoom(), + mutationFn: (chatRoomId: number) => deleteChatRoom(chatRoomId), + }), }; diff --git a/src/components/layout/Header/components/ChatHeader.tsx b/src/components/layout/Header/components/ChatHeader.tsx index 1d1c249..dce0cf1 100644 --- a/src/components/layout/Header/components/ChatHeader.tsx +++ b/src/components/layout/Header/components/ChatHeader.tsx @@ -58,7 +58,7 @@ function ChatHeader() { + ))} + + ); +} diff --git a/src/pages/Chat/hooks/useChat.ts b/src/pages/Chat/hooks/useChat.ts index cfd14c1..caa2151 100644 --- a/src/pages/Chat/hooks/useChat.ts +++ b/src/pages/Chat/hooks/useChat.ts @@ -5,6 +5,8 @@ import { useCreateChatRoomMutation, useSendChatMessageMutation, useToggleChatMuteMutation, + useUpdateChatRoomNameMutation, + useDeleteChatRoomMutation, } from '@/pages/Chat/hooks/useChatMutations'; const useChat = (chatRoomId?: number) => { @@ -35,7 +37,11 @@ const useChat = (chatRoomId?: number) => { const { data: clubMembersData } = useQuery(clubQueries.members(clubId)); - const toggleMuteMutation = useToggleChatMuteMutation(chatRoomId); + const toggleMuteMutation = useToggleChatMuteMutation(); + + const updateRoomNameMutation = useUpdateChatRoomNameMutation(); + + const deleteChatRoomMutation = useDeleteChatRoomMutation(); return { chatRoomList, @@ -51,6 +57,10 @@ const useChat = (chatRoomId?: number) => { clubMembers: clubMembersData?.clubMembers ?? [], toggleMute: toggleMuteMutation.mutateAsync, isTogglingMute: toggleMuteMutation.isPending, + updateRoomName: updateRoomNameMutation.mutateAsync, + isUpdatingRoomName: updateRoomNameMutation.isPending, + deleteChatRoom: deleteChatRoomMutation.mutateAsync, + isDeletingChatRoom: deleteChatRoomMutation.isPending, }; }; diff --git a/src/pages/Chat/hooks/useChatMutations.ts b/src/pages/Chat/hooks/useChatMutations.ts index 294212b..821b8be 100644 --- a/src/pages/Chat/hooks/useChatMutations.ts +++ b/src/pages/Chat/hooks/useChatMutations.ts @@ -27,11 +27,33 @@ export const useSendChatMessageMutation = () => { }); }; -export const useToggleChatMuteMutation = (chatRoomId?: number) => { +export const useUpdateChatRoomNameMutation = () => { const queryClient = useQueryClient(); return useMutation({ - ...chatMutations.toggleMute(chatRoomId), + ...chatMutations.updateRoomName(), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: chatQueryKeys.rooms() }); + }, + }); +}; + +export const useToggleChatMuteMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + ...chatMutations.toggleMute(), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: chatQueryKeys.rooms() }); + }, + }); +}; + +export const useDeleteChatRoomMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + ...chatMutations.deleteRoom(), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: chatQueryKeys.rooms() }); }, diff --git a/src/pages/Chat/index.tsx b/src/pages/Chat/index.tsx index b5f8580..4e7a0da 100644 --- a/src/pages/Chat/index.tsx +++ b/src/pages/Chat/index.tsx @@ -1,11 +1,16 @@ -import { Fragment } from 'react'; +import { Fragment, useState } from 'react'; import { Link } from 'react-router-dom'; import type { Advertisement } from '@/apis/advertisement/entity'; import type { Room } from '@/apis/chat/entity'; import BellOffIcon from '@/assets/svg/bell-off.svg'; +import ChevronLeftIcon from '@/assets/svg/chevron-left.svg'; import PersonIcon from '@/assets/svg/person.svg'; +import BottomModal from '@/components/common/BottomModal'; +import Modal from '@/components/common/Modal'; import BottomOverlaySpacer from '@/components/layout/BottomOverlaySpacer'; import { useAdvertisements } from '@/utils/hooks/useAdvertisements'; +import { useLongPress } from '@/utils/hooks/useLongPress'; +import ChatRoomContextMenu from './components/ChatRoomContextMenu'; import useChat from './hooks/useChat'; const DEFAULT_LAST_MESSAGE = '동아리에 궁금한 점을 물어보세요'; @@ -70,15 +75,24 @@ function ChatRoomAvatar({ roomImageUrl }: Pick) { ); } -function ChatRoomListItem({ room }: { room: Room }) { +interface ChatRoomListItemProps { + room: Room; + onLongPress: (x: number, y: number, room: Room) => void; +} + +function ChatRoomListItem({ room, onLongPress }: ChatRoomListItemProps) { const isGroup = room.chatType === 'GROUP'; const hasUnreadMessage = room.unreadCount > 0; const previewMessage = room.lastMessage?.trim() || DEFAULT_LAST_MESSAGE; + const longPress = useLongPress({ + onLongPress: (x: number, y: number) => onLongPress(x, y, room), + }); return ( @@ -187,8 +201,14 @@ function ChatAdvertisementListItemSkeleton() { ); } +interface ContextMenuProps { + x: number; + y: number; + room: Room; +} + function ChatListPage() { - const { chatRoomList } = useChat(); + const { chatRoomList, updateRoomName, deleteChatRoom, toggleMute } = useChat(); const rooms = chatRoomList.rooms; const advertisementCount = getAdvertisementCount(rooms.length); const { advertisements, isLoadingAdvertisements, trackAdvertisementClick } = useAdvertisements({ @@ -196,6 +216,54 @@ function ChatListPage() { scope: 'chat-list', }); + const [contextMenu, setContextMenu] = useState(null); + const [leaveRoom, setLeaveRoom] = useState(null); + const [changeRoomName, setChangeRoomName] = useState(null); + const [newRoomName, setNewRoomName] = useState(''); + + const changeName = async () => { + if (!changeRoomName) return; + const roomId = changeRoomName.roomId; + const normalizedName = newRoomName.trim(); + try { + await updateRoomName({ chatRoomId: roomId, name: normalizedName }); + } catch (error) { + console.error('Error updating room name:', error); + } + setChangeRoomName(null); + }; + + const contextMenuItems = (room: Room) => [ + { + label: '채팅방 이름 변경', + onClick: () => { + setChangeRoomName(room); + setNewRoomName(room.roomName); + }, + }, + { + label: room.isMuted ? '알림 켜기' : '알림 끄기', + onClick: () => { + toggleMute(room.roomId); + setContextMenu(null); + }, + }, + ...(room.chatType === 'DIRECT' + ? [{ label: '채팅방 나가기', onClick: () => setLeaveRoom(room), danger: true }] + : []), + ]; + + const deleteChat = async () => { + if (!leaveRoom) return; + const roomId = leaveRoom.roomId; + setLeaveRoom(null); + try { + await deleteChatRoom(roomId); + } catch (error) { + console.error('Error leaving chat room:', error); + } + }; + if (rooms.length === 0) { return (
@@ -215,7 +283,7 @@ function ChatListPage() { return ( - + setContextMenu({ x, y, room })} /> {advertisement && ( )} @@ -227,6 +295,61 @@ function ChatListPage() { })}
+ setLeaveRoom(null)} className="h-[172px] w-[341px] rounded-2xl"> +
+

채팅방 나가기

+

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

+
+
+ + +
+
+ setChangeRoomName(null)} className="h-59"> +
+ +
이름 변경
+
+
+ setNewRoomName(e.target.value)} + className="text-text-700 mt-11 h-[50px] w-[343px] rounded-2xl border border-indigo-50 text-center" + placeholder="변경할 채팅방명을 입력해주세요." + /> + +
+
+ {contextMenu && ( + setContextMenu(null)} + /> + )} ); } diff --git a/src/utils/hooks/useLongPress.ts b/src/utils/hooks/useLongPress.ts new file mode 100644 index 0000000..8259c61 --- /dev/null +++ b/src/utils/hooks/useLongPress.ts @@ -0,0 +1,88 @@ +import { useRef } from 'react'; + +interface LongPressOptions { + delay?: number; + onLongPress: (x: number, y: number) => void; +} + +const LONG_PRESS_TIME = 500; +export function useLongPress({ delay = LONG_PRESS_TIME, onLongPress }: LongPressOptions) { + const timerRef = useRef | null>(null); + const pointerIdRef = useRef(null); + const startPointRef = useRef<{ x: number; y: number } | null>(null); + const didLongPressRef = useRef(false); + + const clearTimer = () => { + if (!timerRef.current) return; + + clearTimeout(timerRef.current); + timerRef.current = null; + }; + + const cancel = () => { + clearTimer(); + pointerIdRef.current = null; + startPointRef.current = null; + }; + + const start = (e: React.PointerEvent) => { + if (e.pointerType !== 'touch') return; + + cancel(); + didLongPressRef.current = false; + pointerIdRef.current = e.pointerId; + startPointRef.current = { x: e.clientX, y: e.clientY }; + + timerRef.current = setTimeout(() => { + didLongPressRef.current = true; + onLongPress(e.clientX, e.clientY); + clearTimer(); + }, delay); + }; + + const move = (e: React.PointerEvent) => { + if (e.pointerType !== 'touch') return; + if (pointerIdRef.current !== e.pointerId) return; + if (!startPointRef.current) return; + + const deltaX = Math.abs(e.clientX - startPointRef.current.x); + const deltaY = Math.abs(e.clientY - startPointRef.current.y); + + if (deltaX > 8 || deltaY > 8) { + cancel(); + } + }; + + const end = (e: React.PointerEvent) => { + if (e.pointerType !== 'touch') return; + if (pointerIdRef.current !== e.pointerId) return; + + if (didLongPressRef.current) { + e.preventDefault(); + } + + cancel(); + }; + + const handleClickCapture = (e: React.MouseEvent) => { + if (!didLongPressRef.current) return; + + e.preventDefault(); + e.stopPropagation(); + didLongPressRef.current = false; + }; + + const preventNativeContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + }; + + return { + onPointerDown: start, + onPointerMove: move, + onPointerUp: end, + onPointerCancel: cancel, + onPointerLeave: cancel, + onClickCapture: handleClickCapture, + onContextMenu: preventNativeContextMenu, + }; +} diff --git a/src/utils/hooks/useOutsideTapDismiss.ts b/src/utils/hooks/useOutsideTapDismiss.ts new file mode 100644 index 0000000..3c1c3c7 --- /dev/null +++ b/src/utils/hooks/useOutsideTapDismiss.ts @@ -0,0 +1,94 @@ +import { useEffect, useRef, type RefObject } from 'react'; + +const TAP_MOVE_THRESHOLD_PX = 8; +const SUPPRESS_CLICK_TIMEOUT_MS = 400; + +export default function useOutsideTapDismiss(ref: RefObject, onDismiss: () => void) { + const onDismissRef = useRef(onDismiss); + + useEffect(() => { + onDismissRef.current = onDismiss; + }, [onDismiss]); + + useEffect(() => { + let activePointerId: number | null = null; + let startX = 0; + let startY = 0; + + const resetGesture = () => { + activePointerId = null; + startX = 0; + startY = 0; + }; + + const suppressNextClick = () => { + let timeoutId = 0; + + const handleClick = (event: MouseEvent) => { + window.clearTimeout(timeoutId); + window.removeEventListener('click', handleClick, true); + event.preventDefault(); + event.stopPropagation(); + }; + + timeoutId = window.setTimeout(() => { + window.removeEventListener('click', handleClick, true); + }, SUPPRESS_CLICK_TIMEOUT_MS); + + window.addEventListener('click', handleClick, true); + }; + + const handlePointerDown = (event: PointerEvent) => { + const element = ref.current; + + if (!element) return; + if (!event.isPrimary) return; + if (event.pointerType === 'mouse' && event.button !== 0) return; + if (event.target instanceof Node && element.contains(event.target)) return; + + activePointerId = event.pointerId; + startX = event.clientX; + startY = event.clientY; + + event.stopPropagation(); + }; + + const handlePointerMove = (event: PointerEvent) => { + if (activePointerId !== event.pointerId) return; + + const deltaX = Math.abs(event.clientX - startX); + const deltaY = Math.abs(event.clientY - startY); + + if (deltaX <= TAP_MOVE_THRESHOLD_PX && deltaY <= TAP_MOVE_THRESHOLD_PX) return; + + resetGesture(); + onDismissRef.current(); + }; + + const handlePointerUp = (event: PointerEvent) => { + if (activePointerId !== event.pointerId) return; + + resetGesture(); + suppressNextClick(); + onDismissRef.current(); + }; + + const handlePointerCancel = (event: PointerEvent) => { + if (activePointerId !== event.pointerId) return; + + resetGesture(); + }; + + window.addEventListener('pointerdown', handlePointerDown, true); + window.addEventListener('pointermove', handlePointerMove, true); + window.addEventListener('pointerup', handlePointerUp, true); + window.addEventListener('pointercancel', handlePointerCancel, true); + + return () => { + window.removeEventListener('pointerdown', handlePointerDown, true); + window.removeEventListener('pointermove', handlePointerMove, true); + window.removeEventListener('pointerup', handlePointerUp, true); + window.removeEventListener('pointercancel', handlePointerCancel, true); + }; + }, [ref]); +}