Skip to content

[feat] 채팅 UX와 공통 인터랙션 개선#285

Merged
ff1451 merged 6 commits intodevelopfrom
284-feat-채팅-ux와-공통-인터랙션-개선
Apr 16, 2026

Hidden character warning

The head ref may contain hidden characters: "284-feat-\ucc44\ud305-ux\uc640-\uacf5\ud1b5-\uc778\ud130\ub799\uc158-\uac1c\uc120"
Merged

[feat] 채팅 UX와 공통 인터랙션 개선#285
ff1451 merged 6 commits intodevelopfrom
284-feat-채팅-ux와-공통-인터랙션-개선

Conversation

@ff1451
Copy link
Copy Markdown
Collaborator

@ff1451 ff1451 commented Apr 16, 2026

✨ 요약

- 채팅 검색 결과와 메시지 이동/복귀 흐름을 정리했습니다.
- 채팅방 정보 화면을 추가하고 채팅 헤더 동작을 개선했습니다.
- 채팅방 추가 화면의 선택/로딩 UX와 초대 응답 타입을 정리했습니다.
- 공용 MemberAvatar 컴포넌트를 추출하고 타이머 랭킹 전환 반응성을 개선했습니다.



😎 해결한 이슈



Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 채팅방 정보 페이지 추가 — 멤버 목록, 멤버별 액션(1:1 채팅 생성·내보내기 등), 채팅방 나가기 지원
    • 멤버 아바타 컴포넌트 추가
  • 개선사항

    • 채팅 이전 메시지 양방향 스크롤 및 빠른 로드 지원
    • 채팅 검색 UI와 로딩/상태 표시 개선
    • 채팅방 추가 흐름 및 선택/확인 동작 개선
    • 드롭다운 테마 색상 적용 및 항목 순서 간소화

@ff1451 ff1451 requested a review from Copilot April 16, 2026 08:13
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 16, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7b955f76-31ab-4590-ac72-c586bde4cf5d

📥 Commits

Reviewing files that changed from the base of the PR and between 1877c71 and 32c2d08.

📒 Files selected for processing (4)
  • src/apis/chat/entity.ts
  • src/pages/Chat/components/ChatRoomListItem.tsx
  • src/pages/Chat/hooks/useChat.ts
  • src/pages/Timer/index.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/pages/Chat/hooks/useChat.ts

Walkthrough

