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
41 changes: 34 additions & 7 deletions apps/web/src/apis/chat/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,28 @@ const toNumber = (value: NumericLike): number => {
return 0;
};

const createStableHash = (value: string): number => {
let hash = 0;

for (let i = 0; i < value.length; i++) {
hash = (hash * 31 + value.charCodeAt(i)) | 0;
}

const normalized = Math.abs(hash);
return normalized === 0 ? 1 : normalized;
};

const getFallbackMessageId = (message: RawChatMessage): number => {
const senderId = toNumber(message.senderId ?? message.siteUserId);
const attachmentSignature = (message.attachments ?? [])
.map((attachment) => `${attachment.isImage ? "image" : "file"}:${attachment.url}:${attachment.createdAt}`)
.join(",");
const seed = `${senderId}|${message.createdAt}|${message.content}|${attachmentSignature}`;

// 서버에서 id가 누락되는 경우를 대비해 항상 동일한 임시 음수 id를 생성합니다.
return -createStableHash(seed);
};

const normalizeAttachment = (attachment: RawChatAttachment): ChatAttachment => ({
id: toNumber(attachment.id),
isImage: attachment.isImage,
Expand All @@ -56,13 +78,18 @@ const normalizeAttachment = (attachment: RawChatAttachment): ChatAttachment => (
createdAt: attachment.createdAt,
});

export const normalizeChatMessage = (message: RawChatMessage): ChatMessage => ({
id: toNumber(message.id),
content: message.content,
senderId: toNumber(message.senderId ?? message.siteUserId),
createdAt: message.createdAt,
attachments: (message.attachments ?? []).map(normalizeAttachment),
});
export const normalizeChatMessage = (message: RawChatMessage): ChatMessage => {
const parsedId = toNumber(message.id);
const normalizedId = parsedId > 0 ? parsedId : getFallbackMessageId(message);

return {
id: normalizedId,
content: message.content,
senderId: toNumber(message.senderId ?? message.siteUserId),
createdAt: message.createdAt,
attachments: (message.attachments ?? []).map(normalizeAttachment),
};
};

export const normalizeChatPartner = (partner: RawChatPartner): ChatPartner => ({
partnerId: toNumber(partner.partnerId ?? partner.siteUserId),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ import { type ChatMessage, ConnectionStatus } from "@/types/chat";
// --- 프로젝트 내부 의존성 ---
import useInfinityScroll from "@/utils/useInfinityScroll";

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 useChatListHandler = (chatId: number) => {
// --- 1. State 및 Ref 선언 ---
const clientRef = useRef<Client | null>(null);
Expand Down Expand Up @@ -46,11 +58,12 @@ const useChatListHandler = (chatId: number) => {
);
// Deduplicate by id, keeping the last occurrence (chronological order)
const dedupedMessages: ChatMessage[] = [];
const seenIds = new Set<string | number>();
const seenIds = new Set<string>();
for (let i = sortedMessages.length - 1; i >= 0; i--) {
const msg = sortedMessages[i];
if (!seenIds.has(msg.id)) {
seenIds.add(msg.id);
const dedupeKey = getMessageDedupeKey(msg);
if (!seenIds.has(dedupeKey)) {
seenIds.add(dedupeKey);
dedupedMessages.unshift(msg);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,14 @@ 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
? `message-${message.id}`
: `message-${message.senderId}-${message.createdAt}-${message.content}-${index}`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use a stable key for fallback chat messages

Using index in the fallback React key makes keys shift whenever older items are prepended by infinite scroll, so messages with missing server IDs (the exact target of this fix) are remounted on each page load instead of preserved. In practice this can cause visible flicker/reset of message bubbles and attachment rendering in long threads; the key should be derived from stable message identity only (e.g., fallback id/dedupe key) and not list position.

Useful? React with 👍 / 👎.


return (
<div
key={message.id}
key={messageKey}
ref={index === 0 ? topDetectorRef : null} // 첫 번째 메시지에 ref 부착
>
{/* 날짜 구분선 */}
Expand All @@ -155,7 +159,6 @@ const ChatContent = ({ chatId }: ChatContentProps) => {
)}
{/* 일반 채팅 메시지 */}
<ChatMessageBox
key={message.id}
message={message}
currentUserId={userId}
partnerNickname={nickname}
Expand Down
Loading