diff --git a/src/App.tsx b/src/App.tsx index 124be5c..913d595 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,8 @@ const StudentIdStep = lazy(() => import('./pages/Auth/SignUp/StudentIdStep')); const TermStep = lazy(() => import('./pages/Auth/SignUp/TermStep')); const UniversityStep = lazy(() => import('./pages/Auth/SignUp/UniversityStep')); 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 ApplicationPage = lazy(() => import('./pages/Club/Application')); const ApplyCompletePage = lazy(() => import('./pages/Club/Application/applyCompletePage')); @@ -120,6 +122,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> diff --git a/src/apis/chat/entity.ts b/src/apis/chat/entity.ts index f6a3f82..5b5f87e 100644 --- a/src/apis/chat/entity.ts +++ b/src/apis/chat/entity.ts @@ -1,6 +1,7 @@ -import type { PaginationParams, PaginationResponse } from '../common/pagination'; +import type { PaginationParams, PaginationResponse } from '@/apis/common/pagination'; export type ChatType = 'DIRECT' | 'CLUB_GROUP' | 'GROUP' | 'INQUIRY'; +export type SortBy = 'CLUB' | 'NAME'; export interface Room { roomId: number; @@ -35,6 +36,7 @@ export interface SendChatMessageRequest { export interface ChatMessageRequestParam extends PaginationParams { chatRoomId: number; + messageId?: number; } export interface ChatMessagesResponse extends PaginationResponse { @@ -45,3 +47,55 @@ export interface ChatMessagesResponse extends PaginationResponse { export interface CreateChatRoomResponse { chatRoomId: number; } + +export interface Messages { + roomId: number; + chatType: ChatType; + roomName: string; + roomImageUrl: string; + matchedMessage: string; + matchedMessageSentAt: string; + matchedMessageId: number; +} + +export interface RoomMatched extends PaginationResponse { + rooms?: Room[]; +} + +export interface MessageMatched extends PaginationResponse { + messages?: Messages[]; +} + +export interface MatchedRequestParams extends PaginationParams { + keyword: string; +} + +export interface MatchResponse { + roomMatches?: RoomMatched; + messageMatches?: MessageMatched; +} + +export interface User { + userId: number; + name: string; + imageUrl: string; + studentNumber: string; +} + +export interface Section { + clubId: number; + clubName: string; + users: User[]; +} + +export interface InvitableFriendRequestParams extends PaginationParams { + query: string; + sortBy: SortBy; +} + +export interface InvitableFriend extends PaginationResponse { + sortBy: SortBy; + grouped: boolean; + users?: User[]; + sections?: Section[]; +} diff --git a/src/apis/chat/index.ts b/src/apis/chat/index.ts index 8158c3e..5eae7fe 100644 --- a/src/apis/chat/index.ts +++ b/src/apis/chat/index.ts @@ -6,6 +6,10 @@ import type { ChatRoomsResponse, CreateChatRoomResponse, SendChatMessageRequest, + MatchResponse, + MatchedRequestParams, + InvitableFriendRequestParams, + InvitableFriend, } from './entity'; export const getChatRooms = async () => { @@ -23,6 +27,14 @@ export const postChatRooms = async (userId: number) => { return response; }; +export const postChatRoomsGroup = async (userIds: number[]) => { + const response = await apiClient.post('chats/rooms/group', { + body: { userIds }, + requiresAuth: true, + }); + return response; +}; + export const postChatMessage = async ({ chatRoomId, content }: SendChatMessageRequest) => { return apiClient.post(`chats/rooms/${chatRoomId}/messages`, { body: { content }, @@ -64,3 +76,19 @@ export const deleteChatRoom = async (chatRoomId: number) => { }); return response; }; + +export const getSearchChat = async ({ ...query }: MatchedRequestParams) => { + const response = await apiClient.get(`chats/rooms/search`, { + params: query, + requiresAuth: true, + }); + return response; +}; + +export const getInvitableFriends = async ({ ...query }: InvitableFriendRequestParams) => { + const response = await apiClient.get('chats/rooms/invitables', { + params: query, + requiresAuth: true, + }); + return response; +}; diff --git a/src/apis/chat/mutations.ts b/src/apis/chat/mutations.ts index 49885d1..f162b6a 100644 --- a/src/apis/chat/mutations.ts +++ b/src/apis/chat/mutations.ts @@ -1,6 +1,7 @@ import { mutationOptions } from '@tanstack/react-query'; import { patchChatRoomName, + postChatRoomsGroup, postAdminChatRoom, postChatMessage, postChatMute, @@ -11,6 +12,7 @@ import { export const chatMutationKeys = { createRoom: () => ['chat', 'createRoom'] as const, createAdminRoom: () => ['chat', 'createAdminRoom'] as const, + createRoomGroup: () => ['chat', 'createRoomGroup'] as const, sendMessage: () => ['chat', 'sendMessage'] as const, toggleMute: (chatRoomId?: number) => ['chat', 'toggleMute', chatRoomId ?? 'unknown'] as const, updateRoomName: () => ['chat', 'updateRoomName'] as const, @@ -28,6 +30,11 @@ export const chatMutations = { mutationKey: chatMutationKeys.createAdminRoom(), mutationFn: postAdminChatRoom, }), + createRoomGroup: () => + mutationOptions({ + mutationKey: chatMutationKeys.createRoomGroup(), + mutationFn: postChatRoomsGroup, + }), sendMessage: () => mutationOptions({ mutationKey: chatMutationKeys.sendMessage(), diff --git a/src/apis/chat/queries.ts b/src/apis/chat/queries.ts index 2ac95de..ecd86e6 100644 --- a/src/apis/chat/queries.ts +++ b/src/apis/chat/queries.ts @@ -1,12 +1,14 @@ import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query'; -import type { ChatMessagesResponse } from './entity'; -import { getChatMessages, getChatRooms } from '.'; +import { getChatMessages, getChatRooms, getSearchChat, getInvitableFriends } from '@/apis/chat'; +import type { ChatMessagesResponse, SortBy } from '@/apis/chat/entity'; export const chatQueryKeys = { all: ['chat'] as const, rooms: () => [...chatQueryKeys.all, 'rooms'] as const, messages: (chatRoomId: number) => [...chatQueryKeys.all, 'messages', chatRoomId] 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], }; export const chatQueries = { @@ -15,12 +17,13 @@ export const chatQueries = { queryKey: chatQueryKeys.rooms(), queryFn: getChatRooms, }), - messages: (chatRoomId?: number, limit = 20) => + messages: (chatRoomId?: number, messageId?: number, limit = 20) => infiniteQueryOptions({ queryKey: chatRoomId ? chatQueryKeys.messages(chatRoomId) : chatQueryKeys.disabledMessages(), queryFn: ({ pageParam }) => getChatMessages({ chatRoomId: chatRoomId!, + messageId: messageId, page: pageParam, limit, }), @@ -29,4 +32,14 @@ export const chatQueries = { lastPage.currentPage < lastPage.totalPage ? lastPage.currentPage + 1 : undefined, enabled: Boolean(chatRoomId), }), + search: (keyword: string) => + queryOptions({ + queryKey: chatQueryKeys.search(keyword), + queryFn: () => getSearchChat({ keyword, page: 1, limit: 20 }), + }), + invite: (query: string, sortBy: SortBy) => + queryOptions({ + queryKey: chatQueryKeys.invite(query, sortBy), + queryFn: () => getInvitableFriends({ query, sortBy, page: 1, limit: 20 }), + }), }; diff --git a/src/assets/svg/check_color.svg b/src/assets/svg/check_color.svg new file mode 100644 index 0000000..27b5d41 --- /dev/null +++ b/src/assets/svg/check_color.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/common/Dropdown.tsx b/src/components/common/Dropdown.tsx index 5bccf62..138d502 100644 --- a/src/components/common/Dropdown.tsx +++ b/src/components/common/Dropdown.tsx @@ -13,6 +13,7 @@ interface DropdownProps extends Omit void; menuClassName?: string; + triggerClassName?: string; } export default function Dropdown({ @@ -21,6 +22,7 @@ export default function Dropdown({ onChange, className, menuClassName, + triggerClassName, ...props }: DropdownProps) { const [isOpen, setIsOpen] = useState(false); @@ -45,8 +47,9 @@ export default function Dropdown({ type="button" onClick={() => setIsOpen(!isOpen)} className={cn( - 'inline-flex h-7.25 items-center justify-center overflow-hidden rounded-full bg-[#69BFDF] px-2.5 text-[13px] leading-[1.6] font-medium text-white transition-opacity active:opacity-90', - isOpen && 'opacity-95' + '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', + isOpen && 'opacity-95', + triggerClassName )} > {selectedOption?.label} diff --git a/src/components/layout/Header/components/ChatAddHeader.tsx b/src/components/layout/Header/components/ChatAddHeader.tsx new file mode 100644 index 0000000..8f7db2b --- /dev/null +++ b/src/components/layout/Header/components/ChatAddHeader.tsx @@ -0,0 +1,23 @@ +import BackTitleHeader from '@/components/layout/Header/components/BackTitleHeader'; + +interface ChatListHeaderProps { + title: string; + onConfirm: () => void; +} + +export default function ChatAddHeader({ title, onConfirm }: ChatListHeaderProps) { + const confirm = ( + + 확인 + + ); + + return ( + + ); +} diff --git a/src/components/layout/Header/components/ChatListHeader.tsx b/src/components/layout/Header/components/ChatListHeader.tsx index 7f299a9..3999559 100644 --- a/src/components/layout/Header/components/ChatListHeader.tsx +++ b/src/components/layout/Header/components/ChatListHeader.tsx @@ -1,4 +1,5 @@ import type { Ref } from 'react'; +import { useNavigate } from 'react-router-dom'; import AddCircle from '@/assets/svg/add_circle.svg'; import Search from '@/assets/svg/big-search-icon.svg'; import BackTitleHeader from '@/components/layout/Header/components/BackTitleHeader'; @@ -9,12 +10,13 @@ interface ChatListHeaderProps { } export default function ChatListHeader({ title, headerRef }: ChatListHeaderProps) { + const navigate = useNavigate(); const rightSlot = ( - + navigate('/chats/search')}> - + navigate('/chats/add')}> diff --git a/src/components/layout/Header/components/ChatSearchHeader.tsx b/src/components/layout/Header/components/ChatSearchHeader.tsx new file mode 100644 index 0000000..d433552 --- /dev/null +++ b/src/components/layout/Header/components/ChatSearchHeader.tsx @@ -0,0 +1,18 @@ +import type { Ref } from 'react'; +import BackTitleHeader from '@/components/layout/Header/components/BackTitleHeader'; + +interface ChatListHeaderProps { + title: string; + headerRef?: Ref; +} + +export default function ChatSearchHeader({ title, headerRef }: ChatListHeaderProps) { + return ( + + ); +} diff --git a/src/components/layout/Header/components/SubpageHeader.tsx b/src/components/layout/Header/components/SubpageHeader.tsx index 8d66ca9..29de738 100644 --- a/src/components/layout/Header/components/SubpageHeader.tsx +++ b/src/components/layout/Header/components/SubpageHeader.tsx @@ -7,6 +7,7 @@ interface SubpageHeaderProps { headerRef?: Ref; rightSlot?: ReactNode; shadowClassName?: string; + headerClassName?: string; } function SubpageHeader({ @@ -14,10 +15,9 @@ function SubpageHeader({ headerRef, rightSlot, shadowClassName = 'shadow-[0_0_20px_rgba(0,0,0,0.03)]', + headerClassName, }: SubpageHeaderProps) { - const headerStyle = { - minHeight: 'var(--subpage-header-height)', - } as CSSProperties; + const headerStyle = headerClassName ? undefined : ({ minHeight: 'var(--subpage-header-height)' } as CSSProperties); return ( diff --git a/src/components/layout/Header/headerConfig.ts b/src/components/layout/Header/headerConfig.ts index ad2b0f1..423c64e 100644 --- a/src/components/layout/Header/headerConfig.ts +++ b/src/components/layout/Header/headerConfig.ts @@ -29,6 +29,14 @@ export const HEADER_CONFIGS: HeaderConfig[] = [ type: 'chatList', match: (pathname) => pathname === '/chats', }, + { + type: 'none', + match: (pathname) => pathname === '/chats/add', + }, + { + type: 'chatSearch', + match: (pathname) => pathname === '/chats/search', + }, { type: 'chat', match: (pathname) => /^\/chats\/\d+$/.test(pathname), diff --git a/src/components/layout/Header/index.tsx b/src/components/layout/Header/index.tsx index 2e12ba5..31b3cf6 100644 --- a/src/components/layout/Header/index.tsx +++ b/src/components/layout/Header/index.tsx @@ -35,6 +35,7 @@ function Header({ headerRef }: HeaderProps) { signup: ({ title, onBack, headerRef }) => , default: ({ title, headerRef }) => , manager: ({ title, headerRef }) => , + chatSearch: ({ title, headerRef }) => , }; const onBack = headerType === 'signup' ? () => navigate('/') : undefined; diff --git a/src/components/layout/Header/routeTitles.ts b/src/components/layout/Header/routeTitles.ts index 97d8ed3..0688fd0 100644 --- a/src/components/layout/Header/routeTitles.ts +++ b/src/components/layout/Header/routeTitles.ts @@ -64,4 +64,12 @@ export const ROUTE_TITLES: RouteTitle[] = [ match: (pathname) => pathname === '/chats', title: '채팅방', }, + { + match: (pathname) => pathname === '/chats/add', + title: '채팅방 추가', + }, + { + match: (pathname) => pathname === '/chats/search', + title: '채팅방 검색', + }, ]; diff --git a/src/components/layout/Header/types.ts b/src/components/layout/Header/types.ts index 9a4a4fd..d79c6c2 100644 --- a/src/components/layout/Header/types.ts +++ b/src/components/layout/Header/types.ts @@ -13,7 +13,8 @@ export type HeaderType = | 'signup' | 'schedule' | 'manager' - | 'chatList'; + | 'chatList' + | 'chatSearch'; export interface HeaderConfig { type: HeaderType; diff --git a/src/pages/Chat/AddChatRoom.tsx b/src/pages/Chat/AddChatRoom.tsx new file mode 100644 index 0000000..3826ffa --- /dev/null +++ b/src/pages/Chat/AddChatRoom.tsx @@ -0,0 +1,149 @@ +import { Fragment, useState } from 'react'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { useLocation, useNavigate } from 'react-router-dom'; +import type { User, Section, SortBy } from '@/apis/chat/entity'; +import { chatQueries } from '@/apis/chat/queries'; +import Search from '@/assets/svg/big-search-icon.svg'; +import Check from '@/assets/svg/check_color.svg'; +import Dropdown from '@/components/common/Dropdown'; +import ChatAddHeader from '@/components/layout/Header/components/ChatAddHeader'; +import { getHeaderPresentation } from '@/components/layout/Header/presentation'; +import useChat from '@/pages/Chat/hooks/useChat'; +import useDebouncedCallback from '@/utils/hooks/useDebounce'; +import { isApiError } from '@/utils/ts/error/apiError'; +import { isServerErrorStatus, redirectToServerErrorPage } from '@/utils/ts/error/errorRedirect'; + +type UserListProps = { + onToggle: (userId: number) => void; + selectedUserIds: Set; +}; + +function InvitableSectionList({ clubName, users, onToggle, selectedUserIds }: Section & UserListProps) { + return ( + + {clubName} + {users.map((user) => ( + + onToggle(user.userId)}> + + {user.name[0]} + + + {user.name} ({user.studentNumber}) + + {selectedUserIds.has(user.userId) && } + + + ))} + + ); +} + +function InvitableUserList({ userId, name, studentNumber, onToggle, selectedUserIds }: User & UserListProps) { + return ( + onToggle(userId)}> + + + {name[0]} + + + {name} ({studentNumber}) + + {selectedUserIds.has(userId) && } + + + ); +} + +const SORT_OPTIONS = [ + { value: 'CLUB', label: '동아리' }, + { value: 'NAME', label: '이름' }, +] as const; + +export default function AddChatRoom() { + const { pathname } = useLocation(); + const { title } = getHeaderPresentation(pathname); + const [keyword, setKeyword] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + const [sortBy, setSortBy] = useState('CLUB'); + const [selectedUserIds, setSelectedUserIds] = useState>(new Set()); + + const navigate = useNavigate(); + const { createRoomGroup } = useChat(); + const { data } = useSuspenseQuery({ + ...chatQueries.invite(debouncedQuery, sortBy), + }); + + const onConfirm = async () => { + try { + const result = await createRoomGroup(Array.from(selectedUserIds)); + navigate(`/chats/${result.chatRoomId}`); + } catch (error) { + if (isApiError(error) && isServerErrorStatus(error.status)) { + redirectToServerErrorPage(); + return; + } + throw error; + } + }; + + const updateDebouncedQuery = useDebouncedCallback((value: string) => { + const trimmed = value.trim(); + setDebouncedQuery(trimmed); + }, 300); + const handleChange = (value: string) => { + setKeyword(value); + updateDebouncedQuery(value); + }; + const toggleUser = (userId: number) => { + setSelectedUserIds((prev) => { + const next = new Set(prev); + if (next.has(userId)) { + next.delete(userId); + } else { + next.add(userId); + } + return next; + }); + }; + + return ( + + + + handleChange(e.target.value)} + className="h-full flex-1 bg-white px-3" + placeholder="이름, 학번 검색" + /> + + + + + 친구 선택({data?.currentCount}) + setSortBy(value)} + /> + + {data?.sortBy === 'CLUB' + ? data?.sections?.map((section) => ( + + )) + : data?.users?.map((user) => ( + + ))} + + + ); +} diff --git a/src/pages/Chat/ChatRoom.tsx b/src/pages/Chat/ChatRoom.tsx index bac4c9e..00b1cef 100644 --- a/src/pages/Chat/ChatRoom.tsx +++ b/src/pages/Chat/ChatRoom.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useParams, useSearchParams } from 'react-router-dom'; import type { ChatMessage } from '@/apis/chat/entity'; import SendArrowIcon from '@/assets/svg/chat-send-arrow.svg'; import LinkifiedText from '@/components/common/LinkifiedText'; @@ -82,8 +82,12 @@ function ChatMessageRow({ isSameSender, message }: ChatMessageRowProps) { function ChatRoom() { const { chatRoomId } = useParams(); + const [searchParams] = useSearchParams(); + const targetMessageId = searchParams.get('messageId') ? Number(searchParams.get('messageId')) : undefined; + const { sendMessage, chatMessages, fetchNextPage, hasNextPage, isFetchingNextPage, isSendingMessage } = useChat( - Number(chatRoomId) + Number(chatRoomId), + targetMessageId ); const [value, setValue] = useState(''); @@ -101,6 +105,14 @@ function ChatRoom() { useViewportHeightLock(scrollContainerRef); const sortedMessages = [...chatMessages].reverse(); + + useEffect(() => { + if (!targetMessageId) return; + const el = document.querySelector(`[data-message-id="${targetMessageId}"]`); + if (el) { + el.scrollIntoView({ block: 'center' }); + } + }, [chatMessages, targetMessageId]); const isSubmitDisabled = isSendingMessage || !value.trim(); const resetTextareaHeight = () => { @@ -167,7 +179,7 @@ function ChatRoom() { const isSameSender = prevMessage?.senderId === message.senderId && !showDateHeader; return ( - + {showDateHeader && ( diff --git a/src/pages/Chat/ChatSearch.tsx b/src/pages/Chat/ChatSearch.tsx new file mode 100644 index 0000000..ef84477 --- /dev/null +++ b/src/pages/Chat/ChatSearch.tsx @@ -0,0 +1,89 @@ +import { useState, Fragment } from 'react'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; +import type { Messages } from '@/apis/chat/entity'; +import { chatQueries } from '@/apis/chat/queries'; +import Search from '@/assets/svg/big-search-icon.svg'; +import useDebouncedCallback from '@/utils/hooks/useDebounce'; +import { ChatRoomAvatar, ChatRoomListItem } from './components/ChatRoomListItem'; +import { formatTime } from './utils/formatTime'; + +function MessageListItem({ message, keyword }: { message: Messages; keyword: string }) { + const parts = message.matchedMessage.split(keyword); + return ( + + + + + + {message.roomName} + + {message.matchedMessageSentAt && ( + + {formatTime(message.matchedMessageSentAt)} + + )} + + + + {parts.map((part, index) => ( + + {part} + {index < parts.length - 1 && {keyword}} + + ))} + + + + + ); +} + +function ChatSearchResults({ keyword }: { keyword: string }) { + const { data } = useSuspenseQuery(chatQueries.search(keyword)); + + return ( + + {data?.roomMatches?.rooms?.map((room) => ( + + ))} + {data?.messageMatches?.messages?.map((message, index) => ( + + ))} + + ); +} + +export default function ChatSearch() { + const [keyword, setKeyword] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + + const updateDebouncedQuery = useDebouncedCallback((value: string) => { + const trimmed = value.trim(); + setDebouncedQuery(trimmed); + }, 300); + + const handleChange = (value: string) => { + setKeyword(value); + updateDebouncedQuery(value); + }; + + return ( + + + handleChange(e.target.value)} + className="h-full flex-1 bg-white px-3" + placeholder="채팅방명, 채팅으로 검색" + /> + + + {debouncedQuery && } + + ); +} diff --git a/src/pages/Chat/components/ChatRoomListItem.tsx b/src/pages/Chat/components/ChatRoomListItem.tsx new file mode 100644 index 0000000..49492f6 --- /dev/null +++ b/src/pages/Chat/components/ChatRoomListItem.tsx @@ -0,0 +1,77 @@ +import { Link } from 'react-router-dom'; +import type { Room } from '@/apis/chat/entity'; +import BellOffIcon from '@/assets/svg/bell-off.svg'; +import PersonIcon from '@/assets/svg/person.svg'; +import { formatTime } from '@/pages/Chat/utils/formatTime'; +import { useLongPress } from '@/utils/hooks/useLongPress'; + +export function ChatRoomAvatar({ roomImageUrl }: Pick) { + if (roomImageUrl) { + return ( + + ); + } + + return ( + + + + ); +} + +interface ChatRoomListItemProps { + room: Room; + defaultMessage?: string; + onLongPress?: (x: number, y: number, room: Room) => void; +} + +export function ChatRoomListItem({ room, defaultMessage = '', onLongPress }: ChatRoomListItemProps) { + const hasUnreadMessage = room.unreadCount > 0; + const previewMessage = room.lastMessage?.trim() || defaultMessage; + const longPress = useLongPress({ + onLongPress: (x, y) => onLongPress?.(x, y, room), + }); + + return ( + + + + + + {room.roomName} + {room.isMuted && } + + {room.lastSentAt && ( + + {formatTime(room.lastSentAt)} + + )} + + + + {previewMessage} + + {hasUnreadMessage && ( + + + {room.unreadCount > 300 ? '300+' : room.unreadCount} + + + )} + + + + ); +} diff --git a/src/pages/Chat/hooks/useChat.ts b/src/pages/Chat/hooks/useChat.ts index caa2151..95098d2 100644 --- a/src/pages/Chat/hooks/useChat.ts +++ b/src/pages/Chat/hooks/useChat.ts @@ -3,13 +3,14 @@ import { chatQueries } from '@/apis/chat/queries'; import { clubQueries } from '@/apis/club/queries'; import { useCreateChatRoomMutation, + useCreateChatRoomGroupMutation, useSendChatMessageMutation, useToggleChatMuteMutation, useUpdateChatRoomNameMutation, useDeleteChatRoomMutation, } from '@/pages/Chat/hooks/useChatMutations'; -const useChat = (chatRoomId?: number) => { +const useChat = (chatRoomId?: number, messageId?: number) => { const { data: chatRoomList } = useSuspenseQuery({ ...chatQueries.rooms(), refetchInterval: 5000, @@ -17,13 +18,15 @@ const useChat = (chatRoomId?: number) => { const createChatRoomMutation = useCreateChatRoomMutation(); + const createRoomGroupMutation = useCreateChatRoomGroupMutation(); + const { data: chatMessagesData, fetchNextPage, hasNextPage, isFetchingNextPage, } = useInfiniteQuery({ - ...chatQueries.messages(chatRoomId), + ...chatQueries.messages(chatRoomId, messageId), refetchInterval: 1000, }); @@ -46,6 +49,7 @@ const useChat = (chatRoomId?: number) => { return { chatRoomList, createChatRoom: createChatRoomMutation.mutateAsync, + createRoomGroup: createRoomGroupMutation.mutateAsync, isCreatingChatRoom: createChatRoomMutation.isPending, chatMessages: allMessages, fetchNextPage, diff --git a/src/pages/Chat/hooks/useChatMutations.ts b/src/pages/Chat/hooks/useChatMutations.ts index 821b8be..ee5d8cc 100644 --- a/src/pages/Chat/hooks/useChatMutations.ts +++ b/src/pages/Chat/hooks/useChatMutations.ts @@ -13,6 +13,17 @@ export const useCreateChatRoomMutation = () => { }); }; +export const useCreateChatRoomGroupMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + ...chatMutations.createRoomGroup(), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: chatQueryKeys.rooms() }); + }, + }); +}; + export const useSendChatMessageMutation = () => { const queryClient = useQueryClient(); diff --git a/src/pages/Chat/index.tsx b/src/pages/Chat/index.tsx index fdc3abf..3cc1781 100644 --- a/src/pages/Chat/index.tsx +++ b/src/pages/Chat/index.tsx @@ -1,19 +1,15 @@ 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 { isDirectChatType } from '@/pages/Chat/utils/chatType'; import { useAdvertisements } from '@/utils/hooks/useAdvertisements'; -import { useLongPress } from '@/utils/hooks/useLongPress'; import ChatRoomContextMenu from './components/ChatRoomContextMenu'; +import { ChatRoomListItem } from './components/ChatRoomListItem'; import useChat from './hooks/useChat'; -const DEFAULT_LAST_MESSAGE = '동아리에 궁금한 점을 물어보세요'; const FIRST_ADVERTISEMENT_ROOM_POSITION = 4; const ADVERTISEMENT_INTERVAL = 6; @@ -41,95 +37,6 @@ const getAdvertisementIndexAfterRoom = (roomIndex: number) => { return Math.floor(roomOffsetFromFirstAdvertisement / ADVERTISEMENT_INTERVAL); }; -const formatTime = (timeString: string) => { - const timeMatch = timeString.match(/(\d{1,2}):(\d{2})/); - - if (!timeMatch) { - return ''; - } - - const hour = Number(timeMatch[1]); - const minute = Number(timeMatch[2]); - const period = hour < 12 ? '오전' : '오후'; - const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; - - return `${period} ${displayHour}:${String(minute).padStart(2, '0')}`; -}; - -function ChatRoomAvatar({ roomImageUrl }: Pick) { - if (roomImageUrl) { - return ( - - ); - } - - return ( - - - - ); -} - -interface ChatRoomListItemProps { - room: Room; - onLongPress: (x: number, y: number, room: Room) => void; -} - -function ChatRoomListItem({ room, onLongPress }: ChatRoomListItemProps) { - 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 ( - - - - - - - {room.roomName} - {room.isMuted && } - - - {room.lastSentAt && ( - - {formatTime(room.lastSentAt)} - - )} - - - - - {previewMessage} - - - {hasUnreadMessage && ( - - - {room.unreadCount > 300 ? '300+' : room.unreadCount} - - - )} - - - - ); -} - interface ChatAdvertisementListItemProps { advertisement: Advertisement; onClick: (advertisementId: number) => void; diff --git a/src/pages/Chat/utils/formatTime.ts b/src/pages/Chat/utils/formatTime.ts new file mode 100644 index 0000000..4386371 --- /dev/null +++ b/src/pages/Chat/utils/formatTime.ts @@ -0,0 +1,14 @@ +export const formatTime = (timeString: string) => { + const timeMatch = timeString.match(/(\d{1,2}):(\d{2})/); + + if (!timeMatch) { + return ''; + } + + const hour = Number(timeMatch[1]); + const minute = Number(timeMatch[2]); + const period = hour < 12 ? '오전' : '오후'; + const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + + return `${period} ${displayHour}:${String(minute).padStart(2, '0')}`; +};
+ {parts.map((part, index) => ( + + {part} + {index < parts.length - 1 && {keyword}} + + ))} +
+ {previewMessage} +
- {previewMessage} -