From c5418203eeb5ad9e3597b1063dd0442e891f2a36 Mon Sep 17 00:00:00 2001 From: manNomi Date: Tue, 3 Nov 2026 20:52:36 +0900 Subject: [PATCH 1/8] =?UTF-8?q?=E2=9C=A8=20feat:=20=EC=B1=84=ED=8C=85=20?= =?UTF-8?q?=EC=86=A1=EC=88=98=EC=8B=A0=EA=B3=BC=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC=20=EC=9E=AC=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/apis/chat/api.ts | 53 +- apps/web/src/apis/chat/auth.ts | 29 + apps/web/src/apis/chat/getChatMessages.ts | 38 -- apps/web/src/apis/chat/getChatPartner.ts | 14 +- apps/web/src/apis/chat/getChatRooms.ts | 18 +- apps/web/src/apis/chat/index.ts | 6 +- apps/web/src/apis/chat/normalize.ts | 55 ++ apps/web/src/apis/chat/putReadChatRoom.ts | 23 +- apps/web/src/apis/chat/queryKeys.ts | 6 + apps/web/src/apis/image-upload/api.ts | 3 +- .../ChatContent/_hooks/useChatListHandler.ts | 500 ------------------ .../_hooks/usePutChatReadHandler.ts | 14 - .../_ui/ChatInputBar/_hooks/useFileHandler.ts | 67 --- .../ChatInputBar/_hooks/useImageHandler.ts | 67 --- .../ChatInputBar/_hooks/useMessageHandler.ts | 41 -- .../ChatContent/_ui/ChatInputBar/index.tsx | 385 +++++++------- .../ChatContent/_ui/ChatMessageBox/index.tsx | 189 +++---- .../chat/[chatId]/_ui/ChatContent/index.tsx | 385 +++++++------- .../chat/[chatId]/_ui/ChatNavBar/index.tsx | 206 ++------ .../web/src/app/mentor/chat/[chatId]/page.tsx | 6 +- .../src/app/mentor/chat/_ui/ChatAccess.tsx | 47 ++ .../mentor/chat/_ui/ChatPageClient/index.tsx | 244 +++++---- apps/web/src/app/mentor/layout.tsx | 1 + .../layout/ReissueProvider/index.tsx | 9 +- apps/web/src/lib/chat/ChatSession.ts | 403 ++++++++++++++ apps/web/src/lib/chat/messages.ts | 35 ++ apps/web/src/lib/chat/stompTransport.ts | 128 +++++ apps/web/src/lib/chat/types.ts | 43 ++ apps/web/src/lib/chat/useChatScroll.ts | 110 ++++ apps/web/src/lib/chat/useChatSession.ts | 66 +++ .../src/lib/web-socket/useConnectWebSocket.ts | 145 ----- apps/web/src/utils/axiosInstance.ts | 19 +- 32 files changed, 1681 insertions(+), 1674 deletions(-) create mode 100644 apps/web/src/apis/chat/auth.ts delete mode 100644 apps/web/src/apis/chat/getChatMessages.ts create mode 100644 apps/web/src/apis/chat/queryKeys.ts delete mode 100644 apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_hooks/useChatListHandler.ts delete mode 100644 apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_hooks/usePutChatReadHandler.ts delete mode 100644 apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/_hooks/useFileHandler.ts delete mode 100644 apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/_hooks/useImageHandler.ts delete mode 100644 apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/_hooks/useMessageHandler.ts create mode 100644 apps/web/src/app/mentor/chat/_ui/ChatAccess.tsx create mode 100644 apps/web/src/lib/chat/ChatSession.ts create mode 100644 apps/web/src/lib/chat/messages.ts create mode 100644 apps/web/src/lib/chat/stompTransport.ts create mode 100644 apps/web/src/lib/chat/types.ts create mode 100644 apps/web/src/lib/chat/useChatScroll.ts create mode 100644 apps/web/src/lib/chat/useChatSession.ts delete mode 100644 apps/web/src/lib/web-socket/useConnectWebSocket.ts diff --git a/apps/web/src/apis/chat/api.ts b/apps/web/src/apis/chat/api.ts index 28f58de60..5fc21bfbb 100644 --- a/apps/web/src/apis/chat/api.ts +++ b/apps/web/src/apis/chat/api.ts @@ -2,21 +2,13 @@ import type { AxiosResponse } from "axios"; import type { ChatMessage, ChatPartner, ChatRoom } from "@/types/chat"; import { axiosInstance } from "@/utils/axiosInstance"; import { - normalizeChatMessage, normalizeChatPartner, normalizeChatRoom, - type RawChatMessage, + parseChatMessage, type RawChatPartner, type RawChatRoom, } from "./normalize"; -// QueryKeys for chat domain -export const ChatQueryKeys = { - chatRooms: "chatRooms", - chatHistories: "chatHistories", - partnerInfo: "partnerInfo", -} as const; - // Re-export types from @/types/chat export type { ChatMessage, ChatRoom, ChatPartner }; @@ -33,11 +25,12 @@ interface GetChatHistoriesParams { roomId: number; size?: number; page?: number; + signal?: AbortSignal; } interface RawChatHistoriesResponse { nextPageNumber: number; - content: RawChatMessage[]; + content: unknown[]; } interface RawChatRoomListResponse { @@ -45,33 +38,51 @@ interface RawChatRoomListResponse { } export const chatApi = { - getChatHistories: async ({ roomId, size = 20, page = 1 }: GetChatHistoriesParams): Promise => { + getChatHistories: async ({ + roomId, + size = 20, + page = 1, + signal, + }: GetChatHistoriesParams): Promise => { const res = await axiosInstance.get(`/chats/rooms/${roomId}`, { params: { size, page, }, + signal, }); - return { - nextPageNumber: res.data.nextPageNumber, - content: (res.data.content ?? []).map(normalizeChatMessage), - }; + const nextPageNumber = res.data?.nextPageNumber; + if ( + !Array.isArray(res.data?.content) || + (nextPageNumber !== -1 && (!Number.isSafeInteger(nextPageNumber) || nextPageNumber <= page)) + ) { + throw new Error("채팅 내역 응답이 올바르지 않습니다."); + } + + const content: ChatMessage[] = []; + for (const rawMessage of res.data.content) { + const message = parseChatMessage(rawMessage); + if (!message) throw new Error("채팅 내역에 올바르지 않은 메시지가 있습니다."); + content.push(message); + } + + return { nextPageNumber, content }; }, - getChatRooms: async (): Promise => { - const res = await axiosInstance.get("/chats/rooms"); + getChatRooms: async (signal?: AbortSignal): Promise => { + const res = await axiosInstance.get("/chats/rooms", { signal }); return { chatRooms: (res.data.chatRooms ?? []).map(normalizeChatRoom), }; }, - putReadChatRoom: async (roomId: number): Promise => { - const response: AxiosResponse = await axiosInstance.put(`/chats/rooms/${roomId}/read`); + putReadChatRoom: async (roomId: number, signal?: AbortSignal): Promise => { + const response: AxiosResponse = await axiosInstance.put(`/chats/rooms/${roomId}/read`, undefined, { signal }); return response.data; }, - getChatPartner: async (roomId: number): Promise => { - const res = await axiosInstance.get(`/chats/rooms/${roomId}/partner`); + getChatPartner: async (roomId: number, signal?: AbortSignal): Promise => { + const res = await axiosInstance.get(`/chats/rooms/${roomId}/partner`, { signal }); return normalizeChatPartner(res.data); }, }; diff --git a/apps/web/src/apis/chat/auth.ts b/apps/web/src/apis/chat/auth.ts new file mode 100644 index 000000000..1fff512ea --- /dev/null +++ b/apps/web/src/apis/chat/auth.ts @@ -0,0 +1,29 @@ +import useAuthStore from "@/lib/zustand/useAuthStore"; +import { tokenParse } from "@/utils/jwtUtils"; + +export const getChatUserId = (accessToken: string | null): number | null => { + const payload = tokenParse(accessToken); + const userId = Number(payload?.sub); + + if ( + !payload || + (typeof payload.sub !== "number" && typeof payload.sub !== "string") || + !Number.isSafeInteger(userId) || + userId <= 0 || + typeof payload.exp !== "number" || + !Number.isFinite(payload.exp) || + payload.exp <= Math.floor(Date.now() / 1000) + ) { + return null; + } + + return userId; +}; + +export const useChatUserId = (): number | null => { + const accessToken = useAuthStore((state) => state.accessToken); + const isInitialized = useAuthStore((state) => state.isInitialized); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + + return isInitialized && isAuthenticated ? getChatUserId(accessToken) : null; +}; diff --git a/apps/web/src/apis/chat/getChatMessages.ts b/apps/web/src/apis/chat/getChatMessages.ts deleted file mode 100644 index 2d14dc061..000000000 --- a/apps/web/src/apis/chat/getChatMessages.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useInfiniteQuery } from "@tanstack/react-query"; -import type { AxiosError } from "axios"; -import { type ChatHistoriesResponse, type ChatMessage, ChatQueryKeys, chatApi } from "./api"; - -/** - * @description 채팅 히스토리를 무한 스크롤로 가져오는 훅 - */ -const useGetChatHistories = (roomId: number, size: number = 20) => { - return useInfiniteQuery< - ChatHistoriesResponse, - AxiosError, - { - pages: ChatHistoriesResponse[]; - pageParams: number[]; - messages: ChatMessage[]; - }, - [string, number], - number - >({ - queryKey: [ChatQueryKeys.chatHistories, roomId], - queryFn: ({ pageParam = 1 }: { pageParam?: number }) => chatApi.getChatHistories({ roomId, size, page: pageParam }), - initialPageParam: 1, - getNextPageParam: (lastPage: ChatHistoriesResponse) => { - // nextPageNumber가 -1이면 더 이상 페이지가 없음 - return lastPage.nextPageNumber === -1 ? undefined : lastPage.nextPageNumber; - }, - staleTime: 1000 * 60 * 5, // 5분간 캐시 - refetchOnMount: "always", - enabled: !!roomId, // roomId가 있을 때만 쿼리 실행 - select: (data) => ({ - pages: data.pages, - pageParams: data.pageParams, - messages: data.pages.flatMap((page) => page.content), - }), - }); -}; - -export default useGetChatHistories; diff --git a/apps/web/src/apis/chat/getChatPartner.ts b/apps/web/src/apis/chat/getChatPartner.ts index 56be7d482..b7b83a32a 100644 --- a/apps/web/src/apis/chat/getChatPartner.ts +++ b/apps/web/src/apis/chat/getChatPartner.ts @@ -1,16 +1,22 @@ import { useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { type ChatPartner, ChatQueryKeys, chatApi } from "./api"; +import { SKIP_GLOBAL_ERROR_TOAST_META } from "@/lib/react-query/errorToastMeta"; +import { type ChatPartner, chatApi } from "./api"; +import { useChatUserId } from "./auth"; +import { chatKeys } from "./queryKeys"; /** * @description 채팅 상대방 정보를 가져오는 훅 */ const useGetPartnerInfo = (roomId: number) => { + const userId = useChatUserId(); + return useQuery({ - queryKey: [ChatQueryKeys.partnerInfo, roomId], - queryFn: () => chatApi.getChatPartner(roomId), + queryKey: chatKeys.partner(userId, roomId), + queryFn: ({ signal }) => chatApi.getChatPartner(roomId, signal), staleTime: 1000 * 60 * 5, - enabled: !!roomId, + enabled: userId !== null && Number.isSafeInteger(roomId) && roomId > 0, + meta: SKIP_GLOBAL_ERROR_TOAST_META, }); }; diff --git a/apps/web/src/apis/chat/getChatRooms.ts b/apps/web/src/apis/chat/getChatRooms.ts index b96a43d25..79a55115c 100644 --- a/apps/web/src/apis/chat/getChatRooms.ts +++ b/apps/web/src/apis/chat/getChatRooms.ts @@ -1,15 +1,25 @@ import { useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { ChatQueryKeys, type ChatRoom, type ChatRoomListResponse, chatApi } from "./api"; +import { SKIP_GLOBAL_ERROR_TOAST_META } from "@/lib/react-query/errorToastMeta"; +import { type ChatRoom, type ChatRoomListResponse, chatApi } from "./api"; +import { useChatUserId } from "./auth"; +import { chatKeys } from "./queryKeys"; /** * @description 채팅방 목록을 가져오는 훅 */ const useGetChatRooms = () => { + const userId = useChatUserId(); + return useQuery({ - queryKey: [ChatQueryKeys.chatRooms], - queryFn: chatApi.getChatRooms, - staleTime: 1000 * 60 * 5, // 5분간 캐시 + queryKey: chatKeys.rooms(userId), + queryFn: ({ signal }) => chatApi.getChatRooms(signal), + enabled: userId !== null, + staleTime: 0, + refetchOnWindowFocus: true, + refetchInterval: 30_000, + refetchIntervalInBackground: false, + meta: SKIP_GLOBAL_ERROR_TOAST_META, select: (data) => data.chatRooms, }); }; diff --git a/apps/web/src/apis/chat/index.ts b/apps/web/src/apis/chat/index.ts index e1a190a4a..7b6fdcb40 100644 --- a/apps/web/src/apis/chat/index.ts +++ b/apps/web/src/apis/chat/index.ts @@ -1,6 +1,8 @@ export type { ChatHistoriesResponse, ChatMessage, ChatPartner, ChatRoom, ChatRoomListResponse } from "./api"; -export { ChatQueryKeys, chatApi } from "./api"; -export { default as useGetChatHistories } from "./getChatMessages"; +export { chatApi } from "./api"; +export { getChatUserId, useChatUserId } from "./auth"; export { default as useGetPartnerInfo } from "./getChatPartner"; export { default as useGetChatRooms } from "./getChatRooms"; +export { parseChatMessage } from "./normalize"; export { default as usePutChatRead } from "./putReadChatRoom"; +export { chatKeys } from "./queryKeys"; diff --git a/apps/web/src/apis/chat/normalize.ts b/apps/web/src/apis/chat/normalize.ts index 19534fa31..c98ed63ad 100644 --- a/apps/web/src/apis/chat/normalize.ts +++ b/apps/web/src/apis/chat/normalize.ts @@ -91,6 +91,61 @@ export const normalizeChatMessage = (message: RawChatMessage): ChatMessage => { }; }; +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isOptionalNumericId = (value: unknown): value is NumericLike => { + if (value === undefined || value === null) return true; + if (typeof value !== "number" && typeof value !== "string") return false; + if (typeof value === "string" && value.trim() === "") return false; + + return Number.isSafeInteger(Number(value)); +}; + +const isTimestamp = (value: unknown): value is string => + typeof value === "string" && value.trim() !== "" && Number.isFinite(Date.parse(value)); + +const isRawAttachment = (value: unknown): value is RawChatAttachment => + isRecord(value) && + isOptionalNumericId(value.id) && + typeof value.isImage === "boolean" && + typeof value.url === "string" && + value.url.trim() !== "" && + (value.thumbnailUrl === undefined || value.thumbnailUrl === null || typeof value.thumbnailUrl === "string") && + isTimestamp(value.createdAt); + +export const parseChatMessage = (value: unknown): ChatMessage | null => { + if ( + !isRecord(value) || + !isOptionalNumericId(value.id) || + typeof value.content !== "string" || + !isTimestamp(value.createdAt) + ) { + return null; + } + + const rawSenderId = value.senderId ?? value.siteUserId; + if (!isOptionalNumericId(rawSenderId)) return null; + + const senderId = Number(rawSenderId); + if (!Number.isSafeInteger(senderId) || senderId <= 0) return null; + + if ( + value.attachments !== undefined && + (!Array.isArray(value.attachments) || !value.attachments.every(isRawAttachment)) + ) { + return null; + } + + return normalizeChatMessage({ + id: value.id, + content: value.content, + senderId, + createdAt: value.createdAt, + attachments: value.attachments as RawChatAttachment[] | undefined, + }); +}; + export const normalizeChatPartner = (partner: RawChatPartner): ChatPartner => ({ partnerId: toNumber(partner.partnerId ?? partner.siteUserId), nickname: partner.nickname, diff --git a/apps/web/src/apis/chat/putReadChatRoom.ts b/apps/web/src/apis/chat/putReadChatRoom.ts index 0602a25cb..cf0b9ae2c 100644 --- a/apps/web/src/apis/chat/putReadChatRoom.ts +++ b/apps/web/src/apis/chat/putReadChatRoom.ts @@ -1,20 +1,27 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { ChatQueryKeys, chatApi } from "./api"; +import { SKIP_GLOBAL_ERROR_TOAST_META } from "@/lib/react-query/errorToastMeta"; +import { chatApi } from "./api"; +import { useChatUserId } from "./auth"; +import { chatKeys } from "./queryKeys"; /** * @description 채팅방 읽음 처리 훅 */ -const usePutChatRead = () => { +const usePutChatRead = (contextUserId?: number) => { const queryClient = useQueryClient(); + const authenticatedUserId = useChatUserId(); + const userId = contextUserId ?? authenticatedUserId; - return useMutation({ - mutationFn: chatApi.putReadChatRoom, - onSuccess: () => { - // 채팅방 목록 쿼리를 무효화하여 새로 고침 - queryClient.invalidateQueries({ queryKey: [ChatQueryKeys.chatRooms] }); + return useMutation({ + mutationFn: (roomId) => chatApi.putReadChatRoom(roomId), + onMutate: () => ({ userId }), + onSuccess: (_data, _roomId, context) => { + if (context?.userId) { + return queryClient.invalidateQueries({ queryKey: chatKeys.rooms(context.userId) }); + } }, - onError: (error) => {}, + meta: SKIP_GLOBAL_ERROR_TOAST_META, }); }; diff --git a/apps/web/src/apis/chat/queryKeys.ts b/apps/web/src/apis/chat/queryKeys.ts new file mode 100644 index 000000000..d79824256 --- /dev/null +++ b/apps/web/src/apis/chat/queryKeys.ts @@ -0,0 +1,6 @@ +export const chatKeys = { + all: ["chat"], + rooms: (userId: number | null) => ["chat", userId, "rooms"] as const, + partner: (userId: number | null, roomId: number) => ["chat", userId, "partner", roomId] as const, + outbox: (userId: number, roomId: number) => ["chat", userId, "outbox", roomId] as const, +} as const; diff --git a/apps/web/src/apis/image-upload/api.ts b/apps/web/src/apis/image-upload/api.ts index fb95fe646..75e8926f7 100644 --- a/apps/web/src/apis/image-upload/api.ts +++ b/apps/web/src/apis/image-upload/api.ts @@ -59,7 +59,7 @@ export const imageUploadApi = { /** * 채팅 이미지 업로드 (로그인 후) */ - postUploadChatImages: async (files: File[]): Promise => { + postUploadChatImages: async (files: File[], signal?: AbortSignal): Promise => { const formData = new FormData(); files.forEach((file) => { formData.append("files", file); @@ -67,6 +67,7 @@ export const imageUploadApi = { const res = await axiosInstance.post(`/file/chat`, formData, { headers: { "Content-Type": "multipart/form-data" }, + signal, }); return res.data.map((image) => image.fileUrl); }, diff --git a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_hooks/useChatListHandler.ts b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_hooks/useChatListHandler.ts deleted file mode 100644 index c6b97a6a1..000000000 --- a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_hooks/useChatListHandler.ts +++ /dev/null @@ -1,500 +0,0 @@ -import type { Client } from "@stomp/stompjs"; -import { useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useRef } from "react"; -import { ChatQueryKeys, useGetChatHistories } from "@/apis/chat"; -import useConnectWebSocket from "@/lib/web-socket/useConnectWebSocket"; -import { type ChatMessage, ConnectionStatus } from "@/types/chat"; -import { normalizeImageUrlToUploadCdn } from "@/utils/cdnUrl"; -// --- 프로젝트 내부 의존성 --- -import useInfinityScroll from "@/utils/useInfinityScroll"; - -const BOTTOM_PROXIMITY_THRESHOLD = 80; -const OUTBOUND_TEXT_SCROLL_CONFIRM_TIMEOUT_MS = 5000; -const INITIAL_SCROLL_SETTLE_DELAYS_MS = [100, 350, 800]; - -interface PendingOutboundTextScroll { - id: string; - senderId: number; - content: string; - timeoutId: ReturnType; -} - -const getMessageDedupeKey = (message: ChatMessage): string => { - if (message.id > 0) { - return `id:${message.id}`; - } - - const attachmentKey = message.attachments - .map((attachment) => `${attachment.isImage ? "image" : "file"}:${attachment.url}:${attachment.createdAt}`) - .join(","); - - return `fallback:${message.senderId}:${message.createdAt}:${message.content}:${attachmentKey}`; -}; - -const getImageUrlKeys = (url: string | null | undefined) => { - if (!url) return []; - - const normalizedUrl = normalizeImageUrlToUploadCdn(url); - return Array.from(new Set([url, normalizedUrl].filter((key) => key.length > 0))); -}; - -const normalizePendingText = (content: string) => content.trim(); - -const useChatListHandler = (chatId: number) => { - // --- 1. State 및 Ref 선언 --- - const clientRef = useRef(null); - const messagesEndRef = useRef(null); // 새 메시지 수신 시 자동 스크롤을 위한 ref - const scrollContainerRef = useRef(null); // 실제 스크롤 컨테이너 ref - const hasInitialAutoScrolledRef = useRef(false); - const prevMessageCountRef = useRef(0); - const prevChatIdRef = useRef(chatId); - const shouldForceScrollToBottomRef = useRef(false); - const pendingOutboundTextScrollsRef = useRef([]); - const imagePreviewByUrlRef = useRef>(new Map()); - const objectUrlsRef = useRef([]); - const queryClient = useQueryClient(); - - const scrollToBottom = useCallback(() => { - const container = scrollContainerRef.current; - if (!container) return; - - container.scrollTop = container.scrollHeight; - }, []); - - const removePendingOutboundTextScrolls = useCallback((ids: Set) => { - pendingOutboundTextScrollsRef.current = pendingOutboundTextScrollsRef.current.filter((item) => { - if (!ids.has(item.id)) return true; - - clearTimeout(item.timeoutId); - return false; - }); - }, []); - - const clearPendingOutboundTextScrolls = useCallback(() => { - pendingOutboundTextScrollsRef.current.forEach((item) => clearTimeout(item.timeoutId)); - pendingOutboundTextScrollsRef.current = []; - }, []); - - // --- 2. 하위 Hooks 호출 --- - - // API를 통해 채팅 기록을 페이지 단위로 가져옵니다. - const { - data: chatHistoryPages, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isLoading, - } = useGetChatHistories(chatId); - - // WebSocket 연결을 관리하고 현재 연결 상태를 받아옵니다. - const { connectionStatus, submittedMessages, setSubmittedMessages } = useConnectWebSocket({ - roomId: chatId, - clientRef, - }); - - const invalidateChatPreviewQueries = useCallback(() => { - queryClient.invalidateQueries({ queryKey: [ChatQueryKeys.chatHistories, chatId], refetchType: "none" }); - queryClient.invalidateQueries({ queryKey: [ChatQueryKeys.chatRooms], refetchType: "none" }); - }, [chatId, queryClient]); - - // 화면 상단에 도달했을 때 이전 채팅 기록을 불러오는 무한 스크롤 Hook입니다. - const { lastElementRef: topDetectorRef } = useInfinityScroll({ - fetchNextPage, - hasNextPage, - isDirectionTop: true, - }); - - // --- 3. Effects --- - - // API로부터 받아온 채팅 기록(페이지 배열)을 단일 메시지 배열로 가공하고 정렬합니다. - useEffect(() => { - if (chatHistoryPages?.pages) { - const allMessages = chatHistoryPages.pages.flatMap((page) => page.content || []); - const sortedMessages = allMessages.sort( - (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), - ); - // Deduplicate by id, keeping the last occurrence (chronological order) - const dedupedMessages: ChatMessage[] = []; - const seenIds = new Set(); - for (let i = sortedMessages.length - 1; i >= 0; i--) { - const msg = sortedMessages[i]; - const dedupeKey = getMessageDedupeKey(msg); - if (!seenIds.has(dedupeKey)) { - seenIds.add(dedupeKey); - dedupedMessages.unshift(msg); - } - } - setSubmittedMessages(dedupedMessages); - } - }, [chatHistoryPages, setSubmittedMessages]); - - useEffect(() => { - if (imagePreviewByUrlRef.current.size === 0) return; - - const matchedPreviewUrls = new Set(); - let hasChanged = false; - - const messagesWithPreviews = submittedMessages.map((message) => { - let hasAttachmentChanged = false; - const attachments = message.attachments.map((attachment) => { - if (!attachment.isImage || attachment.previewUrl || attachment.isOptimistic) { - return attachment; - } - - const previewUrl = [...getImageUrlKeys(attachment.url), ...getImageUrlKeys(attachment.thumbnailUrl)] - .map((key) => imagePreviewByUrlRef.current.get(key)) - .find((value): value is string => Boolean(value)); - - if (!previewUrl) { - return attachment; - } - - hasAttachmentChanged = true; - matchedPreviewUrls.add(previewUrl); - return { - ...attachment, - previewUrl, - }; - }); - - if (!hasAttachmentChanged) { - return message; - } - - hasChanged = true; - return { - ...message, - attachments, - }; - }); - - if (matchedPreviewUrls.size === 0) return; - - const reconciledMessages = messagesWithPreviews.filter((message) => { - const shouldRemoveOptimisticMessage = - message.attachments.length > 0 && - message.attachments.every( - (attachment) => - attachment.isOptimistic && attachment.previewUrl && matchedPreviewUrls.has(attachment.previewUrl), - ); - - if (shouldRemoveOptimisticMessage) { - hasChanged = true; - return false; - } - - return true; - }); - - if (!hasChanged) return; - - imagePreviewByUrlRef.current.forEach((previewUrl, key) => { - if (matchedPreviewUrls.has(previewUrl)) { - imagePreviewByUrlRef.current.delete(key); - } - }); - - setSubmittedMessages(reconciledMessages); - }, [submittedMessages, setSubmittedMessages]); - - // 채팅방 전환 시 자동 스크롤 상태를 초기화합니다. - useEffect(() => { - if (prevChatIdRef.current === chatId) return; - - objectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); - objectUrlsRef.current = []; - imagePreviewByUrlRef.current.clear(); - clearPendingOutboundTextScrolls(); - prevChatIdRef.current = chatId; - hasInitialAutoScrolledRef.current = false; - prevMessageCountRef.current = 0; - shouldForceScrollToBottomRef.current = false; - }, [chatId, clearPendingOutboundTextScrolls]); - - // 초기 메시지가 렌더링되면 최초 1회만 하단으로 이동합니다. - useEffect(() => { - if (isLoading || submittedMessages.length === 0 || hasInitialAutoScrolledRef.current) { - return; - } - - const rafIds: number[] = []; - const timeoutIds: ReturnType[] = []; - - const scheduleScrollToBottom = () => { - const rafId = requestAnimationFrame(() => { - scrollToBottom(); - }); - rafIds.push(rafId); - }; - - scheduleScrollToBottom(); - const doubleRafId = requestAnimationFrame(() => { - scrollToBottom(); - scheduleScrollToBottom(); - }); - rafIds.push(doubleRafId); - - INITIAL_SCROLL_SETTLE_DELAYS_MS.forEach((delayMs) => { - const timeoutId = setTimeout(scrollToBottom, delayMs); - timeoutIds.push(timeoutId); - }); - - hasInitialAutoScrolledRef.current = true; - prevMessageCountRef.current = submittedMessages.length; - - return () => { - rafIds.forEach((rafId) => cancelAnimationFrame(rafId)); - timeoutIds.forEach((timeoutId) => clearTimeout(timeoutId)); - }; - }, [isLoading, scrollToBottom, submittedMessages.length]); - - // 신규 메시지 도착 시 하단 근처라면 유지하고, 내가 보낸 메시지는 현재 위치와 무관하게 하단으로 이동합니다. - useEffect(() => { - if (isLoading || isFetchingNextPage) return; - - const currentMessageCount = submittedMessages.length; - const prevMessageCount = prevMessageCountRef.current; - const container = scrollContainerRef.current; - - if (!container) { - prevMessageCountRef.current = currentMessageCount; - return; - } - - if (currentMessageCount <= prevMessageCount) { - prevMessageCountRef.current = currentMessageCount; - return; - } - - if (!hasInitialAutoScrolledRef.current) { - prevMessageCountRef.current = currentMessageCount; - return; - } - - const newMessages = submittedMessages.slice(prevMessageCount); - const matchedPendingOutboundTextIds = new Set(); - const hasConfirmedOutboundText = newMessages.some((message) => { - const pendingOutboundText = pendingOutboundTextScrollsRef.current.find( - (item) => item.senderId === message.senderId && item.content === normalizePendingText(message.content), - ); - - if (!pendingOutboundText) return false; - - matchedPendingOutboundTextIds.add(pendingOutboundText.id); - return true; - }); - - if (matchedPendingOutboundTextIds.size > 0) { - removePendingOutboundTextScrolls(matchedPendingOutboundTextIds); - } - - const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; - const shouldForceScrollToBottom = shouldForceScrollToBottomRef.current || hasConfirmedOutboundText; - - if (shouldForceScrollToBottom || distanceFromBottom <= BOTTOM_PROXIMITY_THRESHOLD) { - const rafId = requestAnimationFrame(() => { - scrollToBottom(); - }); - - shouldForceScrollToBottomRef.current = false; - prevMessageCountRef.current = currentMessageCount; - - return () => cancelAnimationFrame(rafId); - } - - prevMessageCountRef.current = currentMessageCount; - }, [isLoading, isFetchingNextPage, removePendingOutboundTextScrolls, scrollToBottom, submittedMessages]); - - // --- 4. Handler 함수 --- - - /** 텍스트 메시지를 WebSocket을 통해 서버로 전송합니다. */ - const sendTextMessage = useCallback( - (content: string, senderId: number) => { - const normalizedContent = normalizePendingText(content); - if (normalizedContent === "") return; // 빈 메시지 전송 방지 - - if (clientRef.current?.active && connectionStatus === ConnectionStatus.Connected) { - // WebSocket으로 메시지 전송 - clientRef.current.publish({ - destination: `/publish/chat/${chatId}`, - body: JSON.stringify({ content, senderId }), - }); - - const pendingTextScrollId = `${Date.now()}-${Math.random()}`; - const timeoutId = setTimeout(() => { - pendingOutboundTextScrollsRef.current = pendingOutboundTextScrollsRef.current.filter( - (item) => item.id !== pendingTextScrollId, - ); - }, OUTBOUND_TEXT_SCROLL_CONFIRM_TIMEOUT_MS); - - pendingOutboundTextScrollsRef.current.push({ - id: pendingTextScrollId, - senderId, - content: normalizedContent, - timeoutId, - }); - - requestAnimationFrame(scrollToBottom); - invalidateChatPreviewQueries(); - } else { - // 여기에 메시지 전송 실패에 대한 UI 피드백 로직을 추가할 수 있습니다. (e.g., alert, toast) - } - }, - [chatId, connectionStatus, invalidateChatPreviewQueries, scrollToBottom], - ); // chatId와 connectionStatus가 변경될 경우에만 함수를 재생성 - - const sendImageMessage = useCallback( - (imageUrls: string[], previewUrls: string[] = []) => { - if (imageUrls.length === 0) return false; - - if (clientRef.current?.active && connectionStatus === ConnectionStatus.Connected) { - imageUrls.forEach((imageUrl, index) => { - const previewUrl = previewUrls[index]; - if (!previewUrl) return; - - getImageUrlKeys(imageUrl).forEach((key) => { - imagePreviewByUrlRef.current.set(key, previewUrl); - }); - }); - - clientRef.current.publish({ - destination: `/publish/chat/${chatId}/image`, - body: JSON.stringify({ imageUrls }), - }); - invalidateChatPreviewQueries(); - - return true; - } - - return false; - }, - [chatId, connectionStatus, invalidateChatPreviewQueries], - ); - - /** 이미지 파일만 미리보기 메시지로 추가 */ - const addImageMessagePreview = useCallback( - (files: File[], senderId: number) => { - const newMessages: ChatMessage[] = []; - const previewUrls: string[] = []; - files.forEach((file) => { - if (file.type.startsWith("image/")) { - const tempId = Date.now() + Math.random(); - const imageUrl = URL.createObjectURL(file); - objectUrlsRef.current.push(imageUrl); - previewUrls.push(imageUrl); - newMessages.push({ - id: tempId, - content: `이미지: ${file.name}`, - senderId, - createdAt: new Date().toISOString(), - attachments: [ - { - id: tempId, - isImage: true, - url: imageUrl, - thumbnailUrl: imageUrl, - previewUrl: imageUrl, - isOptimistic: true, - createdAt: new Date().toISOString(), - }, - ], - }); - } - }); - if (newMessages.length > 0) { - shouldForceScrollToBottomRef.current = true; - setSubmittedMessages((prev) => [...prev, ...newMessages]); - } - return previewUrls; - }, - [setSubmittedMessages], - ); - - const removeImageMessagePreviews = useCallback( - (previewUrls: string[]) => { - if (previewUrls.length === 0) return; - - const previewUrlSet = new Set(previewUrls); - previewUrls.forEach((previewUrl) => { - URL.revokeObjectURL(previewUrl); - }); - objectUrlsRef.current = objectUrlsRef.current.filter((objectUrl) => !previewUrlSet.has(objectUrl)); - - setSubmittedMessages((prev) => - prev.filter( - (message) => - message.attachments.length === 0 || - !message.attachments.every( - (attachment) => - attachment.isOptimistic && attachment.previewUrl && previewUrlSet.has(attachment.previewUrl), - ), - ), - ); - }, - [setSubmittedMessages], - ); - - /** 이미지가 아닌 파일을 미리보기 메시지로 추가 */ - const addFileMessagePreview = useCallback( - (files: File[], senderId: number) => { - const newMessages: ChatMessage[] = []; - files.forEach((file) => { - if (!file.type.startsWith("image/")) { - const tempId = Date.now() + Math.random(); - newMessages.push({ - id: tempId, - content: `파일: ${file.name}`, - senderId, - createdAt: new Date().toISOString(), - attachments: [ - { - id: tempId, - isImage: false, - url: URL.createObjectURL(file), - thumbnailUrl: null, - createdAt: new Date().toISOString(), - }, - ], - }); - } - }); - if (newMessages.length > 0) { - shouldForceScrollToBottomRef.current = true; - setSubmittedMessages((prev) => [...prev, ...newMessages]); - } - }, - [setSubmittedMessages], - ); - - // Cleanup created object URLs on unmount - useEffect(() => { - return () => { - objectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); - objectUrlsRef.current = []; - clearPendingOutboundTextScrolls(); - }; - }, [clearPendingOutboundTextScrolls]); - - // --- 5. 최종 반환 객체 --- - return { - // Data & State - messages: submittedMessages, - connectionStatus, - isLoading, // 초기 데이터 로딩 상태 - isFetchingNextPage, // 이전 기록 로딩 상태 - - // Refs - scrollContainerRef, // 실제 스크롤 컨테이너 ref - messagesEndRef, // 자동 스크롤을 위한 ref - topDetectorRef, // 무한 스크롤 감지를 위한 ref - - // Handlers - sendTextMessage, - sendImageMessage, - addImageMessagePreview, - removeImageMessagePreviews, - addFileMessagePreview, - }; -}; - -export default useChatListHandler; diff --git a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_hooks/usePutChatReadHandler.ts b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_hooks/usePutChatReadHandler.ts deleted file mode 100644 index f40124ccd..000000000 --- a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_hooks/usePutChatReadHandler.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useEffect } from "react"; - -import { usePutChatRead } from "@/apis/chat"; - -const usePutChatReadHandler = (chatId: number) => { - const { mutate: putChatRead } = usePutChatRead(); - - useEffect(() => { - if (chatId) { - putChatRead(chatId); - } - }, [chatId, putChatRead]); -}; -export default usePutChatReadHandler; diff --git a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/_hooks/useFileHandler.ts b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/_hooks/useFileHandler.ts deleted file mode 100644 index 2ec4a7788..000000000 --- a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/_hooks/useFileHandler.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { useRef, useState } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -// Zod 스키마 정의 -const fileSchema = z.object({ - files: z.array(z.instanceof(File)).min(1, "파일을 선택해주세요"), -}); - -// 타입 정의 -type FileForm = z.infer; - -type UseFileHandlerProps = { - onSendFiles: (data: { files: File[] }) => void; - setIsAttachmentOptionsOpen: (isOpen: boolean) => void; -}; - -const useFileHandler = ({ onSendFiles, setIsAttachmentOptionsOpen }: UseFileHandlerProps) => { - // 파일용 폼 - const fileForm = useForm({ - resolver: zodResolver(fileSchema), - }); - - // state - const [selectedFiles, setSelectedFiles] = useState([]); - - // ref - const fileInputRef = useRef(null); - - // 파일이 있는지 여부 - const hasFiles = selectedFiles.length > 0; - - const handleFileChange = (e: React.ChangeEvent) => { - const files = e.target.files; - if (files) { - const fileArray = Array.from(files); - setSelectedFiles(fileArray); - // 파일 선택 시 첨부파일 옵션 닫기 - setIsAttachmentOptionsOpen(false); - } - }; - - const handleFileClick = () => { - // 파일 선택 트리거 - fileInputRef.current?.click(); - }; - - const onSubmitFiles = (data: FileForm) => { - onSendFiles(data); - setSelectedFiles([]); - if (fileInputRef.current) fileInputRef.current.value = ""; - }; - - return { - fileInputRef, - selectedFiles, - setSelectedFiles, - fileForm, - onSubmitFiles, - hasFiles, - handleFileChange, - handleFileClick, - }; -}; - -export default useFileHandler; diff --git a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/_hooks/useImageHandler.ts b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/_hooks/useImageHandler.ts deleted file mode 100644 index 16fb17388..000000000 --- a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/_hooks/useImageHandler.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { useRef, useState } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -// Zod 스키마 정의 -const imageSchema = z.object({ - images: z.array(z.instanceof(File)).min(1, "이미지를 선택해주세요"), -}); - -// 타입 정의 -type ImageForm = z.infer; - -type UseImageHandlerProps = { - onSendImages: (data: { images: File[] }) => void; - setIsAttachmentOptionsOpen: (isOpen: boolean) => void; -}; - -const useImageHandler = ({ onSendImages, setIsAttachmentOptionsOpen }: UseImageHandlerProps) => { - // 이미지용 폼 - const imageForm = useForm({ - resolver: zodResolver(imageSchema), - }); - - // state - const [selectedImages, setSelectedImages] = useState([]); - - // ref - const imageInputRef = useRef(null); - - // 이미지가 있는지 여부 - const hasImages = selectedImages.length > 0; - - const handleImageChange = (e: React.ChangeEvent) => { - const files = e.target.files; - if (files) { - const fileArray = Array.from(files); - setSelectedImages(fileArray); - // 이미지 선택 시 첨부파일 옵션 닫기 - setIsAttachmentOptionsOpen(false); - } - }; - - const handleAlbumClick = () => { - // 이미지 파일 선택 트리거 - imageInputRef.current?.click(); - }; - - const onSubmitImages = (data: ImageForm) => { - onSendImages(data); - setSelectedImages([]); - if (imageInputRef.current) imageInputRef.current.value = ""; - }; - - return { - imageInputRef, - selectedImages, - setSelectedImages, - imageForm, - onSubmitImages, - hasImages, - handleImageChange, - handleAlbumClick, - }; -}; - -export default useImageHandler; diff --git a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/_hooks/useMessageHandler.ts b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/_hooks/useMessageHandler.ts deleted file mode 100644 index e3a9502f6..000000000 --- a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/_hooks/useMessageHandler.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -// Zod 스키마 정의 -const messageSchema = z.object({ - message: z.string().min(1, "메시지를 입력해주세요").trim(), -}); - -// 타입 정의 -type MessageForm = z.infer; - -type UseMessageHandlerProps = { - onSendMessage: (data: { message: string }) => void; -}; - -const useMessageHandler = ({ onSendMessage }: UseMessageHandlerProps) => { - // 텍스트 메시지용 폼 - const messageForm = useForm({ - resolver: zodResolver(messageSchema), - defaultValues: { message: "" }, - }); - - const watchedMessage = messageForm.watch("message", ""); - const isMessageEmpty = !watchedMessage?.trim(); - - const onSubmitMessage = (data: MessageForm) => { - onSendMessage({ - message: data.message, - }); - messageForm.reset(); - }; - - return { - messageForm, - onSubmitMessage, - isMessageEmpty, - }; -}; - -export default useMessageHandler; diff --git a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/index.tsx b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/index.tsx index 53346ffd5..2f2c2e7a3 100644 --- a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/index.tsx +++ b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatInputBar/index.tsx @@ -1,231 +1,212 @@ -import clsx from "clsx"; -import { useState } from "react"; -import Image from "@/components/ui/FallbackImage"; -import { IconAlbum, IconDirectMessage, IconFile, IconPlusK200, IconXWhite } from "@/public/svgs/mentor"; -import { downloadLocalFile } from "@/utils/fileUtils"; -import useFileHandler from "./_hooks/useFileHandler"; -import useImageHandler from "./_hooks/useImageHandler"; -import useMessageHandler from "./_hooks/useMessageHandler"; +"use client"; + +import { type ChangeEvent, type FormEvent, type KeyboardEvent, useEffect, useId, useRef, useState } from "react"; +import { IconAlbum, IconDirectMessage } from "@/public/svgs/mentor"; interface ChatInputBarProps { - onSendMessage: (data: { message: string }) => void; - onSendImages: (data: { images: File[] }) => void; - onSendFiles: (data: { files: File[] }) => void; + disabled?: boolean; + canSend?: boolean; + onSendText: (content: string) => Promise; + onSendImages: (files: File[]) => Promise; +} + +interface SelectedImage { + id: number; + file: File; } -const ChatInputBar = ({ onSendMessage, onSendImages, onSendFiles }: ChatInputBarProps) => { - const [isAttachmentOptionsOpen, setIsAttachmentOptionsOpen] = useState(false); - - const { messageForm, onSubmitMessage, isMessageEmpty } = useMessageHandler({ onSendMessage }); - const { - imageInputRef, - selectedImages, - setSelectedImages, - imageForm, - onSubmitImages, - hasImages, - handleImageChange, - handleAlbumClick, - } = useImageHandler({ onSendImages, setIsAttachmentOptionsOpen }); - - const { - fileInputRef, - selectedFiles, - setSelectedFiles, - fileForm, - onSubmitFiles, - hasFiles, - handleFileChange, - handleFileClick, - } = useFileHandler({ onSendFiles, setIsAttachmentOptionsOpen }); - - const hasAttachments = hasImages || hasFiles; - - // 통합 전송 함수 - DirectMessage 버튼 클릭 시 호출 - const handleSend = () => { - if (hasImages) { - imageForm.setValue("images", selectedImages); - imageForm.handleSubmit(onSubmitImages)(); - } else if (hasFiles) { - fileForm.setValue("files", selectedFiles); - fileForm.handleSubmit(onSubmitFiles)(); - } else if (!isMessageEmpty) { - messageForm.handleSubmit(onSubmitMessage)(); +const ImagePreview = ({ file }: { file: File }) => { + const [previewUrl, setPreviewUrl] = useState(null); + + useEffect( + function createImagePreview() { + const objectUrl = URL.createObjectURL(file); + setPreviewUrl(objectUrl); + + return function releaseImagePreview() { + URL.revokeObjectURL(objectUrl); + }; + }, + [file], + ); + + return previewUrl ? ( + {file.name} + ) : ( +
+ ); +}; + +const ChatInputBar = ({ disabled = false, canSend = true, onSendText, onSendImages }: ChatInputBarProps) => { + const [message, setMessage] = useState(""); + const [selectedImages, setSelectedImages] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const imageInputRef = useRef(null); + const isSubmittingRef = useRef(false); + const isComposingRef = useRef(false); + const nextImageIdRef = useRef(0); + const hintId = useId(); + const hasImages = selectedImages.length > 0; + const isLocked = disabled || isSubmitting; + + const send = async () => { + if (disabled || !canSend || isSubmittingRef.current || isComposingRef.current) return; + + const content = message.trim(); + if (!hasImages && !content) return; + + isSubmittingRef.current = true; + setIsSubmitting(true); + setError(null); + + try { + const isAccepted = hasImages + ? await onSendImages(selectedImages.map((image) => image.file)) + : await onSendText(content); + + if (isAccepted) { + if (hasImages) { + setSelectedImages([]); + } else { + setMessage(""); + } + } else { + setError("연결을 확인한 뒤 다시 보내주세요. 입력 내용은 유지했어요."); + } + } catch { + setError("전송하지 못했어요. 입력 내용을 유지했으니 다시 시도해주세요."); + } finally { + isSubmittingRef.current = false; + setIsSubmitting(false); } }; + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + void send(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter" || event.shiftKey) return; + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229 || isComposingRef.current) return; + + event.preventDefault(); + void send(); + }; + + const handleImageChange = (event: ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + event.target.value = ""; + if (disabled || isSubmittingRef.current || files.length === 0) return; + + const images = files.filter((file) => file.type.startsWith("image/")); + setError(images.length === files.length ? null : "이미지만 보낼 수 있어요."); + setSelectedImages((previous) => [...previous, ...images.map((file) => ({ id: nextImageIdRef.current++, file }))]); + }; + return ( -
- {/* 숨겨진 파일 입력 요소들 */} - - -
- {/* 선택된 파일들 미리보기 */} - {(selectedImages.length > 0 || selectedFiles.length > 0) && ( -
- {selectedImages.map((file, index) => ( -
- - -
- ))} - {selectedFiles.map((file, index) => ( -
- + + {hasImages && ( +
+

+ 이미지 {selectedImages.length}장만 전송해요. 작성한 메시지는 유지돼요. +

+
    + {selectedImages.map(({ id, file }) => ( +
  • + -
+ ))} -
- )} - - {/* 메시지 폼 */} -
{ - e.preventDefault(); - handleSend(); + +
+ )} + +
+ +