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
15 changes: 15 additions & 0 deletions src/apis/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
27 changes: 23 additions & 4 deletions src/apis/chat/mutations.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -24,15 +33,25 @@ 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');
}

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),
}),
};
2 changes: 1 addition & 1 deletion src/components/layout/Header/components/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ function ChatHeader() {
<button
type="button"
disabled={isTogglingMute}
onClick={() => void toggleMute()}
onClick={() => void toggleMute(numericRoomId)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
isMuted ? 'bg-gray-300' : 'bg-primary'
} disabled:cursor-not-allowed disabled:opacity-60`}
Comment on lines 58 to 64
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

헤더의 mute 토글 Promise를 처리해주세요.

Line 61의 void toggleMute(numericRoomId)는 rejection을 막지 못합니다. API 실패 시 unhandled rejection이 남을 수 있으니 await/catch로 감싸거나, fire-and-forget이 목적이면 Promise를 밖으로 노출하지 않는 형태로 바꾸는 편이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/layout/Header/components/ChatHeader.tsx` around lines 58 - 64,
The click handler currently uses `void toggleMute(numericRoomId)` which doesn't
prevent unhandled rejections; update the `onClick` in ChatHeader.tsx to properly
handle the Promise from `toggleMute` by either making the handler async and
using `await` inside a try/catch (e.g., `onClick={async () => { try { await
toggleMute(numericRoomId) } catch (e) { /* log/handle */ } }}`) or call
`toggleMute(numericRoomId).catch(e => { /* log/handle */ })` so any API failure
is caught; ensure you still respect `isTogglingMute`/disabled state and do not
leak the raw Promise to the event system.

Expand Down
57 changes: 57 additions & 0 deletions src/pages/Chat/components/ChatRoomContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useRef } from 'react';
import useOutsideTapDismiss from '@/utils/hooks/useOutsideTapDismiss';
import { cn } from '@/utils/ts/cn';

interface MenuItem {
label: string;
onClick: () => void;
danger?: boolean;
}

interface ChatRoomContextMenuProps {
x: number;
y: number;
title: string;
items: MenuItem[];
onClose: () => void;
}
const MENU_WIDTH = 161;
const MENU_ITEM_HEIGHT = 44;
const MENU_HEADER_HEIGHT = 27;
const MENU_VERTICAL_PADDING = 24;

export default function ChatRoomContextMenu({ x, y, title, items, onClose }: ChatRoomContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
useOutsideTapDismiss(menuRef, onClose);

const menuHeight = MENU_HEADER_HEIGHT + MENU_VERTICAL_PADDING + items.length * MENU_ITEM_HEIGHT;

const adjustedX = x + MENU_WIDTH > window.innerWidth ? x - MENU_WIDTH : x;
const adjustedY = y + menuHeight > window.innerHeight ? y - menuHeight : y;
Comment thread
ParkSungju01 marked this conversation as resolved.

return (
<div
ref={menuRef}
className="bg-text-100/80 fixed z-50 w-[161px] overflow-hidden rounded-xl py-3 shadow-lg"
style={{ left: adjustedX, top: adjustedY, height: menuHeight }}
>
<div className="truncate px-4 py-3 text-[14px] font-bold text-indigo-900">{title}</div>
{items.map((item) => (
<button
key={item.label}
type="button"
onClick={() => {
item.onClick();
onClose();
}}
className={cn(
'active:bg-indigo-5 w-full px-4 py-2.5 text-left text-[14px] font-medium',
item.danger ? 'text-red-500' : 'text-indigo-700'
)}
>
{item.label}
</button>
))}
</div>
);
}
12 changes: 11 additions & 1 deletion src/pages/Chat/hooks/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
useCreateChatRoomMutation,
useSendChatMessageMutation,
useToggleChatMuteMutation,
useUpdateChatRoomNameMutation,
useDeleteChatRoomMutation,
} from '@/pages/Chat/hooks/useChatMutations';

const useChat = (chatRoomId?: number) => {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
};
};

Expand Down
26 changes: 24 additions & 2 deletions src/pages/Chat/hooks/useChatMutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() });
},
Expand Down
133 changes: 128 additions & 5 deletions src/pages/Chat/index.tsx
Original file line number Diff line number Diff line change
@@ -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 = '동아리에 궁금한 점을 물어보세요';
Expand Down Expand Up @@ -70,15 +75,24 @@ function ChatRoomAvatar({ roomImageUrl }: Pick<Room, 'roomImageUrl'>) {
);
}

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),
});
Comment on lines +87 to +89
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# useLongPress 훅의 setTimeout 콜백 확인
cat src/utils/hooks/useLongPress.ts

Repository: BCSDLab/KONECT_FRONT_END

Length of output: 2291


useLongPress 훅의 이벤트 객체 참조 버그

setTimeout 콜백에서 e.clientX/e.clientY를 직접 참조하는 것은 위험합니다. 이벤트 핸들러 반환 후 React의 이벤트 객체가 무효화되므로 좌표가 NaN이 됩니다. 이미 저장된 startPointRef.current.x/y를 사용하도록 수정하세요:

