Skip to content

Commit 08ec85f

Browse files
authored
Merge pull request #521 from solid-connection/fix/mentor-chat-image-placeholder
fix(web): 멘토 채팅 이미지 로딩 재시도
2 parents cd209e9 + f76707b commit 08ec85f

4 files changed

Lines changed: 60 additions & 4 deletions

File tree

apps/web/src/apis/chat/normalize.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ const normalizeAttachment = (attachment: RawChatAttachment): ChatAttachment => (
7474
id: toNumber(attachment.id),
7575
isImage: attachment.isImage,
7676
url: attachment.url,
77-
thumbnailUrl: attachment.thumbnailUrl ?? "",
77+
thumbnailUrl: attachment.thumbnailUrl ?? null,
7878
createdAt: attachment.createdAt,
7979
});
8080

apps/web/src/app/mentor/chat/[chatId]/_ui/ChatContent/_ui/ChatMessageBox/index.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import { formatTime } from "@/utils/datetimeUtils";
55
import { downloadFile, getFileExtension, getFileNamePrefix } from "@/utils/fileUtils";
66
import { getMessageType, shouldShowContent } from "./_utils/messageUtils";
77

8+
const CHAT_IMAGE_RETRY_LIMIT = 5;
9+
const CHAT_IMAGE_RETRY_DELAY_MS = 1000;
10+
811
interface ChatMessageBoxProps {
912
message: ChatMessage;
1013
currentUserId?: number; // 현재 사용자 ID
@@ -34,13 +37,16 @@ const ChatMessageBox = ({
3437
// 이미지 렌더링
3538
<div className="relative overflow-hidden rounded-lg">
3639
<Image
37-
src={attachment.url}
40+
src={attachment.thumbnailUrl || attachment.url}
3841
cdnHostType="upload"
3942
alt="첨부 이미지"
4043
width={200}
4144
height={150}
4245
className="max-w-[200px] rounded-lg object-cover"
4346
unoptimized
47+
retryOnError
48+
retryLimit={CHAT_IMAGE_RETRY_LIMIT}
49+
retryDelayMs={CHAT_IMAGE_RETRY_DELAY_MS}
4450
/>
4551
</div>
4652
) : (

apps/web/src/components/ui/FallbackImage.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import NextImage from "next/image";
4-
import { useState } from "react";
4+
import { useEffect, useRef, useState } from "react";
55
import { getUploadCdnOrigin, normalizeImageUrlToUploadCdn } from "@/utils/cdnUrl";
66

77
const DEFAULT_FALLBACK_SRC = "/svgs/placeholders/image-placeholder.svg";
@@ -34,29 +34,79 @@ const resolveCdnUrl = (src: string, cdnHostType?: CdnHostType) => {
3434
type FallbackImageProps = React.ComponentProps<typeof NextImage> & {
3535
fallbackSrc?: string;
3636
cdnHostType?: CdnHostType;
37+
retryOnError?: boolean;
38+
retryLimit?: number;
39+
retryDelayMs?: number;
3740
};
3841

3942
const FallbackImage = ({
4043
src,
4144
fallbackSrc = DEFAULT_FALLBACK_SRC,
4245
cdnHostType,
46+
retryOnError = false,
47+
retryLimit = 0,
48+
retryDelayMs = 1000,
4349
onError,
4450
...props
4551
}: FallbackImageProps) => {
4652
const [failedSource, setFailedSource] = useState<string | null>(null);
53+
const [retryAttempt, setRetryAttempt] = useState(0);
54+
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
55+
const sourceKeyRef = useRef<string | null>(null);
4756

4857
const normalizedSrc = typeof src === "string" ? resolveCdnUrl(src, cdnHostType) || fallbackSrc : src;
4958
const sourceKey = typeof normalizedSrc === "string" ? normalizedSrc : JSON.stringify(normalizedSrc);
5059
const hasError = failedSource === sourceKey;
5160
const resolvedSrc = hasError ? fallbackSrc : normalizedSrc;
61+
const normalizedRetryLimit = Math.max(0, retryLimit);
62+
const normalizedRetryDelayMs = Math.max(0, retryDelayMs);
63+
const canRetry =
64+
retryOnError &&
65+
retryAttempt < normalizedRetryLimit &&
66+
typeof normalizedSrc === "string" &&
67+
normalizedSrc !== fallbackSrc;
68+
69+
useEffect(() => {
70+
if (sourceKeyRef.current === sourceKey) return;
71+
72+
sourceKeyRef.current = sourceKey;
73+
setFailedSource(null);
74+
setRetryAttempt(0);
75+
76+
if (retryTimeoutRef.current) {
77+
clearTimeout(retryTimeoutRef.current);
78+
retryTimeoutRef.current = null;
79+
}
80+
}, [sourceKey]);
81+
82+
useEffect(() => {
83+
return () => {
84+
if (retryTimeoutRef.current) {
85+
clearTimeout(retryTimeoutRef.current);
86+
}
87+
};
88+
}, []);
5289

5390
return (
5491
<NextImage
5592
{...props}
93+
key={`${sourceKey}:${hasError ? "fallback" : "source"}:${retryAttempt}`}
5694
src={resolvedSrc}
5795
onError={(event) => {
5896
if (!hasError && resolvedSrc !== fallbackSrc) {
5997
setFailedSource(sourceKey);
98+
99+
if (canRetry) {
100+
if (retryTimeoutRef.current) {
101+
clearTimeout(retryTimeoutRef.current);
102+
}
103+
104+
retryTimeoutRef.current = setTimeout(() => {
105+
setRetryAttempt((prev) => prev + 1);
106+
setFailedSource((current) => (current === sourceKey ? null : current));
107+
retryTimeoutRef.current = null;
108+
}, normalizedRetryDelayMs);
109+
}
60110
}
61111
onError?.(event);
62112
}}

apps/web/src/types/chat.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export interface ChatAttachment {
1717
id: number;
1818
isImage: boolean;
1919
url: string;
20-
thumbnailUrl: string;
20+
thumbnailUrl: string | null;
2121
createdAt: string;
2222
}
2323

0 commit comments

Comments
 (0)