import { useMemo, useState } from 'react'; import type React from 'react'; import { IconToastWarning, i18next } from '@tencentcloud/uikit-base-component-react'; import cs from 'classnames'; import { Avatar, ConversationType, MessageStatus, MessageType, View, useChatContext, } from 'tuikit-atomicx-react/chat'; import { useChatUIState } from '../../../../context/useChatUIState'; import { useTime } from '../../../../hooks'; import { isCallMessage, isCreateGroupMessage } from '../../../../utils/call'; import { useMessageListContext } from '../../MessageListContext'; import { ReadReceiptInfo } from '../../ReadReceiptInfo'; import AudioMessage from '../AudioMessage'; import { CustomMessage } from '../CustomMessage'; import FaceMessage from '../FaceMessage'; import FileMessage from '../FileMessage'; import GroupTipMessage from '../GroupTipMessage'; import ImageMessage from '../ImageMessage'; // import LocationMessage from '../LocationMessage'; import MergerMessage from '../MergerMessage'; import MessageActionDropdown from '../MessageActionDropdown'; import MessageBubble from '../MessageBubble'; import { MessageStatusIcon } from '../MessageMeta'; import RecalledMessage from '../RecalledMessage'; import StreamMessage from '../StreamMessage'; import TextMessage from '../TextMessage'; import VideoMessage from '../VideoMessage'; import { useMessageLayoutClasses } from './hooks/useMessageLayoutClasses'; import classes from './MessageLayout.module.scss'; import type { MessageAction, MessageAlignmentType } from '../../../../types/message'; import type { MessageInfo, StreamMessagePayload } from 'tuikit-atomicx-react/chat'; interface MessageLayoutProps { alignment?: MessageAlignmentType | undefined; isAggregated?: boolean | undefined; isHiddenMessageAvatar?: boolean | undefined; removeAvatar?: boolean | undefined; isHiddenMessageNick?: boolean | undefined; isLastInChunk?: boolean | undefined; message: MessageInfo; nick?: string | undefined; messageActionList?: MessageAction[] | undefined; messageAvatar?: React.ComponentType<{ messageInfo: MessageInfo }> | undefined; // --- style props --- style?: React.CSSProperties | undefined; className?: string | undefined; } const MessageComponentsFactory = { [MessageType.Text]: TextMessage, [MessageType.Image]: ImageMessage, [MessageType.Video]: VideoMessage, [MessageType.Audio]: AudioMessage, [MessageType.File]: FileMessage, [MessageType.Face]: FaceMessage, [MessageType.Merged]: MergerMessage, [MessageType.Custom]: CustomMessage, [MessageType.Tips]: GroupTipMessage, [MessageType.Stream]: StreamMessage, } as any; function MessageLayout(props: MessageLayoutProps) { const { message, messageActionList, className, style, alignment, isAggregated, isHiddenMessageAvatar, removeAvatar, isHiddenMessageNick, nick: nickProp, messageAvatar: MessageAvatar, } = props; const { messageRenderers } = useMessageListContext(); const { highlightMessageIDSet } = useChatUIState(); const { getTimeStampAuto } = useTime(); const { activeConversation } = useChatContext(); const isMessageOwner = message.isSentBySelf; const isHighlighted = highlightMessageIDSet.has(message.msgID); const MessageComponent = messageRenderers?.[message.messageType] ?? MessageComponentsFactory[message.messageType]; const isStreamingMessage = message.messageType === MessageType.Stream && !(message.messagePayload as StreamMessagePayload).isStreamEnded; const isGroup = message.conversationType === ConversationType.Group; const [isReadReceiptInfoOpen, setIsReadReceiptInfoOpen] = useState(false); const { layoutClasses, contentClasses, headerClasses, bodyClasses, bubbleClasses, avatarClasses, statusClasses, } = useMessageLayoutClasses({ isMessageOwner, alignment, className, isAggregated, }); const shouldRenderAsGroupTip = useMemo(() => { if (message.messageType === MessageType.Custom && isCreateGroupMessage(message)) { return true; } if ( message.messageType === MessageType.Custom && isCallMessage(message) && message.conversationType === ConversationType.Group ) { return true; } return false; }, [message.msgID]); const showName = useMemo(() => { if (nickProp) { return nickProp; } if (activeConversation?.type === ConversationType.C2C) { if (!message.isSentBySelf) { return activeConversation?.title || message.from.nickname || message.from.userID; } return message.from.nickname || message.from.userID; } return message.from.nameCard || message.from.nickname || message.from.userID; }, [nickProp, activeConversation?.type, activeConversation?.title, message.isSentBySelf, message.from.nameCard, message.from.nickname, message.from.userID]); if (!MessageComponent) { return null; } if (message.status === MessageStatus.Recalled) { return ; } if (message.messageType === MessageType.Tips || shouldRenderAsGroupTip) { return ; } function handleReadReceiptOpen() { setIsReadReceiptInfoOpen(true); } function handleReadReceiptClose() { setIsReadReceiptInfoOpen(false); } function getReadReceiptText(): string { if ( !message.needReadReceipt || message.status !== MessageStatus.SendSuccess || !message.readReceiptInfo || !isMessageOwner ) { return ''; } const { readCount, unreadCount, isPeerRead } = message.readReceiptInfo; if (isGroup) { if (unreadCount === undefined || readCount === undefined) { return ''; } // When both are 0, receipt info hasn't been fetched yet — don't show "All Read" if (unreadCount === 0 && readCount === 0) { return ''; } if (unreadCount > 0) { return `${unreadCount}${i18next.t('MessageList.people', { defaultValue: ' ' })}${i18next.t('MessageList.unread', { defaultValue: 'Unread' })}`; } return i18next.t('MessageList.all_read', { defaultValue: 'All Read' }); } if (isPeerRead) { return i18next.t('MessageList.read', { defaultValue: 'Read' }); } return i18next.t('MessageList.unread', { defaultValue: 'Unread' }); } const renderStatusColumn = () => { const showSendStatus = isMessageOwner && (message.status === MessageStatus.Sending || message.status === MessageStatus.SendFail); const showReadReceipt = isMessageOwner && message.status === MessageStatus.SendSuccess && message.needReadReceipt; const readReceiptText = showReadReceipt ? getReadReceiptText() : ''; if (!showSendStatus && !(showReadReceipt && readReceiptText)) { return null; } return ( {showSendStatus && ( {message.status === MessageStatus.SendFail ? ( ) : ( )} )} {showReadReceipt && readReceiptText && ( {!isGroup && ( {readReceiptText} )} {isGroup && message.readReceiptInfo && message.readReceiptInfo.unreadCount > 0 && ( {readReceiptText} )} {isGroup && !(message.readReceiptInfo && message.readReceiptInfo.unreadCount > 0) && ( {readReceiptText} )} )} ); }; // Avatar display logic: // - alignment 'left' or 'right': always show avatar regardless of props // - removeAvatar=true: completely remove avatar from DOM (user takes full control of layout) // - isHiddenMessageAvatar=true: visually hide avatar but keep DOM space (no layout shift) const isSingleSideAlignment = alignment === 'left' || alignment === 'right'; const shouldRenderAvatar = isSingleSideAlignment || !removeAvatar; const avatarVisibility = !isSingleSideAlignment && isHiddenMessageAvatar ? 'hidden' : 'visible'; const messageBubble = ( ); const renderAvatar = () => { if (!shouldRenderAvatar) { return null; } if (MessageAvatar) { return ( ); } return ( ); }; return ( <> {renderAvatar()} {!isAggregated && ( {!isHiddenMessageNick && ( { showName } )} {getTimeStampAuto(message.timestamp?.getTime() || 0)} )} {message.status === MessageStatus.SendSuccess && !isStreamingMessage ? ( {messageBubble} ) : messageBubble} {renderStatusColumn()} ); } export default MessageLayout;