import { useMemo, useState, useRef, useCallback, useEffect, useLayoutEffect, Fragment, forwardRef, useImperativeHandle } from 'react'; import type React from 'react'; import type { ComponentType } from 'react'; import { Toast, useUIKit } from '@tencentcloud/uikit-base-component-react'; import cs from 'classnames'; import { ConversationType, GroupType, MessageStatus, MessageType, View, GroupStore, useChatContext } from 'tuikit-atomicx-react/chat'; import { useChatUIState, chatUIStore } from '../../context/useChatUIState'; import { useMessageActions } from '../../hooks/useMessageActions'; import { useReadReceipt } from '../../hooks/useReadReceipt'; import { scrollToBottom, scrollToMessage, scrollToMessageImmediate } from '../../hooks/useScroll'; import { isCallMessage, isCreateGroupMessage } from '../../utils/call'; import { isTypingEndMessage, isTypingMessage, isTypingStartMessage } from '../../utils/chatTypingStatus'; import { registerI18n } from './i18n'; import { Message } from './Message'; import { MessageForward } from './MessageForward'; import { MessageListContext } from './MessageListContext'; import classes from './MessageListH5.module.scss'; import { MessageTimeDivider } from './MessageTimeDivider'; import { UnreadNotification } from './UnreadNotification'; import type { MessageAction, MessageAlignmentType } from '../../types/message'; import type { MessageInfo, StreamMessagePayload } from 'tuikit-atomicx-react/chat'; registerI18n(); interface MessageChunk { key: string; timestamp: number; messages: MessageInfo[]; } interface MessageListHandle { scrollToBottom: (behavior?: ScrollBehavior) => void; scrollToMessage: (messageID: string, options?: { behavior?: ScrollBehavior; block?: ScrollLogicalPosition }) => void; getScrollDistanceFromBottom: () => number; } interface MessageAvatarProps { messageInfo: MessageInfo; } interface MessageListProps { /** message alignment, default is 'two-sided' */ alignment?: MessageAlignmentType | undefined; /** enable read receipt */ enableReadReceipt?: boolean | undefined; /** message actions e.g. recall, delete, etc. Pass an empty array to disable the message context menu. */ messageActionList?: MessageAction[] | undefined; /** max time between message group */ messageAggregationTime?: number | undefined; /** custom filter function */ filter?: ((message: MessageInfo) => boolean) | undefined; /** custom class name */ className?: string | undefined; /** custom style */ style?: React.CSSProperties | undefined; /** custom message component */ Message?: React.ComponentType> | undefined; /** custom message timeline component */ MessageTimeDivider?: React.ComponentType | undefined; /** custom message avatar component */ messageAvatar?: React.ComponentType | undefined; /** custom local notification message component */ LocalNoticeMessage?: React.ComponentType | undefined; /** custom renderers to override built-in message bubble content by MessageType */ messageRenderers?: Partial>> | undefined; /** conversation id */ conversationID?: string | undefined; /** * When alignment is 'two-sided', whether to show the avatar for self-sent messages. * Defaults to false (self-sent messages have no avatar in two-sided mode). */ showSelfAvatar?: boolean | undefined; } /** Get unix timestamp (seconds) from MessageInfo */ function getMessageTime(message: MessageInfo): number { if (message.timestamp instanceof Date) { return Math.floor(message.timestamp.getTime() / 1000); } return 0; } const INITIAL_SCROLL_STABLE_FRAME_COUNT = 2; const INITIAL_SCROLL_MAX_FRAME_COUNT = 8; function stabilizeScrollToBottom(container: HTMLElement, onSettled: () => void): () => void { let rafID: number | null = null; let isCancelled = false; let frameCount = 0; let stableFrameCount = 0; let previousScrollHeight = -1; const run = () => { if (isCancelled) { return; } scrollToBottom(container, 'instant'); const currentScrollHeight = container.scrollHeight; if (Math.abs(currentScrollHeight - previousScrollHeight) < 1) { stableFrameCount += 1; } else { stableFrameCount = 0; previousScrollHeight = currentScrollHeight; } frameCount += 1; if (stableFrameCount >= INITIAL_SCROLL_STABLE_FRAME_COUNT || frameCount >= INITIAL_SCROLL_MAX_FRAME_COUNT) { onSettled(); return; } rafID = requestAnimationFrame(run); }; run(); return () => { isCancelled = true; if (rafID !== null) { cancelAnimationFrame(rafID); } }; } const MessageListH5 = forwardRef((props, ref) => { const { messageAggregationTime: propMessageAggregationTime, messageActionList: propMessageActionList, filter, className, style, Message: ResolvedMessage = Message, MessageTimeDivider: ResolvedMessageTimeDivider = MessageTimeDivider, messageAvatar, LocalNoticeMessage = null, messageRenderers, alignment: propAlignment, enableReadReceipt: propEnableReadReceipt, showSelfAvatar = false, } = props; const { t, messageListConfig, direction } = useUIKit(); const resolvedAlignment = (propAlignment ?? messageListConfig?.alignment ?? 'two-sided') as MessageAlignmentType; const resolvedEnableReadReceipt = propEnableReadReceipt ?? messageListConfig?.enableReadReceipt ?? false; const resolvedMessageAggregationTime = propMessageAggregationTime ?? messageListConfig?.messageAggregationTime ?? (5 * 60); const configMessageActions = useMessageActions(messageListConfig?.messageActionList); const resolvedMessageActionList = propMessageActionList ?? configMessageActions; const enableMessageAggregation = resolvedMessageAggregationTime > 0; const { joinedGroupList } = GroupStore(); const { activeConversationID, activeConversation, messageList, hasOlderMessages, hasNewerMessages, loadOlderMessages, loadNewerMessages, loadMessages, messageListOnEvent, clearConversationUnreadCount, } = useChatContext(); const { enableReadReceipt, setEnableReadReceipt, highlightMessage, isForwardModalOpen, closeForwardModal, listMode, setListMode, isPeerTyping, setIsPeerTyping, pendingLocateMessage, setPendingLocateMessage, recalledMessageIDSet, } = useChatUIState(); const isMessageJumping = listMode === 'fragment'; const currentGroupID = activeConversationID?.startsWith('GROUP') ? activeConversationID.slice(5) : undefined; const groupInfo = useMemo(() => joinedGroupList.find(g => g.groupID === currentGroupID), [joinedGroupList, currentGroupID]); const groupType = groupInfo?.groupType; const isGroup = activeConversation?.type === ConversationType.Group; const shouldSendReadReceipt = useCallback((message: MessageInfo) => { if (!activeConversation) { return false; } if (message.needReadReceipt === false) { return false; } if (isGroup) { return !message.isSentBySelf; } if (activeConversation?.type === ConversationType.C2C) { return !message.isSentBySelf && message.readReceiptInfo?.isPeerRead === false; } return false; }, [activeConversation, isGroup]); const getMessageIDFromDom = useCallback((dom: Element) => dom.getAttribute('data-message-id') || '', []); const { resetProcessedMessages, } = useReadReceipt({ enabled: enableReadReceipt ?? false, containerSelector: '#message-list-container', messageSelector: '[data-message-id]', getMessageIDFromDom, shouldSendReadReceipt, }); // Scroll container ref const scrollContainerRef = useRef(null); useImperativeHandle(ref, () => ({ scrollToBottom: (behavior: ScrollBehavior = 'smooth') => scrollToBottom(scrollContainerRef.current, behavior), scrollToMessage: (messageID: string, options) => scrollToMessage(scrollContainerRef.current, messageID, options), getScrollDistanceFromBottom: () => { const el = scrollContainerRef.current; if (!el) { return 0; } return el.scrollHeight - el.scrollTop - el.clientHeight; }, }), []); // Inline scroll state (aligned with Vue: simple isNearBottom check) const NEAR_BOTTOM_THRESHOLD = 20; // Fragment mode pulls newer messages a bit earlier than the strict bottom // threshold so scrolling down feels responsive. const LOAD_NEWER_THRESHOLD = 150; const isNearBottomRef = useRef(true); // Pause auto-scroll while any message context menu is open const openContextMenuCountRef = useRef(0); const isContextMenuOpenRef = useRef(false); // message list states const [unreadCount, setUnreadCount] = useState(0); const [isScrollToBottomVisible, setIsScrollToBottomVisible] = useState(false); const [isInitialScrollReady, setIsInitialScrollReady] = useState(true); const latestMessage = messageList && messageList.length > 0 ? messageList[messageList.length - 1] : undefined; const latestStreamPayload = latestMessage?.messageType === MessageType.Stream ? latestMessage.messagePayload as StreamMessagePayload : undefined; const latestStreamMessageID = latestStreamPayload ? latestMessage?.msgID : undefined; const latestStreamMarkdown = latestStreamPayload?.markdown; const isLatestStreamEnded = latestStreamPayload?.isStreamEnded; // message list refs const previousConversationID = useRef(''); const finishFirstRenderRef = useRef(false); const disableAutoScrollRef = useRef(false); const isLoadingRef = useRef(false); const isLoadingNewerRef = useRef(false); const cancelInitialScrollStabilizationRef = useRef<(() => void) | null>(null); const streamScrollRafRef = useRef(null); const listModeRef = useRef(listMode); listModeRef.current = listMode; const hasNewerMessagesRef = useRef(hasNewerMessages); hasNewerMessagesRef.current = hasNewerMessages; useEffect(() => { if ( resolvedEnableReadReceipt && groupType !== GroupType.Community && groupType !== GroupType.AVChatRoom ) { setEnableReadReceipt(true); } else { setEnableReadReceipt(false); } }, [resolvedEnableReadReceipt, setEnableReadReceipt, groupType]); const initializeMessageList = async () => { cancelInitialScrollStabilizationRef.current?.(); cancelInitialScrollStabilizationRef.current = null; setUnreadCount(0); setIsScrollToBottomVisible(false); finishFirstRenderRef.current = false; disableAutoScrollRef.current = false; isLoadingRef.current = false; isLoadingNewerRef.current = false; resetProcessedMessages(); // If there's a pending locate message for current conversation, enter fragment mode const locateInfo = pendingLocateMessage; if (locateInfo) { // Do NOT hide the container: keep the previous content visible while the // fragment loads, then position synchronously in the layout effect below. setIsInitialScrollReady(true); await loadMessages({ messageListType: 'history', cursor: locateInfo, direction: 'both', }) .then(() => { setListMode('fragment'); }) .catch(() => { setPendingLocateMessage(null); Toast.error({ message: t('MessageList.origin_message_has_been_recalled'), }); if (!finishFirstRenderRef.current) { loadMessages(); } }); } else { setIsInitialScrollReady(false); setListMode('latest'); await loadMessages(); } }; useEffect(() => () => { cancelInitialScrollStabilizationRef.current?.(); if (streamScrollRafRef.current !== null) { cancelAnimationFrame(streamScrollRafRef.current); } }, []); // 处理切换会话时的初始化滚动 useLayoutEffect(() => { if (!messageList?.length) { if (!finishFirstRenderRef.current) { setIsInitialScrollReady(true); } return; } if (finishFirstRenderRef.current || !scrollContainerRef.current) { return; } if (pendingLocateMessage) { // Position synchronously here — layout effects run after DOM mutation but // before paint, so setting scrollTop now makes the target already centered // on the very first painted frame (no flash, no white screen, no scroll). const targetMsgID = pendingLocateMessage.msgID; scrollToMessageImmediate(scrollContainerRef.current, targetMsgID, { block: 'start' }); setPendingLocateMessage(null); highlightMessage({ messageID: targetMsgID, duration: 3000 }); } else { cancelInitialScrollStabilizationRef.current?.(); cancelInitialScrollStabilizationRef.current = stabilizeScrollToBottom(scrollContainerRef.current, () => { cancelInitialScrollStabilizationRef.current = null; finishFirstRenderRef.current = true; setIsInitialScrollReady(true); }); return; } finishFirstRenderRef.current = true; }, [messageList]); // Populate recalledMessageIDSet by scanning messageList for recalled messages useEffect(() => { if (!messageList?.length) { return; } let updated: Set | null = null; for (const message of messageList) { if (message.status === MessageStatus.Recalled || message.status === MessageStatus.Deleted) { if (updated === null) { updated = new Set(recalledMessageIDSet); } updated.add(message.msgID); } } if (updated !== null) { chatUIStore.setState({ recalledMessageIDSet: updated }); } }, [messageList]); // Watch pendingLocateMessage for same-conversation search locate useLayoutEffect(() => { if (!pendingLocateMessage) { return; } const isInCurrentList = messageList?.some(message => message.msgID === pendingLocateMessage.msgID) ?? false; if (isInCurrentList) { if (!scrollContainerRef.current) { return; } scrollToMessage(scrollContainerRef.current, pendingLocateMessage.msgID, { block: 'start', behavior: 'smooth', skipIfVisible: false, }).catch(() => {}); highlightMessage({ messageID: pendingLocateMessage.msgID, duration: 3000 }); setPendingLocateMessage(null); return; } if (!isInCurrentList) { initializeMessageList(); } }, [pendingLocateMessage]); // Watch activeConversationID switch useEffect(() => { if (!activeConversationID) { return; } if (activeConversationID !== previousConversationID.current) { previousConversationID.current = activeConversationID; initializeMessageList(); } }, [activeConversationID]); const handleLoadOlderMessage = useCallback(async () => { const el = scrollContainerRef.current; if (!finishFirstRenderRef.current || isLoadingRef.current || !hasOlderMessages || !el) { return; } isLoadingRef.current = true; const oldScrollHeight = el.scrollHeight; try { await loadOlderMessages(); if (scrollContainerRef.current) { scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight - oldScrollHeight; } } finally { isLoadingRef.current = false; } }, [hasOlderMessages, loadOlderMessages]); // Fragment mode: load newer messages when scrolling toward the bottom. // New messages are appended below the viewport, so unlike loadOlder there is // no need to compensate scrollTop — the content above the viewport is unchanged. const handleLoadNewerMessage = useCallback(async () => { const el = scrollContainerRef.current; if (!finishFirstRenderRef.current || isLoadingNewerRef.current || !hasNewerMessagesRef.current || !el) { return; } isLoadingNewerRef.current = true; try { await loadNewerMessages(); } finally { isLoadingNewerRef.current = false; } }, [loadNewerMessages]); const handleScroll = useCallback(() => { const el = scrollContainerRef.current; if (!el) { return; } const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; isNearBottomRef.current = distanceFromBottom < NEAR_BOTTOM_THRESHOLD; if (isNearBottomRef.current && listModeRef.current === 'latest') { // Scrolled back to bottom: hide button and clear unread count disableAutoScrollRef.current = false; setIsScrollToBottomVisible(false); setUnreadCount(0); } // NOTE: do NOT show the button here when leaving bottom; // the button is only shown when a new message arrives while not at bottom. // Load more when scrolled to top if (el.scrollTop <= 50) { handleLoadOlderMessage(); } // Fragment mode: pull newer messages when approaching the bottom. // The switch back to 'latest' is handled reactively by watching // hasNewerMessages, so here we only need to keep paging downward. if ( listModeRef.current === 'fragment' && hasNewerMessagesRef.current && distanceFromBottom < LOAD_NEWER_THRESHOLD ) { handleLoadNewerMessage(); } }, [handleLoadOlderMessage, handleLoadNewerMessage]); const scheduleStreamScroll = useCallback(() => { if ( listModeRef.current !== 'latest' || !finishFirstRenderRef.current || !isNearBottomRef.current || isContextMenuOpenRef.current ) { if (streamScrollRafRef.current !== null) { cancelAnimationFrame(streamScrollRafRef.current); streamScrollRafRef.current = null; } return; } if (streamScrollRafRef.current !== null) { cancelAnimationFrame(streamScrollRafRef.current); } streamScrollRafRef.current = requestAnimationFrame(() => { streamScrollRafRef.current = null; if ( listModeRef.current === 'latest' && finishFirstRenderRef.current && isNearBottomRef.current && !isContextMenuOpenRef.current ) { scrollToBottom(scrollContainerRef.current, 'auto'); } }); }, []); useLayoutEffect(() => { if (!latestStreamMessageID) { return; } scheduleStreamScroll(); }, [ latestStreamMessageID, latestStreamMarkdown, isLatestStreamEnded, scheduleStreamScroll, ]); useLayoutEffect(() => { const container = scrollContainerRef.current; if (!container || !latestStreamMessageID || typeof ResizeObserver === 'undefined') { return undefined; } const streamMessageElement = Array .from(container.querySelectorAll('[data-message-id]')) .find(element => element.dataset.messageId === latestStreamMessageID); if (!streamMessageElement) { return undefined; } const resizeObserver = new ResizeObserver(scheduleStreamScroll); resizeObserver.observe(streamMessageElement); return () => resizeObserver.disconnect(); }, [latestStreamMessageID, scheduleStreamScroll]); const handleBackToLatest = useCallback(async () => { if (!activeConversationID) { return; } setUnreadCount(0); setIsScrollToBottomVisible(false); setListMode('latest'); await loadMessages(); requestAnimationFrame(() => { scrollToBottom(scrollContainerRef.current, 'instant'); }); }, [activeConversationID, loadMessages, setListMode]); // Fragment mode → latest transition. // When downward paging reaches the newest message, the Store flips // hasNewerMessages to false and internally switches back to sequential mode // (so realtime messages resume appending). Mirror that in the UI by leaving // fragment mode. We intentionally do NOT force a scrollToBottom here: the user // reached the bottom by scrolling, so their position is already continuous. // This is safe against a fresh jump because the first screen (>= hopping page // size) guarantees hasNewerMessages starts as true right after a jump. useEffect(() => { if (listMode === 'fragment' && !hasNewerMessages && finishFirstRenderRef.current) { setListMode('latest'); setUnreadCount(0); } }, [listMode, hasNewerMessages, setListMode]); const messageChunks = useMemo(() => { if (messageList === undefined) { return []; } const filteredMessageList = filter ? messageList.filter(filter) : messageList.filter(message => message.status !== MessageStatus.Deleted); if (!enableMessageAggregation) { const chunks: MessageChunk[] = []; filteredMessageList.forEach((message) => { chunks.push({ timestamp: getMessageTime(message), messages: [message], key: `chunk-${message.msgID}`, }); }); return chunks; } const chunks: MessageChunk[] = []; const MAX_TIME_BETWEEN_MESSAGE_GROUP = resolvedMessageAggregationTime; filteredMessageList.forEach((message, index, messages) => { const messageTime = getMessageTime(message); const lastChunk = chunks[chunks.length - 1]; const prevMessage = index > 0 ? messages[index - 1] : undefined; const isRecalled = message.status === MessageStatus.Recalled; const prevIsRecalled = prevMessage?.status === MessageStatus.Recalled; const isFailed = message.status === MessageStatus.SendFail; const prevIsFailed = prevMessage?.status === MessageStatus.SendFail; const isViolation = message.status === MessageStatus.Violation; const prevIsViolation = prevMessage?.status === MessageStatus.Violation; const shouldCreateNewChunk = !lastChunk || messageTime - lastChunk.timestamp > MAX_TIME_BETWEEN_MESSAGE_GROUP || lastChunk.messages[0].from?.userID !== message.from?.userID || isRecalled || prevIsRecalled || isFailed || prevIsFailed || isViolation || prevIsViolation || isCallMessage(message) || (prevMessage && isCallMessage(prevMessage)) || (prevMessage && isCreateGroupMessage(prevMessage)); if (shouldCreateNewChunk) { chunks.push({ timestamp: messageTime, messages: [message], key: `chunk-${message.msgID}`, }); } else { lastChunk.messages.push(message); } }); return chunks; }, [messageList, enableMessageAggregation, resolvedMessageAggregationTime]); // Subscribe to new message events (aligned with Vue's onEvent approach) useEffect(() => { if (!activeConversationID) { setIsPeerTyping(false); return undefined; } setIsPeerTyping(false); const unsub = messageListOnEvent((event) => { if (event.type !== 'onReceiveNewMessage') { return; } const { message } = event; if (isTypingMessage(message)) { if (isTypingStartMessage(message)) { setIsPeerTyping(true); } else if (isTypingEndMessage(message)) { setIsPeerTyping(false); } return; } if (message.isSentBySelf) { if (listMode === 'fragment') { setTimeout(() => { handleBackToLatest(); }, 1000); } else if (!isContextMenuOpenRef.current) { scrollToBottom(scrollContainerRef.current, 'smooth'); } return; } // Other's message if (activeConversationID) { clearConversationUnreadCount(activeConversationID).catch(() => {}); } if (listMode === 'fragment') { setUnreadCount(prev => prev + 1); return; } if (isNearBottomRef.current) { // Skip auto-scroll while a message context menu is open if (!isContextMenuOpenRef.current) { scrollToBottom(scrollContainerRef.current, 'smooth'); } } else { // Not at bottom: increment unread count and show the scroll-to-bottom button setUnreadCount(prev => prev + 1); setIsScrollToBottomVisible(true); } }); return unsub; }, [activeConversationID, listMode, setIsPeerTyping]); const handleContextMenuOpenChange = useCallback((open: boolean) => { openContextMenuCountRef.current = Math.max(0, openContextMenuCountRef.current + (open ? 1 : -1)); isContextMenuOpenRef.current = openContextMenuCountRef.current > 0; }, []); const messageListContextValue = useMemo( () => ({ ...(messageRenderers !== undefined ? { messageRenderers } : {}), onContextMenuOpenChange: handleContextMenuOpenChange, }), [messageRenderers, handleContextMenuOpenChange], ); // Register scroll listener useEffect(() => { const el = scrollContainerRef.current; if (!el) { return undefined; } el.addEventListener('scroll', handleScroll, { passive: true }); return () => { el.removeEventListener('scroll', handleScroll); }; }, [handleScroll]); if (messageList === undefined) { return ; } return ( {messageChunks.length > 0 && ( {hasOlderMessages ? t('MessageList.load_more') : t('MessageList.no_more')} )} {messageChunks.map((chunk, chunkIndex, chunks) => ( {chunk.messages.map((message, messageIndex, messages) => ( ))} ))} {!!LocalNoticeMessage && } {isPeerTyping && ( {t('MessageList.peer_is_typing')} )} {(isScrollToBottomVisible || isMessageJumping) && ( { setUnreadCount(0); setIsScrollToBottomVisible(false); scrollToBottom(scrollContainerRef.current, 'smooth'); }} /> )} closeForwardModal()} /> ); }); MessageListH5.displayName = 'MessageListH5'; export { MessageListH5 }; export type { MessageListProps, MessageListHandle, MessageAvatarProps };