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
6 changes: 4 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const ChatListPage = lazy(() => import('./pages/Chat'));
const ChatSearch = lazy(() => import('./pages/Chat/ChatSearch'));
const ChatAdd = lazy(() => import('./pages/Chat/AddChatRoom'));
const ChatRoom = lazy(() => import('./pages/Chat/ChatRoom'));
const ChatRoomInfo = lazy(() => import('./pages/Chat/ChatRoomInfo'));
const ApplicationPage = lazy(() => import('./pages/Club/Application'));
const ApplyCompletePage = lazy(() => import('./pages/Club/Application/applyCompletePage'));
const ClubFeePage = lazy(() => import('./pages/Club/Application/clubFeePage'));
Expand Down Expand Up @@ -88,6 +89,8 @@ function App() {
<Route element={<Layout showBottomNav />}>
<Route path="home" element={<Home />} />
<Route path="notifications" element={<NotificationsPage />} />
<Route path="chats/search" element={<ChatSearch />} />
<Route path="chats/add" element={<ChatAdd />} />
<Route path="council">
<Route index element={<CouncilDetail />} />
<Route path="notice/:noticeId" element={<CouncilNotice />} />
Expand Down Expand Up @@ -122,9 +125,8 @@ function App() {
<Route path="mypage/manager/:clubId/recruitment/account" element={<ManagedAccount />} />
<Route path="mypage/manager/:clubId/members/sheet/preview" element={<ManagedSheetImportPreview />} />
<Route path="profile" element={<Profile />} />
<Route path="chats/search" element={<ChatSearch />} />
<Route path="chats/add" element={<ChatAdd />} />
<Route path="chats/:chatRoomId" element={<ChatRoom />} />
<Route path="chats/:chatRoomId/info" element={<ChatRoomInfo />} />
</Route>
</Route>

Expand Down
31 changes: 23 additions & 8 deletions src/apis/chat/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export interface Messages {
matchedMessage: string;
matchedMessageSentAt: string;
matchedMessageId: number;
unreadCount: number;
isMuted: boolean;
}

export interface RoomMatched extends PaginationResponse {
Expand All @@ -75,27 +77,40 @@ export interface MatchResponse {
messageMatches?: MessageMatched;
}

export interface User {
export interface InvitableUser {
userId: number;
name: string;
imageUrl: string;
studentNumber: string;
}

export interface Section {
export interface InvitableSection {
clubId: number;
clubName: string;
users: User[];
users: InvitableUser[];
}

export interface InvitableFriendRequestParams extends PaginationParams {
export interface InvitableFriendsRequestParams extends PaginationParams {
query: string;
sortBy: SortBy;
}

export interface InvitableFriend extends PaginationResponse {
interface InvitableFriendsBase extends PaginationResponse {
sortBy: SortBy;
grouped: boolean;
users?: User[];
sections?: Section[];
}

export interface GroupedInvitableFriendsResponse extends InvitableFriendsBase {
sortBy: 'CLUB';
grouped: true;
users: [];
sections: InvitableSection[];
}

export interface FlatInvitableFriendsResponse extends InvitableFriendsBase {
sortBy: 'NAME';
grouped: false;
users: InvitableUser[];
sections: [];
}

export type InvitableFriendsResponse = GroupedInvitableFriendsResponse | FlatInvitableFriendsResponse;
8 changes: 4 additions & 4 deletions src/apis/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import type {
ChatRoomsResponse,
CreateChatRoomResponse,
SendChatMessageRequest,
InvitableFriendsRequestParams,
InvitableFriendsResponse,
MatchResponse,
MatchedRequestParams,
InvitableFriendRequestParams,
InvitableFriend,
} from './entity';

export const getChatRooms = async () => {
Expand Down Expand Up @@ -85,8 +85,8 @@ export const getSearchChat = async ({ ...query }: MatchedRequestParams) => {
return response;
};

export const getInvitableFriends = async ({ ...query }: InvitableFriendRequestParams) => {
const response = await apiClient.get<InvitableFriend>('chats/rooms/invitables', {
export const getInvitableFriends = async ({ ...query }: InvitableFriendsRequestParams) => {
const response = await apiClient.get<InvitableFriendsResponse>('chats/rooms/invitables', {
params: query,
requiresAuth: true,
});
Expand Down
25 changes: 19 additions & 6 deletions src/apis/chat/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@ import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query';
import { getChatMessages, getChatRooms, getSearchChat, getInvitableFriends } from '@/apis/chat';
import type { ChatMessagesResponse, SortBy } from '@/apis/chat/entity';

interface ChatMessagesPageParam {
page: number;
useMessageId: boolean;
}

export const chatQueryKeys = {
all: ['chat'] as const,
rooms: () => [...chatQueryKeys.all, 'rooms'] as const,
messages: (chatRoomId: number) => [...chatQueryKeys.all, 'messages', chatRoomId] as const,
messagesByRoom: (chatRoomId: number) => [...chatQueryKeys.all, 'messages', chatRoomId] as const,
messages: (chatRoomId: number, messageId?: number) =>
[...chatQueryKeys.messagesByRoom(chatRoomId), messageId ?? 'latest'] as const,
disabledMessages: () => [...chatQueryKeys.all, 'messages', 'disabled'] as const,
search: (keyword: string) => [...chatQueryKeys.all, 'search', keyword],
invite: (query: string, sortBy: SortBy) => [...chatQueryKeys.all, 'invite', query, sortBy],
Expand All @@ -19,17 +26,23 @@ export const chatQueries = {
}),
messages: (chatRoomId?: number, messageId?: number, limit = 20) =>
infiniteQueryOptions({
queryKey: chatRoomId ? chatQueryKeys.messages(chatRoomId) : chatQueryKeys.disabledMessages(),
queryKey: chatRoomId ? chatQueryKeys.messages(chatRoomId, messageId) : chatQueryKeys.disabledMessages(),
queryFn: ({ pageParam }) =>
getChatMessages({
chatRoomId: chatRoomId!,
messageId: messageId,
page: pageParam,
messageId: pageParam.useMessageId ? messageId : undefined,
page: pageParam.page,
limit,
}),
initialPageParam: 1,
initialPageParam: { page: 1, useMessageId: Boolean(messageId) } satisfies ChatMessagesPageParam,
getNextPageParam: (lastPage: ChatMessagesResponse) =>
lastPage.currentPage < lastPage.totalPage ? lastPage.currentPage + 1 : undefined,
lastPage.currentPage < lastPage.totalPage
? ({ page: lastPage.currentPage + 1, useMessageId: false } satisfies ChatMessagesPageParam)
: undefined,
getPreviousPageParam: (firstPage: ChatMessagesResponse) =>
firstPage.currentPage > 1
? ({ page: firstPage.currentPage - 1, useMessageId: false } satisfies ChatMessagesPageParam)
: undefined,
enabled: Boolean(chatRoomId),
}),
search: (keyword: string) =>
Expand Down
8 changes: 2 additions & 6 deletions src/components/common/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,6 @@ export default function Dropdown<T extends string>({
useClickTouchOutside(dropdownRef, () => setIsOpen(false));

const selectedOption = options.find((opt) => opt.value === value);
const orderedOptions = [
...options.filter((option) => option.value !== value),
...options.filter((option) => option.value === value),
];

const handleSelect = (optionValue: T) => {
onChange(optionValue);
Expand All @@ -47,7 +43,7 @@ export default function Dropdown<T extends string>({
type="button"
onClick={() => setIsOpen(!isOpen)}
className={cn(
'inline-flex h-7.25 items-center justify-evenly overflow-hidden rounded-full bg-[#69BFDF] px-2.5 text-[13px] leading-[1.6] font-medium text-white transition-opacity active:opacity-90',
'bg-primary-500 inline-flex h-7.25 items-center justify-evenly overflow-hidden rounded-full px-2.5 text-[13px] leading-[1.6] font-medium text-white transition-opacity active:opacity-90',
isOpen && 'opacity-95',
triggerClassName
)}
Expand All @@ -67,7 +63,7 @@ export default function Dropdown<T extends string>({
)}
>
<div className="flex min-w-14 flex-col gap-1">
{orderedOptions.map((option) => (
{options.map((option) => (
<button
key={option.value}
type="button"
Expand Down
10 changes: 10 additions & 0 deletions src/components/common/MemberAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export function MemberAvatar({ name }: { name: string }) {
return (
<div
className="bg-indigo-25 text-text-600 flex size-10 shrink-0 items-center justify-center rounded-[10px] text-[15px] leading-6 font-medium"
aria-hidden="true"
>
{name.charAt(0)}
</div>
);
}
16 changes: 11 additions & 5 deletions src/components/layout/Header/components/ChatAddHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
import BackTitleHeader from '@/components/layout/Header/components/BackTitleHeader';

interface ChatListHeaderProps {
disabled?: boolean;
title: string;
onConfirm: () => void;
}

export default function ChatAddHeader({ title, onConfirm }: ChatListHeaderProps) {
const confirm = (
<button type="button" onClick={onConfirm}>
<span className="text-primary-500">확인</span>
export default function ChatAddHeader({ disabled = false, title, onConfirm }: ChatListHeaderProps) {
const confirmButton = (
<button
type="button"
onClick={onConfirm}
disabled={disabled}
className="text-primary-500 disabled:text-primary-300"
>
확인
</button>
);

return (
<BackTitleHeader
title={title}
rightSlot={confirm}
rightSlot={confirmButton}
headerClassName="h-13 rounded-b-3xl px-4 py-3 shadow-[0_0_20px_0_rgba(0,0,0,0.03)]"
rightSlotContainerClassName="flex items-center gap-2"
/>
Expand Down
124 changes: 55 additions & 69 deletions src/components/layout/Header/components/ChatHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,97 +1,83 @@
import type { Ref } from 'react';
import { useParams } from 'react-router-dom';
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
import ChevronLeftIcon from '@/assets/svg/chevron-left.svg';
import HamburgerIcon from '@/assets/svg/hamburger.svg';
import ToggleSwitch from '@/components/common/ToggleSwitch';
import useChat from '@/pages/Chat/hooks/useChat';
import { isGroupChatType } from '@/pages/Chat/utils/chatType';
import useBooleanState from '@/utils/hooks/useBooleanState';
import { useApiErrorToast } from '@/utils/hooks/error/useApiErrorToast';
import { useSmartBack } from '@/utils/hooks/useSmartBack';
import { cn } from '@/utils/ts/cn';

function ChatHeader({ headerRef }: { headerRef?: Ref<HTMLElement> }) {
const smartBack = useSmartBack();
const navigate = useNavigate();
const location = useLocation();
const { pathname, state } = location;
const { chatRoomId } = useParams();
const numericRoomId = Number(chatRoomId);
const isInfoPage = pathname.endsWith('/info');

const showApiErrorToast = useApiErrorToast();
const { chatRoomList, clubMembers, toggleMute, isTogglingMute } = useChat(numericRoomId);

const { value: open, setTrue: openSidebar, setFalse: closeSidebar } = useBooleanState();

const chatRoom = chatRoomList.rooms.find((room) => room.roomId === numericRoomId);
const isGroup = isGroupChatType(chatRoom?.chatType);
const isMuted = chatRoom?.isMuted ?? false;

return (
<>
<header ref={headerRef} className="fixed top-0 right-0 left-0 z-30 flex h-13 items-center bg-white px-4 py-2">
<div className="flex min-w-0 flex-1 items-center gap-3">
<button type="button" aria-label="뒤로가기" onClick={smartBack} className="shrink-0">
<ChevronLeftIcon />
</button>
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();
};
Comment on lines +28 to +35
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.

<div
className={cn('fixed inset-0 z-50 transition-colors duration-300', {
'pointer-events-auto bg-black/30': open,
'pointer-events-none bg-black/0': !open,
})}
onClick={closeSidebar}
>
<div
className={cn(
'absolute top-0 right-0 flex h-full w-72 transform flex-col overflow-hidden bg-white p-4 transition-transform duration-300 ease-in-out',
open ? 'translate-x-0' : 'translate-x-full'
)}
onClick={(e) => e.stopPropagation()}
>
<div className="mb-6 flex shrink-0 items-center justify-between border-b pb-4">
<span className="text-sm font-medium">알림</span>
const handleToggleMute = async () => {
try {
await toggleMute(numericRoomId);
} catch (error) {
showApiErrorToast(error, '알림 설정 변경에 실패했습니다.');
}
};

<button
type="button"
disabled={isTogglingMute}
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`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
isMuted ? 'translate-x-1' : 'translate-x-6'
}`}
/>
</button>
</div>
return (
<header
ref={headerRef}
className={cn('fixed top-0 right-0 left-0 z-30 flex items-center bg-white', {
'h-13 px-4 py-2': !isInfoPage,
'h-15.75 rounded-b-3xl px-4 py-3 shadow-[0_0_20px_0_rgba(0,0,0,0.03)]': isInfoPage,
})}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<button type="button" aria-label="뒤로가기" onClick={handleBack} className="shrink-0">
<ChevronLeftIcon />
</button>

{isGroup && (
<>
<div className="mb-4 shrink-0 text-sm font-bold">참여자 {clubMembers.length}명</div>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto">
{clubMembers.map((member) => (
<div key={member.userId} className="flex items-center gap-3">
<img src={member.imageUrl} className="h-8 w-8 rounded-full" />
<div className="flex flex-col">
<span className="text-sm font-medium">{member.name}</span>
<span className="text-xs text-gray-400">{member.studentNumber}</span>
</div>
</div>
))}
</div>
</>
)}
<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>
</>

{isInfoPage ? (
<ToggleSwitch
label={isMuted ? '알림 켜기' : '알림 끄기'}
enabled={!isMuted}
onChange={() => void handleToggleMute()}
disabled={isTogglingMute}
ariaLabel="채팅방 알림 설정"
layout="horizontal"
variant="manager"
className="shrink-0"
/>
) : (
<Link to={`/chats/${chatRoomId}/info`} state={state} className="shrink-0" aria-label="채팅방 정보 열기">
<HamburgerIcon />
</Link>
)}
</header>
);
}

Expand Down
Loading
Loading