import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ConversationType } from 'tuikit-atomicx-react/chat'; import { Dialog, IconAddCircle, IconBeautyAR, IconCall1, IconFile, IconImageRectangle, IconVideo, IconVideoDefault, Toast, useUIKit, } from '@tencentcloud/uikit-base-component-react'; import cs from 'classnames'; import { View } from 'tuikit-atomicx-react/chat'; import { useChatContext, useLoginStore } from 'tuikit-atomicx-react/chat'; import { UserPicker } from 'tuikit-atomicx-react/chat'; import { useChatUIState } from '../../context/useChatUIState'; import { MessageContentType } from '../../types/messageInput'; import { startCall } from '../../utils/call'; import { parseConversationDraftContent, serializeConversationDraftContent } from '../../utils/conversationDraft'; import { deleteLocalConversationDraft, getLocalConversationDraft, setLocalConversationDraft } from '../../utils/conversationDraftStorage'; import { MentionPanelH5 } from './MentionPanelH5/MentionPanelH5'; import { registerI18n } from './i18n'; import classes from './MessageInputH5.module.scss'; import { MessageInputPanelH5 } from './MessageInputPanelH5/MessageInputPanelH5'; import { QuotedMessagePreview } from './QuotedMessagePreview'; import { getSendErrorMessage } from './utils/getSendErrorMessage'; import type { MentionMember } from './MentionPanelH5/MentionPanelH5'; import type { CustomAction, IMessageInputProps } from '../MessageInput/types'; import type { IUserPickerRef, IUserPickerResult, IUserPickerRow } from 'tuikit-atomicx-react/chat'; import type { InputContent } from '../../types/messageInput'; import type { SendMessageInputOption, SendMessagePayload } from 'tuikit-atomicx-react/chat'; registerI18n(); type InputMode = 'none' | 'text' | 'emoji' | 'action'; type CallType = 1 | 2; interface MentionRecord { id: string; label: string; text: string; } interface MessageInputH5Props extends IMessageInputProps { channel?: string; } type BuiltInActionKey = | 'ImagePicker' | 'VideoPicker' | 'FilePicker' | 'AudioCallPicker' | 'VideoCallPicker'; type ConfigurableActionKey = BuiltInActionKey | 'EmojiPicker'; interface H5ActionItem { key: BuiltInActionKey; label: string; renderIcon: (size?: string) => React.ReactNode; onClick: () => void; className?: string; style?: React.CSSProperties; disabled?: boolean; } const EMOJI_LIST = [ '๐Ÿ˜€', '๐Ÿ˜ƒ', '๐Ÿ˜„', '๐Ÿ˜', '๐Ÿ˜†', '๐Ÿ˜…', '๐Ÿคฃ', '๐Ÿ˜‚', '๐Ÿ™‚', '๐Ÿ™ƒ', '๐Ÿ˜‰', '๐Ÿ˜Š', '๐Ÿ˜‡', '๐Ÿฅฐ', '๐Ÿ˜', '๐Ÿคฉ', '๐Ÿ˜˜', '๐Ÿ˜—', '๐Ÿ˜š', '๐Ÿ˜™', '๐Ÿฅฒ', '๐Ÿ˜‹', '๐Ÿ˜›', '๐Ÿ˜œ', '๐Ÿคช', '๐Ÿ˜', '๐Ÿค‘', '๐Ÿค—', '๐Ÿคญ', '๐Ÿคซ', '๐Ÿค”', '๐Ÿค', '๐Ÿ‘', '๐Ÿ‘Ž', '๐Ÿ‘', '๐Ÿ™Œ', '๐Ÿ‘', '๐Ÿคฒ', '๐Ÿค', '๐Ÿ™', 'โค๏ธ', '๐Ÿงก', '๐Ÿ’›', '๐Ÿ’š', '๐Ÿ’™', '๐Ÿ’œ', '๐Ÿ–ค', '๐Ÿค', ]; const MAX_TEXTAREA_HEIGHT = 100; const MIN_TEXTAREA_HEIGHT = 40; const MAX_GROUP_CALL_MEMBERS = 9; const MIN_GROUP_CALL_MEMBERS = 1; function getTextFromInputContent(content: string | InputContent[]) { if (typeof content === 'string') { return content; } return content.map((item) => { switch (item.type) { case MessageContentType.TEXT: return item.content; case MessageContentType.EMOJI: return item.content.text || item.content.key; case MessageContentType.MENTION: return `${item.content.mentionSuggestionChar}${item.content.label}`; default: return ''; } }).join(''); } function MessageInputH5({ autoFocus = false, disabled = false, disableMention = false, hideSendButton = false, placeholder, className = '', style = {}, maxLength = 500, actions, slots, channel = 'default', setOfflinePushInfo, }: MessageInputH5Props) { const { t } = useUIKit(); const { loginUserInfo } = useLoginStore(); const barRef = useRef(null); const textareaRef = useRef(null); const imageInputRef = useRef(null); const videoInputRef = useRef(null); const fileInputRef = useRef(null); const groupMemberPickerRef = useRef(null); const { activeConversation, activeConversationID, memberList, hasMoreMembers, loadMembers, loadMoreMembers, setConversationDraft, sendMessage, } = useChatContext(channel); const { enterTyping, leaveTyping, enableReadReceipt, pendingInputCommand, clearPendingInputCommand, } = useChatUIState(channel); const [inputValue, setInputValue] = useState(''); const [mode, setMode] = useState('none'); const [cursorPosition, setCursorPosition] = useState(0); const [mentionRecords, setMentionRecords] = useState([]); const [mentionTriggerIndex, setMentionTriggerIndex] = useState(null); const [mentionQuery, setMentionQuery] = useState(''); const [isMentionPanelVisible, setIsMentionPanelVisible] = useState(false); const [isComposing, setIsComposing] = useState(false); const [pendingCallType, setPendingCallType] = useState(null); const [groupCallSelectedCount, setGroupCallSelectedCount] = useState(0); const inputValueRef = useRef(inputValue); const mentionRecordsRef = useRef(mentionRecords); const activeConversationDraftRef = useRef(activeConversation?.draft); const pointerDownModeRef = useRef('none'); const computedPlaceholder = placeholder ?? (disabled ? '' : t('MessageInput.enter_a_message')); const hasValue = inputValue.trim().length > 0; const canMention = activeConversation?.type === ConversationType.Group || activeConversation?.conversationID?.startsWith('GROUP') === true || activeConversationID?.startsWith('GROUP') === true; const isC2CConversation = activeConversation?.type === ConversationType.C2C; const groupMemberOptions = useMemo(() => { const myUserID = loginUserInfo?.userID; return (memberList ?? []) .filter(member => member.userID !== myUserID) .map(member => ({ key: member.userID, label: member.nickname || member.userID, avatarUrl: member.avatarURL, })); }, [loginUserInfo?.userID, memberList]); const adjustTextareaHeight = useCallback(() => { const textarea = textareaRef.current; if (!textarea) { return; } textarea.style.height = `${MIN_TEXTAREA_HEIGHT}px`; const nextHeight = Math.max(MIN_TEXTAREA_HEIGHT, Math.min(textarea.scrollHeight, MAX_TEXTAREA_HEIGHT)); textarea.style.height = `${nextHeight}px`; textarea.style.overflowY = textarea.scrollHeight > MAX_TEXTAREA_HEIGHT ? 'auto' : 'hidden'; }, []); const saveCursorPosition = useCallback(() => { const textarea = textareaRef.current; if (textarea) { setCursorPosition(textarea.selectionStart ?? inputValue.length); } }, [inputValue.length]); const closeMentionPanel = useCallback(() => { setIsMentionPanelVisible(false); setMentionTriggerIndex(null); setMentionQuery(''); }, []); const updateMentionPanel = useCallback((text: string, cursor: number) => { if (!canMention || disableMention || disabled) { closeMentionPanel(); return; } const textBeforeCursor = text.slice(0, cursor); const atIndex = textBeforeCursor.lastIndexOf('@'); if (atIndex < 0) { closeMentionPanel(); return; } const query = textBeforeCursor.slice(atIndex + 1); if (/\s/.test(query)) { closeMentionPanel(); return; } setMentionTriggerIndex(atIndex); setMentionQuery(query); setIsMentionPanelVisible(true); }, [canMention, closeMentionPanel, disableMention, disabled]); useEffect(() => { if (disableMention) { closeMentionPanel(); } }, [closeMentionPanel, disableMention]); useEffect(() => { inputValueRef.current = inputValue; }, [inputValue]); useEffect(() => { mentionRecordsRef.current = mentionRecords; }, [mentionRecords]); useEffect(() => { activeConversationDraftRef.current = activeConversation?.draft; }, [activeConversation?.draft]); const getValidMentionRecords = useCallback((text: string, records: MentionRecord[]) => records.filter(record => text.includes(record.text)), []); const createInputContentFromState = useCallback((text: string, records: MentionRecord[]): InputContent[] => { const validRecords = getValidMentionRecords(text, records) .map(record => ({ ...record, index: text.indexOf(record.text) })) .filter(record => record.index >= 0) .sort((a, b) => a.index - b.index); const content: InputContent[] = []; let cursor = 0; validRecords.forEach((record) => { if (record.index < cursor) { return; } const before = text.slice(cursor, record.index); if (before) { content.push({ type: MessageContentType.TEXT, content: before }); } content.push({ type: MessageContentType.MENTION, content: { id: record.id, label: record.label, mentionSuggestionChar: '@', }, }); cursor = record.index + record.text.length; }); const after = text.slice(cursor); if (after) { content.push({ type: MessageContentType.TEXT, content: after }); } return content; }, [getValidMentionRecords]); const setDraftContent = useCallback((content: string | InputContent[] | undefined) => { setMentionRecords([]); if (typeof content === 'string' || content === undefined) { setInputValue(content ?? ''); return; } const textParts: string[] = []; const nextMentionRecords: MentionRecord[] = []; content.forEach((item) => { if (item.type === MessageContentType.TEXT) { textParts.push(item.content); } else if (item.type === MessageContentType.MENTION) { const mentionText = `${item.content.mentionSuggestionChar}${item.content.label}`; nextMentionRecords.push({ id: item.content.id, label: item.content.label, text: mentionText, }); textParts.push(mentionText); } else if (item.type === MessageContentType.EMOJI) { textParts.push(item.content.text || item.content.key); } }); setMentionRecords(nextMentionRecords); setInputValue(textParts.join('')); }, []); useEffect(() => { if (!pendingInputCommand) { return; } const command = pendingInputCommand; if (command.type === 'focus') { textareaRef.current?.focus(); clearPendingInputCommand(command.id); return; } if (command.type === 'blur') { textareaRef.current?.blur(); clearPendingInputCommand(command.id); return; } if (command.content === undefined) { clearPendingInputCommand(command.id); return; } const commandText = getTextFromInputContent(command.content); if (command.type === 'set') { setDraftContent(command.content); setCursorPosition(commandText.length); } else { const textarea = textareaRef.current; const cursor = textarea?.selectionStart ?? cursorPosition; const nextValue = `${inputValue.slice(0, cursor)}${commandText}${inputValue.slice(cursor)}`; const nextCursor = cursor + commandText.length; setInputValue(nextValue); setCursorPosition(nextCursor); requestAnimationFrame(() => { textareaRef.current?.setSelectionRange(nextCursor, nextCursor); }); } requestAnimationFrame(() => { adjustTextareaHeight(); if (command.focus !== false) { textareaRef.current?.focus(); } }); clearPendingInputCommand(command.id); }, [ adjustTextareaHeight, clearPendingInputCommand, cursorPosition, inputValue, pendingInputCommand, setDraftContent, ]); const saveCurrentDraft = useCallback((conversationID: string) => { const draft = serializeConversationDraftContent( createInputContentFromState(inputValueRef.current, mentionRecordsRef.current), ); setLocalConversationDraft(conversationID, draft).catch(() => {}); setConversationDraft(conversationID, draft).catch(() => {}); }, [createInputContentFromState, setConversationDraft]); useEffect(() => { if (!activeConversationID) { setDraftContent(''); return undefined; } let cancelled = false; getLocalConversationDraft(activeConversationID).then((localDraft) => { if (cancelled) { return; } setDraftContent(parseConversationDraftContent(localDraft ?? activeConversationDraftRef.current)); requestAnimationFrame(adjustTextareaHeight); }); return () => { cancelled = true; saveCurrentDraft(activeConversationID); }; }, [activeConversationID, adjustTextareaHeight, saveCurrentDraft, setDraftContent]); useEffect(() => { if (autoFocus) { textareaRef.current?.focus(); } }, [autoFocus]); useEffect(() => () => { leaveTyping(channel).catch(() => {}); }, [channel, leaveTyping]); function handleTextareaInput(event: React.ChangeEvent) { const nextValue = event.target.value; const nextCursor = event.target.selectionStart ?? nextValue.length; setInputValue(nextValue); setCursorPosition(nextCursor); adjustTextareaHeight(); if (!isComposing && nextValue.trim().length > 0) { enterTyping(channel).catch(() => {}); } updateMentionPanel(nextValue, nextCursor); } function handleTextareaFocus() { setMode('text'); updateMentionPanel(inputValue, cursorPosition); } function handleTextareaBlur() { saveCursorPosition(); leaveTyping(channel).catch(() => {}); } function insertTextAtCursor(textToInsert: string) { const before = inputValue.slice(0, cursorPosition); const after = inputValue.slice(cursorPosition); const nextValue = `${before}${textToInsert}${after}`; const nextCursor = cursorPosition + textToInsert.length; setInputValue(nextValue); setCursorPosition(nextCursor); requestAnimationFrame(() => { adjustTextareaHeight(); textareaRef.current?.setSelectionRange(nextCursor, nextCursor); }); } function insertEmoji(emoji: string) { insertTextAtCursor(emoji); setMode('emoji'); } function handleMentionSelect(member: MentionMember) { if (disableMention) { closeMentionPanel(); return; } const cursor = textareaRef.current?.selectionStart ?? cursorPosition; const start = mentionTriggerIndex ?? cursor; const label = member.nickname || member.userID; const mentionText = `@${label}`; const insertText = `${mentionText} `; const nextValue = `${inputValue.slice(0, start)}${insertText}${inputValue.slice(cursor)}`; const nextCursor = start + insertText.length; setInputValue(nextValue); setMentionRecords(prev => [...prev, { id: member.userID, label, text: mentionText }]); setCursorPosition(nextCursor); closeMentionPanel(); requestAnimationFrame(() => { adjustTextareaHeight(); textareaRef.current?.focus(); textareaRef.current?.setSelectionRange(nextCursor, nextCursor); }); } async function handleSend() { if (!inputValue.trim() || disabled) { return; } const text = inputValue.trim(); const atUserList = mentionRecords .filter(r => inputValue.includes(r.text)) .map(r => r.id); const payload: SendMessagePayload = { type: 'textMessage', text }; const option: SendMessageInputOption = {}; if (atUserList.length > 0) { option.atUserList = [...new Set(atUserList)]; } const offlinePushInfo = setOfflinePushInfo?.(payload); if (offlinePushInfo) { option.offlinePushInfo = offlinePushInfo; } if (enableReadReceipt !== undefined) { option.needReadReceipt = enableReadReceipt; } setInputValue(''); setMentionRecords([]); closeMentionPanel(); requestAnimationFrame(adjustTextareaHeight); try { await sendMessage(payload, Object.keys(option).length > 0 ? option : undefined); } catch (error) { Toast.error({ message: getSendErrorMessage(t, error), }); return; } if (activeConversationID) { deleteLocalConversationDraft(activeConversationID).catch(() => {}); } } function handleFileInput(event: React.ChangeEvent, type: MessageContentType.IMAGE | MessageContentType.VIDEO | MessageContentType.FILE) { const file = event.target.files?.[0]; if (!file) { return; } if (type === MessageContentType.IMAGE && !['image/jpg', 'image/jpeg', 'image/gif', 'image/bmp', 'image/png', 'image/webp'].includes(file.type)) { Toast.error({ message: t('MessageInput.invalid_image_type') }); return; } let payload: SendMessagePayload; if (type === MessageContentType.IMAGE) { payload = { type: 'imageMessage', file }; } else if (type === MessageContentType.VIDEO) { payload = { type: 'videoMessage', file, duration: 0 }; } else { payload = { type: 'fileMessage', file }; } const offlinePushInfo = setOfflinePushInfo?.(payload); const option: SendMessageInputOption = {}; if (offlinePushInfo) { option.offlinePushInfo = offlinePushInfo; } if (enableReadReceipt !== undefined) { option.needReadReceipt = enableReadReceipt; } sendMessage(payload, Object.keys(option).length > 0 ? option : undefined) .catch((error) => { Toast.error({ message: getSendErrorMessage(t, error), }); }); const { target } = event; target.value = ''; setMode('none'); } const handleCallClick = useCallback((type: CallType) => { if (!activeConversation || disabled) { return; } if (isC2CConversation) { const peerUserId = activeConversation.conversationID.replace(/^C2C/, ''); if (peerUserId) { startCall({ userIDList: [peerUserId], chatGroupID: undefined, type }); } setMode('none'); return; } loadMembers(); setGroupCallSelectedCount(0); setPendingCallType(type); }, [activeConversation, disabled, isC2CConversation, loadMembers]); const configuredActionKeys = useMemo(() => { if (!Array.isArray(actions)) { return null; } return new Set(actions.map(action => (typeof action === 'string' ? action : action.key) as ConfigurableActionKey)); }, [actions]); const showEmojiPicker = configuredActionKeys?.has('EmojiPicker') ?? true; function handleConfirmGroupCall() { const selectedMembers = groupMemberPickerRef.current?.getSelectedItems() ?? []; const currentGroupId = activeConversation?.conversationID.replace(/^GROUP/, '') ?? ''; if (!pendingCallType || selectedMembers.length === 0 || !currentGroupId) { return; } startCall({ userIDList: selectedMembers.map((member: IUserPickerRow) => member.key), chatGroupID: currentGroupId, type: pendingCallType, }); setGroupCallSelectedCount(0); setPendingCallType(null); setMode('none'); } function handleCloseGroupCallDialog() { setGroupCallSelectedCount(0); setPendingCallType(null); } const actionItems = useMemo(() => { const defaultActionMap: Record = { ImagePicker: { key: 'ImagePicker', label: t('MessageInput.image'), renderIcon: (size = '28px') => , onClick: () => imageInputRef.current?.click(), }, VideoPicker: { key: 'VideoPicker', label: t('MessageInput.video'), renderIcon: (size = '28px') => , onClick: () => videoInputRef.current?.click(), }, FilePicker: { key: 'FilePicker', label: t('MessageInput.file'), renderIcon: (size = '28px') => , onClick: () => fileInputRef.current?.click(), }, AudioCallPicker: { key: 'AudioCallPicker', label: t('MessageInput.audio_call'), renderIcon: (size = '28px') => , onClick: () => handleCallClick(1), }, VideoCallPicker: { key: 'VideoCallPicker', label: t('MessageInput.video_call'), renderIcon: (size = '28px') => , onClick: () => handleCallClick(2), }, }; const defaultOrder: BuiltInActionKey[] = [ 'ImagePicker', 'VideoPicker', 'FilePicker', 'AudioCallPicker', 'VideoCallPicker', ]; if (!Array.isArray(actions)) { return defaultOrder.map(actionKey => defaultActionMap[actionKey]); } return actions .map((action) => { if (typeof action === 'string') { if (action === 'EmojiPicker') { return null; } if (!(action in defaultActionMap)) { return null; } return defaultActionMap[action as BuiltInActionKey]; } const { key, label, className: actionClassName, style: actionStyle, iconSize, disabled: actionDisabled } = action as CustomAction; if (key === 'EmojiPicker') { return null; } if (!(key in defaultActionMap)) { return null; } const baseAction = defaultActionMap[key as BuiltInActionKey]; return { ...baseAction, label: label ?? baseAction.label, className: actionClassName, style: actionStyle, disabled: actionDisabled, renderIcon: (size = iconSize ? `${iconSize}px` : '28px') => baseAction.renderIcon(size), }; }) .filter((item): item is H5ActionItem => item !== null); }, [actions, t, handleCallClick]); const showActionPanelToggle = actionItems.length > 0; const shouldShowActionToggleButton = (!hasValue || hideSendButton) && showActionPanelToggle; const shouldShowSendButton = !hideSendButton && hasValue; return ( {slots?.headerToolbar?.() ?? }
{slots?.leftInline?.() &&
{slots.leftInline()}
}
{slots?.inputPrefix?.() &&
{slots.inputPrefix()}
}