From 54bea4f53ede6485fb358dc53c281b7e9d3e1400 Mon Sep 17 00:00:00 2001 From: manNomi Date: Thu, 21 May 2026 16:05:18 +0900 Subject: [PATCH 1/4] fix(web): improve mentor chat image pending state --- .../ChatContent/_hooks/useChatListHandler.ts | 125 +++++++++++++++++- .../ChatContent/_ui/ChatMessageBox/index.tsx | 124 ++++++++++++++--- .../chat/[chatId]/_ui/ChatContent/index.tsx | 7 +- apps/web/src/components/ui/FallbackImage.tsx | 52 +------- apps/web/src/types/chat.ts | 2 + 5 files changed, 241 insertions(+), 69 deletions(-) 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..dae7a61a 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 @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from "react"; import { 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 +21,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 +35,8 @@ 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()); // --- 2. 하위 Hooks 호출 --- @@ -76,8 +86,80 @@ 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; + + prevChatIdRef.current = chatId; hasInitialAutoScrolledRef.current = false; prevMessageCountRef.current = 0; }, [chatId]); @@ -161,10 +243,19 @@ const useChatListHandler = (chatId: number) => { ); // 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 }), @@ -185,11 +276,13 @@ const useChatListHandler = (chatId: number) => { 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 +294,8 @@ const useChatListHandler = (chatId: number) => { isImage: true, url: imageUrl, thumbnailUrl: imageUrl, + previewUrl: imageUrl, + isOptimistic: true, createdAt: new Date().toISOString(), }, ], @@ -208,6 +303,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 +349,7 @@ const useChatListHandler = (chatId: number) => { id: tempId, isImage: false, url: URL.createObjectURL(file), - thumbnailUrl: "", + thumbnailUrl: null, createdAt: new Date().toISOString(), }, ], @@ -266,6 +386,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..9585a6bb 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,16 @@ +"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; interface ChatMessageBoxProps { message: ChatMessage; @@ -15,6 +19,107 @@ 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 [isFailed, setIsFailed] = useState(false); + + useEffect(() => { + if (!enabled || !src) { + setIsReady(true); + setIsFailed(false); + return; + } + + let isCancelled = false; + let attempt = 0; + let timeoutId: ReturnType | null = null; + let probeImage: HTMLImageElement | null = null; + + setIsReady(false); + setIsFailed(false); + + const checkImage = () => { + attempt += 1; + probeImage = document.createElement("img"); + + probeImage.onload = () => { + if (isCancelled) return; + setIsReady(true); + setIsFailed(false); + }; + + probeImage.onerror = () => { + if (isCancelled) return; + + if (attempt >= CHAT_IMAGE_HEALTH_CHECK_LIMIT) { + setIsReady(true); + setIsFailed(true); + return; + } + + timeoutId = setTimeout(checkImage, CHAT_IMAGE_HEALTH_CHECK_INTERVAL_MS); + }; + + 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, isFailed }; +}; + +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 } = useChatImageHealthCheck(normalizedImageSrc, shouldHealthCheck); + + if (!isReady) { + return ; + } + + return ( + 첨부 이미지 + ); +}; + const ChatMessageBox = ({ message, currentUserId = 1, @@ -36,18 +141,7 @@ const ChatMessageBox = ({ {attachment.isImage ? ( // 이미지 렌더링
- 첨부 이미지 +
) : ( // 파일 렌더링 diff --git a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/index.tsx b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/index.tsx index 2238d736..a98a4838 100644 --- a/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/index.tsx +++ b/apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/index.tsx @@ -48,6 +48,7 @@ const ChatContent = ({ chatId }: ChatContentProps) => { sendTextMessage, sendImageMessage, addImageMessagePreview, + removeImageMessagePreviews, } = useChatListHandler(chatId); const uploadChatImagesMutation = useUploadChatImages(); @@ -182,14 +183,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/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; } From d834c5b1cfd123d23017c6fb68b9cb6bd262b7fe Mon Sep 17 00:00:00 2001 From: manNomi Date: Thu, 21 May 2026 16:13:31 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EB=A9=98=ED=86=A0=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EA=B9=9C=EB=B9=A1?= =?UTF-8?q?=EC=9E=84=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ChatContent/_ui/ChatMessageBox/index.tsx | 52 +++++++++++-------- .../chat/[chatId]/_ui/ChatContent/index.tsx | 8 ++- 2 files changed, 37 insertions(+), 23 deletions(-) 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 9585a6bb..b931afe4 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 @@ -41,12 +41,12 @@ const ChatImageLoading = () => ( const useChatImageHealthCheck = (src: string, enabled: boolean) => { const [isReady, setIsReady] = useState(!enabled); - const [isFailed, setIsFailed] = useState(false); + const [readySrc, setReadySrc] = useState(src); useEffect(() => { if (!enabled || !src) { setIsReady(true); - setIsFailed(false); + setReadySrc(src); return; } @@ -56,7 +56,7 @@ const useChatImageHealthCheck = (src: string, enabled: boolean) => { let probeImage: HTMLImageElement | null = null; setIsReady(false); - setIsFailed(false); + setReadySrc(""); const checkImage = () => { attempt += 1; @@ -65,7 +65,7 @@ const useChatImageHealthCheck = (src: string, enabled: boolean) => { probeImage.onload = () => { if (isCancelled) return; setIsReady(true); - setIsFailed(false); + setReadySrc(probeImage?.src ?? src); }; probeImage.onerror = () => { @@ -73,7 +73,7 @@ const useChatImageHealthCheck = (src: string, enabled: boolean) => { if (attempt >= CHAT_IMAGE_HEALTH_CHECK_LIMIT) { setIsReady(true); - setIsFailed(true); + setReadySrc(src); return; } @@ -95,28 +95,40 @@ const useChatImageHealthCheck = (src: string, enabled: boolean) => { }; }, [enabled, src]); - return { isReady, isFailed }; + 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 } = useChatImageHealthCheck(normalizedImageSrc, shouldHealthCheck); + const { isReady, readySrc } = useChatImageHealthCheck(normalizedImageSrc, shouldHealthCheck); + const displaySrc = readySrc || normalizedImageSrc; + const [isImageLoaded, setIsImageLoaded] = useState(!shouldHealthCheck || isLocalPreviewUrl(displaySrc)); - if (!isReady) { - return ; - } + useEffect(() => { + setIsImageLoaded(!shouldHealthCheck || isLocalPreviewUrl(displaySrc)); + }, [displaySrc, shouldHealthCheck]); return ( - 첨부 이미지 +
+ {(!isReady || !isImageLoaded) && ( +
+ +
+ )} + {isReady && ( + 첨부 이미지 setIsImageLoaded(true)} + /> + )} +
); }; @@ -140,9 +152,7 @@ const ChatMessageBox = ({
{attachment.isImage ? ( // 이미지 렌더링 -
- -
+ ) : ( // 파일 렌더링
{ {/* 첫 번째 메시지에 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}`; From 263aee7462d6902a0f7bf3316d0adcd32029bd77 Mon Sep 17 00:00:00 2001 From: manNomi Date: Thu, 21 May 2026 16:23:34 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=9E=98=EB=B9=97?= =?UTF-8?q?=20=EB=A9=98=ED=86=A0=20=EC=B1=84=ED=8C=85=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_ui/ChatContent/_hooks/useChatListHandler.ts | 7 ++++--- .../_ui/ChatContent/_ui/ChatMessageBox/index.tsx | 13 ++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) 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 dae7a61a..2608f807 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 @@ -37,6 +37,7 @@ const useChatListHandler = (chatId: number) => { const prevMessageCountRef = useRef(0); const prevChatIdRef = useRef(chatId); const imagePreviewByUrlRef = useRef>(new Map()); + const objectUrlsRef = useRef([]); // --- 2. 하위 Hooks 호출 --- @@ -159,6 +160,9 @@ const useChatListHandler = (chatId: number) => { 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; @@ -269,9 +273,6 @@ const useChatListHandler = (chatId: number) => { [chatId, connectionStatus], ); - // Track created object URLs for cleanup - const objectUrlsRef = useRef([]); - /** 이미지 파일만 미리보기 메시지로 추가 */ const addImageMessagePreview = useCallback( (files: File[], senderId: number) => { 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 b931afe4..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 @@ -11,6 +11,7 @@ import { getMessageType, shouldShowContent } from "./_utils/messageUtils"; 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; @@ -71,13 +72,11 @@ const useChatImageHealthCheck = (src: string, enabled: boolean) => { probeImage.onerror = () => { if (isCancelled) return; - if (attempt >= CHAT_IMAGE_HEALTH_CHECK_LIMIT) { - setIsReady(true); - setReadySrc(src); - return; - } - - timeoutId = setTimeout(checkImage, CHAT_IMAGE_HEALTH_CHECK_INTERVAL_MS); + 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); From 22edfcc0aa5182608d554c86ac8e6904c65d5f80 Mon Sep 17 00:00:00 2001 From: manNomi Date: Thu, 21 May 2026 16:28:39 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20?= =?UTF-8?q?=EC=9E=AC=EC=A7=84=EC=9E=85=20=EC=8B=9C=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/apis/chat/getChatMessages.ts | 1 + .../ChatContent/_hooks/useChatListHandler.ts | 15 ++++-- .../src/lib/web-socket/useConnectWebSocket.ts | 46 ++++++++++++++++++- 3 files changed, 58 insertions(+), 4 deletions(-) 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 2608f807..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,6 +1,7 @@ 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"; @@ -38,6 +39,7 @@ const useChatListHandler = (chatId: number) => { const prevChatIdRef = useRef(chatId); const imagePreviewByUrlRef = useRef>(new Map()); const objectUrlsRef = useRef([]); + const queryClient = useQueryClient(); // --- 2. 하위 Hooks 호출 --- @@ -56,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, @@ -239,11 +246,12 @@ 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( @@ -264,13 +272,14 @@ const useChatListHandler = (chatId: number) => { destination: `/publish/chat/${chatId}/image`, body: JSON.stringify({ imageUrls }), }); + invalidateChatPreviewQueries(); return true; } return false; }, - [chatId, connectionStatus], + [chatId, connectionStatus, invalidateChatPreviewQueries], ); /** 이미지 파일만 미리보기 메시지로 추가 */ 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 };