Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/src/apis/chat/getChatMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, // 전역 로딩 비활성화
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -20,13 +22,24 @@ 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<Client | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null); // 새 메시지 수신 시 자동 스크롤을 위한 ref
const scrollContainerRef = useRef<HTMLDivElement>(null); // 실제 스크롤 컨테이너 ref
const hasInitialAutoScrolledRef = useRef(false);
const prevMessageCountRef = useRef(0);
const prevChatIdRef = useRef(chatId);
const imagePreviewByUrlRef = useRef<Map<string, string>>(new Map());
const objectUrlsRef = useRef<string[]>([]);
const queryClient = useQueryClient();

// --- 2. 하위 Hooks 호출 ---

Expand All @@ -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,
Expand Down Expand Up @@ -76,8 +94,83 @@ const useChatListHandler = (chatId: number) => {
}
}, [chatHistoryPages, setSubmittedMessages]);

useEffect(() => {
if (imagePreviewByUrlRef.current.size === 0) return;

const matchedPreviewUrls = new Set<string>();
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]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -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<string[]>([]);

/** 이미지 파일만 미리보기 메시지로 추가 */
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}`,
Expand All @@ -201,13 +304,40 @@ const useChatListHandler = (chatId: number) => {
isImage: true,
url: imageUrl,
thumbnailUrl: imageUrl,
previewUrl: imageUrl,
isOptimistic: true,
createdAt: new Date().toISOString(),
},
],
});
}
});
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],
);
Expand All @@ -229,7 +359,7 @@ const useChatListHandler = (chatId: number) => {
id: tempId,
isImage: false,
url: URL.createObjectURL(file),
thumbnailUrl: "",
thumbnailUrl: null,
createdAt: new Date().toISOString(),
},
],
Expand Down Expand Up @@ -266,6 +396,7 @@ const useChatListHandler = (chatId: number) => {
sendTextMessage,
sendImageMessage,
addImageMessagePreview,
removeImageMessagePreviews,
addFileMessagePreview,
};
};
Expand Down
Loading
Loading