diff --git a/apps/web/src/apis/chat/getChatMessages.ts b/apps/web/src/apis/chat/getChatMessages.ts index 1b01ab23..6190f658 100644 --- a/apps/web/src/apis/chat/getChatMessages.ts +++ b/apps/web/src/apis/chat/getChatMessages.ts @@ -25,6 +25,7 @@ const useGetChatHistories = (roomId: number, size: number = 20) => { return lastPage.nextPageNumber === -1 ? undefined : lastPage.nextPageNumber; }, staleTime: 1000 * 60 * 5, // 5분간 캐시 + refetchOnMount: "always", enabled: !!roomId, // roomId가 있을 때만 쿼리 실행 meta: { disableGlobalLoading: true, // 전역 로딩 비활성화 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 index 5d7799c4..9187e38f 100644 --- 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 @@ -1,8 +1,10 @@ import type { Client } from "@stomp/stompjs"; +import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef } from "react"; -import { useGetChatHistories } from "@/apis/chat"; +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"; @@ -20,6 +22,13 @@ const getMessageDedupeKey = (message: ChatMessage): string => { 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 useChatListHandler = (chatId: number) => { // --- 1. State 및 Ref 선언 --- const clientRef = useRef(null); @@ -27,6 +36,10 @@ const useChatListHandler = (chatId: number) => { const scrollContainerRef = useRef(null); // 실제 스크롤 컨테이너 ref const hasInitialAutoScrolledRef = useRef(false); const prevMessageCountRef = useRef(0); + const prevChatIdRef = useRef(chatId); + const imagePreviewByUrlRef = useRef>(new Map()); + const objectUrlsRef = useRef([]); + const queryClient = useQueryClient(); // --- 2. 하위 Hooks 호출 --- @@ -45,6 +58,11 @@ const useChatListHandler = (chatId: number) => { 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, @@ -76,8 +94,83 @@ const useChatListHandler = (chatId: number) => { } }, [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(); + prevChatIdRef.current = chatId; hasInitialAutoScrolledRef.current = false; prevMessageCountRef.current = 0; }, [chatId]); @@ -153,43 +246,53 @@ const useChatListHandler = (chatId: number) => { destination: `/publish/chat/${chatId}`, body: JSON.stringify({ content, senderId }), }); + invalidateChatPreviewQueries(); } else { // 여기에 메시지 전송 실패에 대한 UI 피드백 로직을 추가할 수 있습니다. (e.g., alert, toast) } }, - [chatId, connectionStatus], + [chatId, connectionStatus, invalidateChatPreviewQueries], ); // chatId와 connectionStatus가 변경될 경우에만 함수를 재생성 const sendImageMessage = useCallback( - (imageUrls: string[]) => { + (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], + [chatId, connectionStatus, invalidateChatPreviewQueries], ); - // Track created object URLs for cleanup - const objectUrlsRef = useRef([]); - /** 이미지 파일만 미리보기 메시지로 추가 */ 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}`, @@ -201,6 +304,8 @@ const useChatListHandler = (chatId: number) => { isImage: true, url: imageUrl, thumbnailUrl: imageUrl, + previewUrl: imageUrl, + isOptimistic: true, createdAt: new Date().toISOString(), }, ], @@ -208,6 +313,31 @@ const useChatListHandler = (chatId: number) => { } }); if (newMessages.length > 0) 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], ); @@ -229,7 +359,7 @@ const useChatListHandler = (chatId: number) => { id: tempId, isImage: false, url: URL.createObjectURL(file), - thumbnailUrl: "", + thumbnailUrl: null, createdAt: new Date().toISOString(), }, ], @@ -266,6 +396,7 @@ const useChatListHandler = (chatId: number) => { sendTextMessage, sendImageMessage, addImageMessagePreview, + removeImageMessagePreviews, addFileMessagePreview, }; }; diff --git a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatMessageBox/index.tsx b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatMessageBox/index.tsx index 0f013aaf..cfbf109a 100644 --- a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatMessageBox/index.tsx +++ b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatMessageBox/index.tsx @@ -1,12 +1,17 @@ +"use client"; + +import { useEffect, useState } from "react"; import Image from "@/components/ui/FallbackImage"; import ProfileWithBadge from "@/components/ui/ProfileWithBadge"; -import type { ChatMessage } from "@/types/chat"; +import type { ChatAttachment, ChatMessage } from "@/types/chat"; +import { normalizeImageUrlToUploadCdn } from "@/utils/cdnUrl"; import { formatTime } from "@/utils/datetimeUtils"; import { downloadFile, getFileExtension, getFileNamePrefix } from "@/utils/fileUtils"; import { getMessageType, shouldShowContent } from "./_utils/messageUtils"; -const CHAT_IMAGE_RETRY_LIMIT = 5; -const CHAT_IMAGE_RETRY_DELAY_MS = 1000; +const CHAT_IMAGE_HEALTH_CHECK_LIMIT = 10; +const CHAT_IMAGE_HEALTH_CHECK_INTERVAL_MS = 1000; +const CHAT_IMAGE_HEALTH_CHECK_SLOW_INTERVAL_MS = 5000; interface ChatMessageBoxProps { message: ChatMessage; @@ -15,6 +20,117 @@ interface ChatMessageBoxProps { isPartnerMentor?: boolean; } +const appendHealthCheckCacheBuster = (src: string, attempt: number) => { + if (src.startsWith("blob:") || src.startsWith("data:")) return src; + + try { + const url = new URL(src, window.location.origin); + url.searchParams.set("__chat_image_health_check", `${Date.now()}-${attempt}`); + return url.toString(); + } catch { + return src; + } +}; + +const isLocalPreviewUrl = (src: string) => src.startsWith("blob:") || src.startsWith("data:"); + +const ChatImageLoading = () => ( +
+
+
+); + +const useChatImageHealthCheck = (src: string, enabled: boolean) => { + const [isReady, setIsReady] = useState(!enabled); + const [readySrc, setReadySrc] = useState(src); + + useEffect(() => { + if (!enabled || !src) { + setIsReady(true); + setReadySrc(src); + return; + } + + let isCancelled = false; + let attempt = 0; + let timeoutId: ReturnType | null = null; + let probeImage: HTMLImageElement | null = null; + + setIsReady(false); + setReadySrc(""); + + const checkImage = () => { + attempt += 1; + probeImage = document.createElement("img"); + + probeImage.onload = () => { + if (isCancelled) return; + setIsReady(true); + setReadySrc(probeImage?.src ?? src); + }; + + probeImage.onerror = () => { + if (isCancelled) return; + + const retryDelayMs = + attempt >= CHAT_IMAGE_HEALTH_CHECK_LIMIT + ? CHAT_IMAGE_HEALTH_CHECK_SLOW_INTERVAL_MS + : CHAT_IMAGE_HEALTH_CHECK_INTERVAL_MS; + timeoutId = setTimeout(checkImage, retryDelayMs); + }; + + probeImage.src = appendHealthCheckCacheBuster(src, attempt); + }; + + checkImage(); + + return () => { + isCancelled = true; + if (timeoutId) clearTimeout(timeoutId); + if (probeImage) { + probeImage.onload = null; + probeImage.onerror = null; + } + }; + }, [enabled, src]); + + return { isReady, readySrc }; +}; + +const ChatImage = ({ attachment }: { attachment: ChatAttachment }) => { + const imageSrc = attachment.previewUrl ?? attachment.thumbnailUrl ?? attachment.url; + const normalizedImageSrc = normalizeImageUrlToUploadCdn(imageSrc); + const shouldHealthCheck = !attachment.previewUrl && !isLocalPreviewUrl(normalizedImageSrc); + const { isReady, readySrc } = useChatImageHealthCheck(normalizedImageSrc, shouldHealthCheck); + const displaySrc = readySrc || normalizedImageSrc; + const [isImageLoaded, setIsImageLoaded] = useState(!shouldHealthCheck || isLocalPreviewUrl(displaySrc)); + + useEffect(() => { + setIsImageLoaded(!shouldHealthCheck || isLocalPreviewUrl(displaySrc)); + }, [displaySrc, shouldHealthCheck]); + + return ( +
+ {(!isReady || !isImageLoaded) && ( +
+ +
+ )} + {isReady && ( + 첨부 이미지 setIsImageLoaded(true)} + /> + )} +
+ ); +}; + const ChatMessageBox = ({ message, currentUserId = 1, @@ -35,20 +151,7 @@ const ChatMessageBox = ({
{attachment.isImage ? ( // 이미지 렌더링 -
- 첨부 이미지 -
+ ) : ( // 파일 렌더링
{ sendTextMessage, sendImageMessage, addImageMessagePreview, + removeImageMessagePreviews, } = useChatListHandler(chatId); const uploadChatImagesMutation = useUploadChatImages(); @@ -140,8 +141,12 @@ const ChatContent = ({ chatId }: ChatContentProps) => { {/* 첫 번째 메시지에 ref 부착하여 위로 스크롤 시 더 오래된 메시지 로드 */} {messages.map((message, index) => { const showDateSeparator = index === 0 || !isSameDay(messages[index - 1].createdAt, message.createdAt); - const messageKey = - message.id > 0 + const previewMessageKey = message.attachments + .map((attachment) => attachment.previewUrl) + .find((previewUrl): previewUrl is string => Boolean(previewUrl)); + const messageKey = previewMessageKey + ? `preview-${previewMessageKey}` + : message.id > 0 ? `message-${message.id}` : `message-${message.senderId}-${message.createdAt}-${message.content}-${index}`; @@ -182,14 +187,18 @@ const ChatContent = ({ chatId }: ChatContentProps) => { sendTextMessage(data.message, userId); }} onSendImages={async (data) => { + const previewUrls = addImageMessagePreview(data.images, userId); + try { const imageUrls = await uploadChatImagesMutation.mutateAsync(data.images); - const isSent = sendImageMessage(imageUrls); + const isSent = sendImageMessage(imageUrls, previewUrls); if (!isSent) { + removeImageMessagePreviews(previewUrls); toast.error("채팅 연결이 원활하지 않아 이미지를 전송하지 못했어요."); } } catch { + removeImageMessagePreviews(previewUrls); toast.error("이미지 전송에 실패했어요. 다시 시도해주세요."); } }} diff --git a/apps/web/src/components/ui/FallbackImage.tsx b/apps/web/src/components/ui/FallbackImage.tsx index 481fc366..14d242f4 100644 --- a/apps/web/src/components/ui/FallbackImage.tsx +++ b/apps/web/src/components/ui/FallbackImage.tsx @@ -1,7 +1,7 @@ "use client"; import NextImage from "next/image"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { getUploadCdnOrigin, normalizeImageUrlToUploadCdn } from "@/utils/cdnUrl"; const DEFAULT_FALLBACK_SRC = "/svgs/placeholders/image-placeholder.svg"; @@ -34,79 +34,29 @@ const resolveCdnUrl = (src: string, cdnHostType?: CdnHostType) => { type FallbackImageProps = React.ComponentProps & { fallbackSrc?: string; cdnHostType?: CdnHostType; - retryOnError?: boolean; - retryLimit?: number; - retryDelayMs?: number; }; const FallbackImage = ({ src, fallbackSrc = DEFAULT_FALLBACK_SRC, cdnHostType, - retryOnError = false, - retryLimit = 0, - retryDelayMs = 1000, onError, ...props }: FallbackImageProps) => { const [failedSource, setFailedSource] = useState(null); - const [retryAttempt, setRetryAttempt] = useState(0); - const retryTimeoutRef = useRef | null>(null); - const sourceKeyRef = useRef(null); const normalizedSrc = typeof src === "string" ? resolveCdnUrl(src, cdnHostType) || fallbackSrc : src; const sourceKey = typeof normalizedSrc === "string" ? normalizedSrc : JSON.stringify(normalizedSrc); const hasError = failedSource === sourceKey; const resolvedSrc = hasError ? fallbackSrc : normalizedSrc; - const normalizedRetryLimit = Math.max(0, retryLimit); - const normalizedRetryDelayMs = Math.max(0, retryDelayMs); - const canRetry = - retryOnError && - retryAttempt < normalizedRetryLimit && - typeof normalizedSrc === "string" && - normalizedSrc !== fallbackSrc; - - useEffect(() => { - if (sourceKeyRef.current === sourceKey) return; - - sourceKeyRef.current = sourceKey; - setFailedSource(null); - setRetryAttempt(0); - - if (retryTimeoutRef.current) { - clearTimeout(retryTimeoutRef.current); - retryTimeoutRef.current = null; - } - }, [sourceKey]); - - useEffect(() => { - return () => { - if (retryTimeoutRef.current) { - clearTimeout(retryTimeoutRef.current); - } - }; - }, []); return ( { if (!hasError && resolvedSrc !== fallbackSrc) { setFailedSource(sourceKey); - - if (canRetry) { - if (retryTimeoutRef.current) { - clearTimeout(retryTimeoutRef.current); - } - - retryTimeoutRef.current = setTimeout(() => { - setRetryAttempt((prev) => prev + 1); - setFailedSource((current) => (current === sourceKey ? null : current)); - retryTimeoutRef.current = null; - }, normalizedRetryDelayMs); - } } onError?.(event); }} diff --git a/apps/web/src/lib/web-socket/useConnectWebSocket.ts b/apps/web/src/lib/web-socket/useConnectWebSocket.ts index c52129aa..0949dba0 100644 --- a/apps/web/src/lib/web-socket/useConnectWebSocket.ts +++ b/apps/web/src/lib/web-socket/useConnectWebSocket.ts @@ -1,7 +1,9 @@ import { Client } from "@stomp/stompjs"; +import { type InfiniteData, useQueryClient } from "@tanstack/react-query"; import type { MutableRefObject } from "react"; import { useEffect, useState } from "react"; import SockJS from "sockjs-client"; +import { type ChatHistoriesResponse, ChatQueryKeys } from "@/apis/chat/api"; import { normalizeChatMessage, type RawChatMessage } from "@/apis/chat/normalize"; import { type ChatMessage, ConnectionStatus } from "@/types/chat"; @@ -21,10 +23,47 @@ interface UseConnectWebSocketReturn { const NEXT_PUBLIC_API_SERVER_URL = process.env.NEXT_PUBLIC_API_SERVER_URL; +const getMessageCacheKey = (message: ChatMessage) => { + 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 appendMessageToChatHistories = ( + oldData: InfiniteData | undefined, + message: ChatMessage, +) => { + if (!oldData || oldData.pages.length === 0) return oldData; + + const messageKey = getMessageCacheKey(message); + const hasMessage = oldData.pages.some((page) => + page.content.some((cachedMessage) => getMessageCacheKey(cachedMessage) === messageKey), + ); + + if (hasMessage) return oldData; + + return { + ...oldData, + pages: oldData.pages.map((page, index) => + index === 0 + ? { + ...page, + content: [...page.content, message], + } + : page, + ), + }; +}; + const useConnectWebSocket = ({ roomId, clientRef }: UseConnectWebSocketProps): UseConnectWebSocketReturn => { // Hook 내부에서 연결 상태를 직접 관리 const [connectionStatus, setConnectionStatus] = useState(ConnectionStatus.Disconnected); const [submittedMessages, setSubmittedMessages] = useState([]); + const queryClient = useQueryClient(); const accessToken = useAuthStore((state) => state.accessToken); const isInitialized = useAuthStore((state) => state.isInitialized); const hasValidAccessToken = Boolean(accessToken && !isTokenExpired(accessToken)); @@ -63,6 +102,11 @@ const useConnectWebSocket = ({ roomId, clientRef }: UseConnectWebSocketProps): U receivedMessage.createdAt = new Date().toISOString(); } + queryClient.setQueryData>( + [ChatQueryKeys.chatHistories, roomId], + (oldData) => appendMessageToChatHistories(oldData, receivedMessage), + ); + queryClient.invalidateQueries({ queryKey: [ChatQueryKeys.chatRooms], refetchType: "none" }); setSubmittedMessages((prev) => [...prev, receivedMessage]); } catch (error) {} }); @@ -92,7 +136,7 @@ const useConnectWebSocket = ({ roomId, clientRef }: UseConnectWebSocketProps): U } clientRef.current = null; }; - }, [roomId, clientRef, accessToken, hasValidAccessToken, isInitialized]); + }, [roomId, clientRef, accessToken, hasValidAccessToken, isInitialized, queryClient]); // 관리하는 connectionStatus를 반환 return { connectionStatus, submittedMessages, setSubmittedMessages }; diff --git a/apps/web/src/types/chat.ts b/apps/web/src/types/chat.ts index 60b9bcbb..96042516 100644 --- a/apps/web/src/types/chat.ts +++ b/apps/web/src/types/chat.ts @@ -18,6 +18,8 @@ export interface ChatAttachment { isImage: boolean; url: string; thumbnailUrl: string | null; + previewUrl?: string; + isOptimistic?: boolean; createdAt: string; }