timerRef.current = setTimeout(() => {
  didLongPressRef.current = true;
  if (startPointRef.current) {
    onLongPress(startPointRef.current.x, startPointRef.current.y);
  }
  clearTimer();
}, delay);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/Chat/index.tsx` around lines 87 - 89, The useLongPress hook
currently reads e.clientX/e.clientY inside the setTimeout callback which can be
invalidated; instead use the stored coordinates in startPointRef.current when
invoking onLongPress. Modify the timer callback code in useLongPress (the block
that sets timerRef.current and didLongPressRef.current) to check
startPointRef.current and call onLongPress(startPointRef.current.x,
startPointRef.current.y), then call clearTimer(); ensure you keep references to
timerRef, didLongPressRef, startPointRef, onLongPress and clearTimer unchanged
elsewhere.


return (
<Link
{...longPress}
to={`${room.roomId}`}
className="active:bg-indigo-5 flex items-center gap-3 bg-white px-5 py-3 transition-colors"
className="active:bg-indigo-5 flex touch-pan-y items-center gap-3 bg-white px-5 py-3 transition-colors select-none"
>
Comment on lines +78 to 96
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

롱프레스만으로는 방 관리 메뉴에 접근할 수 없습니다.

현재 이름 변경/나가기 진입점이 포인터 롱프레스뿐이라 키보드·보조기기 사용자는 기능을 실행할 방법이 없습니다. 같은 메뉴를 여는 별도 버튼을 두거나, 최소한 Enter/Space로 열 수 있는 대체 경로를 추가해주세요.

As per coding guidelines, '접근성(aria-*, role, 키보드 탐색)이 적절히 처리되는지'.

Also applies to: 282-282

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/Chat/index.tsx` around lines 78 - 96, The room management menu is
currently only reachable via pointer long-press in ChatRoomListItem using
useLongPress/onLongPress, which blocks keyboard and assistive-device users; add
an explicit menu trigger that is keyboard-accessible and screen-reader friendly
(e.g., a small button element rendered inside ChatRoomListItem with
aria-haspopup="menu", aria-label (e.g., "Open room menu"), role="button" if not
a native button, and tabIndex=0), wire its onClick to the same handler that
opens the menu (call the existing onLongPress callback or an extracted
openMenu(room, coords?) helper), and add an onKeyDown handler that activates the
same action on Enter and Space; apply the same change where ChatRoomListItem is
reused (the other occurrence referenced) so both pointer and keyboard/assistive
users can open the name-change/leave menu.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
<ChatRoomAvatar roomImageUrl={room.roomImageUrl} />

Expand Down Expand Up @@ -187,15 +201,69 @@ 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({
advertisementCount,
scope: 'chat-list',
});

const [contextMenu, setContextMenu] = useState<ContextMenuProps | null>(null);
const [leaveRoom, setLeaveRoom] = useState<Room | null>(null);
const [changeRoomName, setChangeRoomName] = useState<Room | null>(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 (
<div className="bg-indigo-0 flex min-h-full flex-col items-center justify-center px-6 py-3 text-center">
Expand All @@ -215,7 +283,7 @@ function ChatListPage() {

return (
<Fragment key={room.roomId}>
<ChatRoomListItem room={room} />
<ChatRoomListItem room={room} onLongPress={(x, y, room) => setContextMenu({ x, y, room })} />
{advertisement && (
<ChatAdvertisementListItem advertisement={advertisement} onClick={trackAdvertisementClick} />
)}
Expand All @@ -227,6 +295,61 @@ function ChatListPage() {
})}
<BottomOverlaySpacer gap={24} />
</div>
<Modal isOpen={leaveRoom !== null} onClose={() => setLeaveRoom(null)} className="h-[172px] w-[341px] rounded-2xl">
<div className="px-6 py-6 text-center">
<p className="text-text-700 mb-5 text-[16px] font-bold">채팅방 나가기</p>
<p className="text-text-500 mt-2 text-[14px]">{leaveRoom?.roomName} 채팅방을 나가시겠어요?</p>
</div>
<div className="flex gap-2">
<button
type="button"
className="ml-4 h-11 w-37 flex-1 cursor-pointer rounded-[10px] border border-[#69BFDF] py-4 text-[14px] font-bold text-[#69BFDF]"
onClick={() => setLeaveRoom(null)}
>
취소
</button>
<button
type="button"
className="bg-primary-500 mr-4 flex-1 cursor-pointer rounded-[10px] py-4 text-[14px] font-medium text-white"
onClick={deleteChat}
>
나가기
</button>
</div>
</Modal>
<BottomModal isOpen={changeRoomName !== null} onClose={() => setChangeRoomName(null)} className="h-59">
<div className="flex items-center px-4 py-4">
<button type="button" aria-label="닫기" onClick={() => setChangeRoomName(null)}>
<ChevronLeftIcon />
</button>
<div className="px-30 text-center font-semibold">이름 변경</div>
</div>
<div className="flex w-full flex-col items-center gap-6">
<input
type="text"
value={newRoomName}
onChange={(e) => setNewRoomName(e.target.value)}
className="text-text-700 mt-11 h-[50px] w-[343px] rounded-2xl border border-indigo-50 text-center"
placeholder="변경할 채팅방명을 입력해주세요."
/>
<button
type="button"
className="bg-primary-500 w-[343px] flex-1 cursor-pointer rounded-[10px] py-4 text-[14px] font-medium text-white"
onClick={changeName}
>
확인
</button>
</div>
</BottomModal>
{contextMenu && (
<ChatRoomContextMenu
x={contextMenu.x}
y={contextMenu.y}
title={contextMenu.room.roomName}
items={contextMenuItems(contextMenu.room)}
onClose={() => setContextMenu(null)}
/>
)}
</div>
);
}
Expand Down
Loading
Loading