채팅 UX 개선으로 채팅 검색/채팅방 추가/채팅방 정보 흐름을 변경했습니다. 라우트에 chats/:chatRoomId/info 페이지를 추가하고 일부 채팅 라우트를 레이아웃 서브트리로 이동했습니다. 채팅 엔티티 타입을 리네이밍하고 InvitableFriendsResponse라는 판별 유니온으로 재정의했으며, 메시지 쿼리 키와 무한 스크롤 페이징을 양방향(이전/다음)으로 확장했습니다. 공통 MemberAvatar 컴포넌트를 추출했고, 헤더 및 관련 컴포넌트들에서 정보/무음 토글과 내비게이션 흐름을 재구성했습니다.

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning Dropdown 컴포넌트의 배경색 변경(bg-[#69BFDF] → bg-primary-500)이 #284와 직접적인 연관이 없어 범위 외 변경으로 보입니다. Dropdown 스타일 변경이 이슈 요구사항과의 연관성을 설명하거나, 별도 PR로 분리하는 것을 고려하세요.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목은 채팅 UX와 공통 인터랙션 개선이라는 주요 변경사항을 명확하고 간결하게 요약하고 있습니다.
Linked Issues check ✅ Passed 채팅 검색/방 추가/방 정보 화면 구현, MemberAvatar 공용 컴포넌트 추출, 타이머 반응성 개선 등 모든 요구사항을 충족합니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 284-feat-채팅-ux와-공통-인터랙션-개선

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

채팅 검색/이동 흐름, 채팅방 정보 화면, 채팅방 추가 UX, 공용 아바타 컴포넌트 추출, 타이머 랭킹 상호작용 개선을 통해 전반적인 채팅 UX 및 공통 인터랙션을 정리하는 PR입니다. (Issue #284)

Changes:

  • 채팅 검색 결과에서 메시지 위치로 이동 후 복귀(backPath) 동작을 정리하고, 메시지/방 리스트 아이템을 공통 베이스로 리팩터링
  • 채팅방 정보(/info) 화면을 추가하고, 채팅 헤더를 정보 화면과 연동되도록 개선(알림 토글 포함)
  • 채팅방 추가 화면의 로딩/선택 UX를 개선하고, 초대 응답 타입을 명확한 유니온 타입으로 정리 + 공용 MemberAvatar 추출
  • 타이머 랭킹 탭/정렬 전환 시 렌더링 반응성 개선(useDeferredValue)

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/pages/Timer/index.tsx 랭킹 탭/정렬 전환 시 deferred 값 기반으로 UI 반응성/펜딩 표시 개선
src/pages/Manager/ManagedSheetImportPreview/index.tsx MemberAvatar 공용 컴포넌트로 import 경로 정리
src/pages/Manager/ManagedMemberList/components/RoleManageModal.tsx MemberAvatar 공용 컴포넌트로 import 경로 정리
src/pages/Manager/ManagedMemberList/components/MemberCard.tsx 로컬 MemberAvatar 제거 후 공용 MemberAvatar 사용
src/pages/Chat/hooks/useChatRoomScroll.ts targetMessageId 기반 하단 infinite scroll(이전/다음 페이지) 지원 추가
src/pages/Chat/hooks/useChatMutations.ts 메시지 전송 후 invalidate 키를 room-prefix(messagesByRoom)로 변경
src/pages/Chat/hooks/useChat.ts infinite query 이전/다음 페이지 노출 + 메시지 중복 제거 로직 추가
src/pages/Chat/components/ChatRoomListItem.tsx 방/메시지 검색 결과 아이템을 공통 베이스 컴포넌트로 통합, backPath state 전달 지원
src/pages/Chat/ChatSearch.tsx 검색어를 URL 쿼리에 반영 + placeholderData 기반 로딩/결과 UX 개선
src/pages/Chat/ChatRoomInfo.tsx 채팅방 정보 화면 신규 추가(멤버 목록/나가기/1:1 채팅 등)
src/pages/Chat/ChatRoom.tsx targetMessageId 기반 이전/다음 페이지 스크롤 로딩 훅 연동 + bottomRef 추가
src/pages/Chat/AddChatRoom.tsx 초대 리스트 로딩/선택 UX 개선 + 그룹 생성 mutation 직접 사용 + 타입 변경 반영
src/components/layout/Header/headerConfig.ts /chats/:id 및 /chats/:id/info에서 chat 헤더 매칭되도록 정규식 확장
src/components/layout/Header/components/ChatSearchHeader.tsx (삭제) 별도 ChatSearchHeader 제거
src/components/layout/Header/components/ChatHeader.tsx /info 화면 분기(알림 토글/뒤로가기/정보 진입)로 동작 개선
src/components/layout/Header/components/ChatAddHeader.tsx 확인 버튼 disabled 상태 지원
src/components/common/MemberAvatar.tsx 공용 MemberAvatar 컴포넌트 신규 추가
src/components/common/Dropdown.tsx 옵션 렌더 순서/스타일 일부 정리
src/apis/chat/queries.ts 메시지 queryKey를 room-prefix + messageId suffix 구조로 재정의, prev/next pageParam 지원
src/apis/chat/index.ts 초대 API 타입을 InvitableFriends*로 정리된 엔티티로 변경
src/apis/chat/entity.ts 초대 응답을 grouped/flat 유니온 타입으로 명확화 + 네이밍 정리
src/App.tsx ChatRoomInfo 라우트 추가 및 chats/search, chats/add 라우트 위치 정리

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +74 to +84
<div className="flex h-full flex-col items-center px-5 pt-6">
<div className="flex w-full shrink-0 items-center overflow-hidden rounded-full bg-white px-5 py-2.5">
<input
type="text"
value={keyword}
onChange={(e) => handleChange(e.target.value)}
className="h-full flex-1 bg-white px-3"
className="flex-1 text-[15px] leading-[1.6] text-indigo-300"
placeholder="채팅방명, 채팅으로 검색"
/>
<Search />
</label>
<SearchIcon />
</div>
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

The search input no longer has an associated label (it was previously wrapped in a <label>). For accessibility, add an explicit label (e.g., visually-hidden <label>) or at least an aria-label so screen readers can identify the control.

Copilot uses AI. Check for mistakes.
Comment on lines +173 to +182
<div className="flex w-full shrink-0 items-center overflow-hidden rounded-full bg-white px-5 py-2.5">
<input
type="text"
value={keyword}
onChange={(e) => handleChange(e.target.value)}
className="h-full flex-1 bg-white px-3"
className="flex-1 text-[15px] leading-[1.6] text-indigo-300"
placeholder="이름, 학번 검색"
/>
<Search />
</label>
<div className="mt-6 h-full w-87.5 overflow-y-auto rounded-t-2xl bg-white py-4">
<div className="flex w-full px-5">
<span className="text-#344352 flex-1 translate-y-2 text-[15px]">친구 선택({data?.currentCount})</span>
<SearchIcon />
</div>
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

This input is missing an accessible name (no <label> and no aria-label). Please add a proper label (visually hidden if needed) or an aria-label so the friend search field is discoverable to assistive tech users.

Copilot uses AI. Check for mistakes.
Comment on lines +28 to +35
const handleBack = () => {
if (isInfoPage && chatRoomId) {
navigate(`/chats/${chatRoomId}`, { state });
return;
}

<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>}
</div>
</div>

<button type="button" aria-label="채팅방 정보 열기" onClick={openSidebar} className="shrink-0">
<HamburgerIcon />
</button>
</header>
smartBack();
};
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

handleBack uses navigate(/chats/${chatRoomId}, { state }), which pushes a new history entry when leaving the info page. This can lead to an unexpected back-stack (e.g., back from the chat room returns to the info page again). Use history back (navigate(-1) / smartBack()) or navigate with { replace: true } when returning from /info to avoid stacking duplicate entries.

Copilot uses AI. Check for mistakes.
Comment thread src/pages/Chat/hooks/useChat.ts Outdated
Comment on lines 36 to 43
const allMessages =
chatMessagesData?.pages
.flatMap((page) => page.messages)
.filter(
(message, index, messages) =>
index === messages.findIndex((candidate) => candidate.messageId === message.messageId)
) ?? [];

Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

The message de-duplication uses filter(... findIndex ...), which is O(n²) over the flattened message list and can become expensive as more pages load. Consider de-duping in a single pass using a Set (or Map) keyed by messageId to keep this linear-time.

Suggested change
const allMessages =
chatMessagesData?.pages
.flatMap((page) => page.messages)
.filter(
(message, index, messages) =>
index === messages.findIndex((candidate) => candidate.messageId === message.messageId)
) ?? [];
const allMessages = (() => {
const messages = chatMessagesData?.pages.flatMap((page) => page.messages);
if (!messages) {
return [];
}
const seenMessageIds = new Set<number>();
return messages.filter((message) => {
if (seenMessageIds.has(message.messageId)) {
return false;
}
seenMessageIds.add(message.messageId);
return true;
});
})();

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/common/Dropdown.tsx (1)

66-75: ⚠️ Potential issue | 🟡 Minor

선택 점 표시 색상도 토큰으로 바꿔주세요.

Line 74의 bg-[#69BFDF]는 하드코드 색상입니다. 동일하게 토큰 클래스로 맞추는 게 좋습니다.

수정 예시
- {option.value === value ? <span aria-hidden="true" className="size-1 rounded-full bg-[`#69BFDF`]" /> : null}
+ {option.value === value ? <span aria-hidden="true" className="size-1 rounded-full bg-primary-500" /> : null}

As per coding guidelines, "Prioritize color tokens from src/styles/theme.css (e.g., indigo-*, blue-*, background, primary) over hardcoded colors".

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

In `@src/components/common/Dropdown.tsx` around lines 66 - 75, The selection
indicator currently uses a hardcoded color class bg-[`#69BFDF`] inside the
Dropdown component's options.map button (the span rendered when option.value ===
value); replace that hardcoded color with a theme token class (e.g., one of the
project's color tokens like blue-*/indigo-*/primary/background) so the selection
dot uses the centralized token from src/styles/theme.css and matches other UI
colors; update the span inside the options.map rendering in Dropdown.tsx to use
the chosen token class instead of bg-[`#69BFDF`] and ensure styling remains a
rounded-full size-1 indicator.
🧹 Nitpick comments (4)
src/pages/Timer/index.tsx (1)

34-35: useDeferredValue를 각각 분리하면 랭킹 쿼리 조합이 잠깐 꼬일 수 있어요.

Line 34-35처럼 activeTab/sort를 따로 defer하면 빠르게 연속 변경할 때 RankingList가 실제로 선택된 적 없는 조합(예: 이전 탭 + 새 정렬값)을 받을 수 있습니다. 지금은 Line 108-109의 query key가 그 값에 바로 연결돼 있어서 불필요한 fetch가 나가고, Line 42의 pending 상태도 그 중간 조합 기준으로 흔들릴 수 있습니다. 랭킹 파라미터를 하나로 묶어서 defer하는 쪽이 안전합니다.

예시 수정안
-import { useDeferredValue, useRef, useState } from 'react';
+import { useDeferredValue, useMemo, useRef, useState } from 'react';
...
-  const deferredActiveTab = useDeferredValue(activeTab);
-  const deferredSort = useDeferredValue(sort);
+  const rankingParams = useMemo(
+    () => ({
+      type: TAB_TO_TYPE[activeTab],
+      sort,
+    }),
+    [activeTab, sort]
+  );
+  const deferredRankingParams = useDeferredValue(rankingParams);
...
-  const isRankingPending = activeTab !== deferredActiveTab || sort !== deferredSort;
+  const isRankingPending =
+    rankingParams.type !== deferredRankingParams.type || rankingParams.sort !== deferredRankingParams.sort;
...
-                type={TAB_TO_TYPE[deferredActiveTab]}
-                sort={deferredSort}
+                type={deferredRankingParams.type}
+                sort={deferredRankingParams.sort}

Also applies to: 42-42, 108-109

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

In `@src/pages/Timer/index.tsx` around lines 34 - 35, Currently activeTab and sort
are deferred separately (deferredActiveTab, deferredSort) which can produce
transient, invalid combinations for RankingList and cause unnecessary fetches;
instead, group the ranking params into a single object (e.g., rankingParams = {
activeTab, sort }), call useDeferredValue once on that object (e.g.,
deferredRankingParams = useDeferredValue(rankingParams)), and then change all
uses—the query key (currently tied to deferredActiveTab/deferredSort around the
RankingList query at the code labeled with lines ~108-109) and the pending state
check (the pending flag near line ~42)—to read from deferredRankingParams so the
component and fetches always use a consistent, atomically-deferred parameter
set.
src/pages/Chat/hooks/useChat.ts (1)

36-42: 메시지 중복 제거 로직 - 성능 고려

findIndex를 사용한 중복 제거는 O(n²) 복잡도입니다. 현재 페이지네이션으로 n이 제한되어 있어 실질적 문제는 없지만, 메시지가 많아지면 Map 또는 Set 기반 dedup을 고려해볼 수 있습니다.

♻️ 성능 개선 제안 (선택사항)
  const allMessages =
    chatMessagesData?.pages
      .flatMap((page) => page.messages)
-      .filter(
-        (message, index, messages) =>
-          index === messages.findIndex((candidate) => candidate.messageId === message.messageId)
-      ) ?? [];
+      .reduce<ChatMessage[]>((acc, message) => {
+        if (!acc.some((m) => m.messageId === message.messageId)) {
+          acc.push(message);
+        }
+        return acc;
+      }, []) ?? [];

또는 Map 사용:

const allMessages = chatMessagesData?.pages
  ? [...new Map(
      chatMessagesData.pages.flatMap((page) => page.messages)
        .map((m) => [m.messageId, m])
    ).values()]
  : [];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/Chat/hooks/useChat.ts` around lines 36 - 42, The current dedup
logic for allMessages in useChat.ts uses findIndex causing O(n²) behavior;
replace it with a Map/Set-based dedup to get O(n) performance: iterate
chatMessagesData?.pages.flatMap(page => page.messages) and insert each message
into a Map keyed by message.messageId (or use a Set to track seen messageIds) to
keep the first (or last) occurrence and then return Array.from(map.values()) (or
filter by seen Set) as allMessages; reference the allMessages declaration,
chatMessagesData.pages, page.messages, and messageId when making the change.
src/pages/Chat/ChatSearch.tsx (1)

31-33: key 속성 개선 권장

index를 key의 일부로 사용하면 메시지 순서가 바뀔 때 리렌더링 이슈가 발생할 수 있습니다. 서버에서 고유 식별자를 제공한다면 그것을 사용하는 것이 좋습니다.

♻️ 개선 제안
  {data?.messageMatches?.messages?.map((message, index) => (
    <ChatMessageListItem
-      key={`${message.roomId}-${index}`}
+      key={`${message.roomId}-${message.messageId}`}
      message={message}
      keyword={keyword}
      navigationState={navigationState}
    />
  ))}

만약 message.messageId가 없다면 현재 방식도 괜찮습니다.

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

In `@src/pages/Chat/ChatSearch.tsx` around lines 31 - 33, The current key on
ChatMessageListItem uses `${message.roomId}-${index}` which can cause unstable
keys when message order changes; update the key in src/pages/Chat/ChatSearch.tsx
to use the message's unique identifier (e.g. message.messageId or message.id)
instead of the array index, e.g. use `message.messageId ??
`${message.roomId}-${index}`` as the key when rendering
data?.messageMatches?.messages in the ChatMessageListItem map so you prefer a
stable server-provided id and fall back to the previous value only if no unique
id exists.
src/pages/Chat/ChatRoomInfo.tsx (1)

77-77: 하드코딩된 색상 #ff4e4e 대신 테마 토큰 사용 권장

src/styles/theme.css의 색상 토큰 사용을 권장합니다. 빨간색 관련 토큰이 없다면 추가하거나 기존 토큰 활용을 검토하세요.

Also applies to: 214-214

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

In `@src/pages/Chat/ChatRoomInfo.tsx` at line 77, The element in ChatRoomInfo (the
JSX with className containing bg-[`#ff4e4e`]) uses a hardcoded red color; replace
that hardcoded color with the appropriate theme token from src/styles/theme.css
(e.g., bg-[var(--color-danger)] or the project's token naming) and, if no
suitable red/danger token exists, add one to theme.css and use it here; apply
the same replacement for the other occurrence of bg-[`#ff4e4e`] in this file so
both uses reference the theme token instead of the hex literal.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/pages/Chat/AddChatRoom.tsx`:
- Around line 29-35: The button for each member is only as wide as its content
and lacks an accessible toggled state; update the button element (the one using
onClick={() => onToggle(userId)}) to span the full width (e.g., add a
container/full-width class to the button so empty space is clickable) and add
aria-pressed={isSelected} so the toggle state is exposed to assistive tech; keep
the existing onToggle(userId), MemberAvatar, name/studentNumber span and
conditional CheckIcon usage but ensure the button has the full-width class and
the aria-pressed attribute tied to isSelected.
- Around line 173-180: The input's class currently uses text-indigo-300 which
applies the same faded color to both the typed text and the placeholder; update
the input element in AddChatRoom.tsx (the <input ... value={keyword}
onChange={(e) => handleChange(e.target.value)} ... />) to use a stronger color
for actual text (e.g., text-indigo-900 or text-gray-900) and a lighter color
specifically for the placeholder using Tailwind's placeholder: utility (e.g.,
placeholder:text-indigo-300); modify the className to remove text-indigo-300 and
add both the main text color and placeholder:text-indigo-300 so typed text
appears darker while the placeholder remains muted.
- Line 2: 해당 컴포넌트에서 useQuery + keepPreviousData 패턴을 useSuspenseQuery 패턴으로 바꿔주세요:
AddChatRoom 컴포넌트에서 현재 사용 중인 useQuery와 keepPreviousData를 제거하고 대신
'@tanstack/react-query'의 useSuspenseQuery를 import/사용하도록 변경하며, 쿼리 옵션에서
keepPreviousData/placeholderData를 제거(간혹 suspense: true가 필요함)하고 데이터 전환 시에는 이미
import된 startTransition을 사용해 상태 업데이트(예: 채팅 룸 선택/파라미터 변경)를 startTransition 블록 안에서
수행해 이전 UI를 유지하도록 처리하세요; 또한 suspense 경계(Suspense/ReactQuerySuspense boundary)가
존재하는지 확인해 주세요.

In `@src/pages/Chat/ChatRoomInfo.tsx`:
- Line 100: The code converts chatRoomId to a number with const numericRoomId =
Number(chatRoomId) which yields NaN when chatRoomId is undefined; update
ChatRoomInfo to validate chatRoomId (e.g., check typeof chatRoomId !== 'string'
or !chatRoomId) before conversion, and handle invalid cases with an early return
or redirect (render a fallback UI or call navigation/redirect) rather than
proceeding with numericRoomId; use the validated/parsed value only when
Number(chatRoomId) is finite (Number.isFinite) and reference numericRoomId and
chatRoomId in the guard so the component never operates on NaN.

In `@src/pages/Chat/components/ChatRoomListItem.tsx`:
- Around line 122-139: In ChatMessageListItem, guard against an empty keyword
before calling message.matchedMessage.split(keyword): if keyword is falsy or
empty, set parts to [message.matchedMessage] (or render matchedMessage directly)
so you don't call split('') and won't bold every character; update the parts
computation and the preview mapping (used in ChatListItemBase preview prop) to
handle this branch so only intended keyword occurrences are highlighted.

---

Outside diff comments:
In `@src/components/common/Dropdown.tsx`:
- Around line 66-75: The selection indicator currently uses a hardcoded color
class bg-[`#69BFDF`] inside the Dropdown component's options.map button (the span
rendered when option.value === value); replace that hardcoded color with a theme
token class (e.g., one of the project's color tokens like
blue-*/indigo-*/primary/background) so the selection dot uses the centralized
token from src/styles/theme.css and matches other UI colors; update the span
inside the options.map rendering in Dropdown.tsx to use the chosen token class
instead of bg-[`#69BFDF`] and ensure styling remains a rounded-full size-1
indicator.

---

Nitpick comments:
In `@src/pages/Chat/ChatRoomInfo.tsx`:
- Line 77: The element in ChatRoomInfo (the JSX with className containing
bg-[`#ff4e4e`]) uses a hardcoded red color; replace that hardcoded color with the
appropriate theme token from src/styles/theme.css (e.g.,
bg-[var(--color-danger)] or the project's token naming) and, if no suitable
red/danger token exists, add one to theme.css and use it here; apply the same
replacement for the other occurrence of bg-[`#ff4e4e`] in this file so both uses
reference the theme token instead of the hex literal.

In `@src/pages/Chat/ChatSearch.tsx`:
- Around line 31-33: The current key on ChatMessageListItem uses
`${message.roomId}-${index}` which can cause unstable keys when message order
changes; update the key in src/pages/Chat/ChatSearch.tsx to use the message's
unique identifier (e.g. message.messageId or message.id) instead of the array
index, e.g. use `message.messageId ?? `${message.roomId}-${index}`` as the key
when rendering data?.messageMatches?.messages in the ChatMessageListItem map so
you prefer a stable server-provided id and fall back to the previous value only
if no unique id exists.

In `@src/pages/Chat/hooks/useChat.ts`:
- Around line 36-42: The current dedup logic for allMessages in useChat.ts uses
findIndex causing O(n²) behavior; replace it with a Map/Set-based dedup to get
O(n) performance: iterate chatMessagesData?.pages.flatMap(page => page.messages)
and insert each message into a Map keyed by message.messageId (or use a Set to
track seen messageIds) to keep the first (or last) occurrence and then return
Array.from(map.values()) (or filter by seen Set) as allMessages; reference the
allMessages declaration, chatMessagesData.pages, page.messages, and messageId
when making the change.

In `@src/pages/Timer/index.tsx`:
- Around line 34-35: Currently activeTab and sort are deferred separately
(deferredActiveTab, deferredSort) which can produce transient, invalid
combinations for RankingList and cause unnecessary fetches; instead, group the
ranking params into a single object (e.g., rankingParams = { activeTab, sort }),
call useDeferredValue once on that object (e.g., deferredRankingParams =
useDeferredValue(rankingParams)), and then change all uses—the query key
(currently tied to deferredActiveTab/deferredSort around the RankingList query
at the code labeled with lines ~108-109) and the pending state check (the
pending flag near line ~42)—to read from deferredRankingParams so the component
and fetches always use a consistent, atomically-deferred parameter set.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 03f85ac3-87e4-46ae-818f-1afb55d51eb7

📥 Commits

Reviewing files that changed from the base of the PR and between d53d46c and 1877c71.

📒 Files selected for processing (22)
  • src/App.tsx
  • src/apis/chat/entity.ts
  • src/apis/chat/index.ts
  • src/apis/chat/queries.ts
  • src/components/common/Dropdown.tsx
  • src/components/common/MemberAvatar.tsx
  • src/components/layout/Header/components/ChatAddHeader.tsx
  • src/components/layout/Header/components/ChatHeader.tsx
  • src/components/layout/Header/components/ChatSearchHeader.tsx
  • src/components/layout/Header/headerConfig.ts
  • src/pages/Chat/AddChatRoom.tsx
  • src/pages/Chat/ChatRoom.tsx
  • src/pages/Chat/ChatRoomInfo.tsx
  • src/pages/Chat/ChatSearch.tsx
  • src/pages/Chat/components/ChatRoomListItem.tsx
  • src/pages/Chat/hooks/useChat.ts
  • src/pages/Chat/hooks/useChatMutations.ts
  • src/pages/Chat/hooks/useChatRoomScroll.ts
  • src/pages/Manager/ManagedMemberList/components/MemberCard.tsx
  • src/pages/Manager/ManagedMemberList/components/RoleManageModal.tsx
  • src/pages/Manager/ManagedSheetImportPreview/index.tsx
  • src/pages/Timer/index.tsx
💤 Files with no reviewable changes (1)
  • src/components/layout/Header/components/ChatSearchHeader.tsx

import { Fragment, useState } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';
import { startTransition, useState } from 'react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
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

🌐 Web query:

TanStack Query의 useSuspenseQueryplaceholderData 또는 keep previous data 패턴을 현재 버전에서 지원하는지 확인해주세요.

💡 Result:

TanStack Query의 useSuspenseQuery는 현재 버전(v5.99.0 기준)에서 placeholderData와 keep previous data 패턴(placeholderData: keepPreviousData)을 지원하지 않습니다. 공식 문서에 따르면 useSuspenseQuery의 옵션은 useQuery와 동일하지만 placeholderData, enabled, throwOnError를 제외합니다. 이는 Suspense와의 호환성 때문으로, placeholderData를 사용하면 pending 상태를 건너뛰어 Suspense가 제대로 작동하지 않기 때문입니다. 대신 React의 useTransition이나 useDeferredValue를 사용하여 이전 데이터를 유지하면서 fetching 상태를 처리하세요.

Citations:


🏁 Script executed:

# Check the actual code in AddChatRoom.tsx to understand context
cat -n src/pages/Chat/AddChatRoom.tsx | head -100

Repository: BCSDLab/KONECT_FRONT_END

Length of output: 4781


🏁 Script executed:

# Search for useTransition usage in the codebase to see if it's a known pattern
rg "useTransition" src/ -A 2 -B 2

Repository: BCSDLab/KONECT_FRONT_END

Length of output: 50


🏁 Script executed:

# Check if there are other pages that use keepPreviousData with useSuspenseQuery attempts
rg "keepPreviousData|useSuspenseQuery" src/pages/ -B 2 -A 2

Repository: BCSDLab/KONECT_FRONT_END

Length of output: 25801


useSuspenseQuery 패턴을 따르세요.

이 페이지에서만 useQuery + keepPreviousData를 사용하고 있어 서버 상태 관리 방식이 레포 전체 관례와 맞지 않습니다. 이미 startTransition을 import했으니 useSuspenseQuery로 변경하고 트랜지션 처리로 이전 데이터 유지를 구현하세요. (TanStack Query v5+에서 useSuspenseQueryplaceholderData 미지원)

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

In `@src/pages/Chat/AddChatRoom.tsx` at line 2, 해당 컴포넌트에서 useQuery +
keepPreviousData 패턴을 useSuspenseQuery 패턴으로 바꿔주세요: AddChatRoom 컴포넌트에서 현재 사용 중인
useQuery와 keepPreviousData를 제거하고 대신 '@tanstack/react-query'의 useSuspenseQuery를
import/사용하도록 변경하며, 쿼리 옵션에서 keepPreviousData/placeholderData를 제거(간혹 suspense:
true가 필요함)하고 데이터 전환 시에는 이미 import된 startTransition을 사용해 상태 업데이트(예: 채팅 룸 선택/파라미터
변경)를 startTransition 블록 안에서 수행해 이전 UI를 유지하도록 처리하세요; 또한 suspense
경계(Suspense/ReactQuerySuspense boundary)가 존재하는지 확인해 주세요.

Comment on lines +29 to +35
<button type="button" className="flex items-center gap-3 text-left" onClick={() => onToggle(userId)}>
<MemberAvatar name={name} />
<span className="flex w-full items-center text-[15px] leading-[1.6] font-semibold text-indigo-700">
{name} ({studentNumber})
</span>
{isSelected && <CheckIcon className="size-6.5" />}
</button>
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 | 🟡 Minor

선택 행 버튼이 너무 좁고 상태도 노출되지 않습니다.

지금은 버튼이 콘텐츠 너비만큼만 클릭 가능해서 모바일에서 빈 영역 탭이 먹지 않습니다. 토글형 액션이라 aria-pressed도 같이 주는 편이 안전합니다.

제안 코드
-    <button type="button" className="flex items-center gap-3 text-left" onClick={() => onToggle(userId)}>
+    <button
+      type="button"
+      className="flex w-full items-center gap-3 text-left"
+      onClick={() => onToggle(userId)}
+      aria-pressed={isSelected}
+    >
       <MemberAvatar name={name} />
-      <span className="flex w-full items-center text-[15px] leading-[1.6] font-semibold text-indigo-700">
+      <span className="min-w-0 flex-1 truncate text-[15px] leading-[1.6] font-semibold text-indigo-700">
         {name} ({studentNumber})
       </span>
       {isSelected && <CheckIcon className="size-6.5" />}
     </button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button type="button" className="flex items-center gap-3 text-left" onClick={() => onToggle(userId)}>
<MemberAvatar name={name} />
<span className="flex w-full items-center text-[15px] leading-[1.6] font-semibold text-indigo-700">
{name} ({studentNumber})
</span>
{isSelected && <CheckIcon className="size-6.5" />}
</button>
<button
type="button"
className="flex w-full items-center gap-3 text-left"
onClick={() => onToggle(userId)}
aria-pressed={isSelected}
>
<MemberAvatar name={name} />
<span className="min-w-0 flex-1 truncate text-[15px] leading-[1.6] font-semibold text-indigo-700">
{name} ({studentNumber})
</span>
{isSelected && <CheckIcon className="size-6.5" />}
</button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/Chat/AddChatRoom.tsx` around lines 29 - 35, The button for each
member is only as wide as its content and lacks an accessible toggled state;
update the button element (the one using onClick={() => onToggle(userId)}) to
span the full width (e.g., add a container/full-width class to the button so
empty space is clickable) and add aria-pressed={isSelected} so the toggle state
is exposed to assistive tech; keep the existing onToggle(userId), MemberAvatar,
name/studentNumber span and conditional CheckIcon usage but ensure the button
has the full-width class and the aria-pressed attribute tied to isSelected.

Comment on lines +173 to 180
<div className="flex w-full shrink-0 items-center overflow-hidden rounded-full bg-white px-5 py-2.5">
<input
type="text"
value={keyword}
onChange={(e) => handleChange(e.target.value)}
className="h-full flex-1 bg-white px-3"
className="flex-1 text-[15px] leading-[1.6] text-indigo-300"
placeholder="이름, 학번 검색"
/>
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 | 🟡 Minor

입력값 색상과 placeholder 색상을 분리해주세요.

text-indigo-300가 실제 입력 텍스트에도 적용돼서, 사용자가 타이핑해도 placeholder처럼 보입니다. 본문 색상으로 두고 placeholder만 옅게 처리하는 쪽이 더 자연스럽습니다.

제안 코드
-          className="flex-1 text-[15px] leading-[1.6] text-indigo-300"
+          className="text-text-700 placeholder:text-indigo-300 flex-1 text-[15px] leading-[1.6]"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className="flex w-full shrink-0 items-center overflow-hidden rounded-full bg-white px-5 py-2.5">
<input
type="text"
value={keyword}
onChange={(e) => handleChange(e.target.value)}
className="h-full flex-1 bg-white px-3"
className="flex-1 text-[15px] leading-[1.6] text-indigo-300"
placeholder="이름, 학번 검색"
/>
<input
type="text"
value={keyword}
onChange={(e) => handleChange(e.target.value)}
className="text-text-700 placeholder:text-indigo-300 flex-1 text-[15px] leading-[1.6]"
placeholder="이름, 학번 검색"
/>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/Chat/AddChatRoom.tsx` around lines 173 - 180, The input's class
currently uses text-indigo-300 which applies the same faded color to both the
typed text and the placeholder; update the input element in AddChatRoom.tsx (the
<input ... value={keyword} onChange={(e) => handleChange(e.target.value)} ...
/>) to use a stronger color for actual text (e.g., text-indigo-900 or
text-gray-900) and a lighter color specifically for the placeholder using
Tailwind's placeholder: utility (e.g., placeholder:text-indigo-300); modify the
className to remove text-indigo-300 and add both the main text color and
placeholder:text-indigo-300 so typed text appears darker while the placeholder
remains muted.

function ChatRoomInfo() {
const navigate = useNavigate();
const { chatRoomId } = useParams();
const numericRoomId = Number(chatRoomId);
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 | 🟡 Minor

chatRoomId undefined 시 NaN 처리 필요

Number(undefined)NaN을 반환합니다. 유효하지 않은 경우 리다이렉트 또는 early return 고려하세요.

🛡️ 제안
  const { chatRoomId } = useParams();
  const numericRoomId = Number(chatRoomId);
+
+ if (!chatRoomId || Number.isNaN(numericRoomId)) {
+   return <Navigate to="/chats" replace />;
+ }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const numericRoomId = Number(chatRoomId);
const { chatRoomId } = useParams();
const numericRoomId = Number(chatRoomId);
if (!chatRoomId || Number.isNaN(numericRoomId)) {
return <Navigate to="/chats" replace />;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/Chat/ChatRoomInfo.tsx` at line 100, The code converts chatRoomId to
a number with const numericRoomId = Number(chatRoomId) which yields NaN when
chatRoomId is undefined; update ChatRoomInfo to validate chatRoomId (e.g., check
typeof chatRoomId !== 'string' or !chatRoomId) before conversion, and handle
invalid cases with an early return or redirect (render a fallback UI or call
navigation/redirect) rather than proceeding with numericRoomId; use the
validated/parsed value only when Number(chatRoomId) is finite (Number.isFinite)
and reference numericRoomId and chatRoomId in the guard so the component never
operates on NaN.

Comment on lines +122 to +139
export function ChatMessageListItem({ message, keyword, navigationState }: ChatMessageListItemProps) {
const parts = message.matchedMessage.split(keyword);

return (
<ChatListItemBase
to={`/chats/${message.roomId}?messageId=${message.matchedMessageId}`}
roomImageUrl={message.roomImageUrl}
roomName={message.roomName}
sentAt={message.matchedMessageSentAt}
navigationState={navigationState}
preview={parts.map((part, index) => (
<Fragment key={index}>
{part}
{index < parts.length - 1 && <span className="font-bold">{keyword}</span>}
</Fragment>
))}
/>
);
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 | 🟡 Minor

keyword 엣지케이스 처리 필요

split('')은 모든 문자를 분리합니다. keyword가 빈 문자열일 경우를 방어하세요.

🛡️ 제안
 export function ChatMessageListItem({ message, keyword, navigationState }: ChatMessageListItemProps) {
-  const parts = message.matchedMessage.split(keyword);
+  const parts = keyword ? message.matchedMessage.split(keyword) : [message.matchedMessage];

   return (
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function ChatMessageListItem({ message, keyword, navigationState }: ChatMessageListItemProps) {
const parts = message.matchedMessage.split(keyword);
return (
<ChatListItemBase
to={`/chats/${message.roomId}?messageId=${message.matchedMessageId}`}
roomImageUrl={message.roomImageUrl}
roomName={message.roomName}
sentAt={message.matchedMessageSentAt}
navigationState={navigationState}
preview={parts.map((part, index) => (
<Fragment key={index}>
{part}
{index < parts.length - 1 && <span className="font-bold">{keyword}</span>}
</Fragment>
))}
/>
);
export function ChatMessageListItem({ message, keyword, navigationState }: ChatMessageListItemProps) {
const parts = keyword ? message.matchedMessage.split(keyword) : [message.matchedMessage];
return (
<ChatListItemBase
to={`/chats/${message.roomId}?messageId=${message.matchedMessageId}`}
roomImageUrl={message.roomImageUrl}
roomName={message.roomName}
sentAt={message.matchedMessageSentAt}
navigationState={navigationState}
preview={parts.map((part, index) => (
<Fragment key={index}>
{part}
{index < parts.length - 1 && <span className="font-bold">{keyword}</span>}
</Fragment>
))}
/>
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/pages/Chat/components/ChatRoomListItem.tsx` around lines 122 - 139, In
ChatMessageListItem, guard against an empty keyword before calling
message.matchedMessage.split(keyword): if keyword is falsy or empty, set parts
to [message.matchedMessage] (or render matchedMessage directly) so you don't
call split('') and won't bold every character; update the parts computation and
the preview mapping (used in ChatListItemBase preview prop) to handle this
branch so only intended keyword occurrences are highlighted.

@ff1451 ff1451 merged commit abc285c into develop Apr 16, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] 채팅 UX와 공통 인터랙션 개선

2 participants