let __listenerIdCounter = 0; import React, { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo } from 'react'; import { Animated, View, TextInput, TouchableOpacity, StyleProp, ViewStyle, TextStyle, KeyboardAvoidingViewProps, KeyboardAvoidingView, Modal, Platform, NativeModules, Text, Image, Dimensions, ImageSourcePropType, ImageStyle, InteractionManager, Keyboard, ColorValue, TouchableWithoutFeedbackProps, ScrollView, StyleSheet, } from 'react-native'; import { JSX } from 'react'; import RichTextEditor, { type RichTextEditorRef, type ContentChangeEvent, type ActiveStylesState, type PasteMediaItem, } from '../CometChatRichTextEditor'; /** * Link tap event data emitted by the native editor when a user taps an existing link. * Matches the LinkTapEventData interface from the rich text editor bridge source. */ interface LinkTapEventData { url: string; text: string; location: number; length: number; } import { Style } from './styles'; import { calculateInputHeight, resolveEffectiveMaxHeight, getIconAlignment, DEFAULT_MIN_HEIGHT, DEFAULT_LINE_HEIGHT, DEFAULT_PADDING_VERTICAL, } from './heightUtils'; //@ts-ignore import { CometChat } from '@cometchat/chat-sdk-react-native'; import { startStreamingForRunId, stopStreamingForRunId, streamingState$ } from '../shared/services/stream-message.service'; import { CometChatSoundManager, CometChatMentionsFormatter, CometChatUrlsFormatter, CometChatTextFormatter, ChatConfigurator, CometChatUIKit, CometChatBottomSheet, CometChatActionSheet, CometChatMediaRecorder, CometChatMessagePreview, CometChatSuggestionList, SuggestionItem, useCometChatTranslation, CometChatUIEvents, CometChatInlineAudioRecorder, } from '../shared'; import { useTheme } from '../theme'; import { CometChatTheme } from '../theme/type'; import { deepMerge } from '../shared/helper/helperFunctions'; import { CometChatMessageComposerAction, DeepPartial } from '../shared/helper/types'; import { isAgenticUser as checkIsAgenticUser, setQuotedMessageSafe, deriveHideButton, deriveDisableFeature, ReplyMessageState, parseMentionKey, calcDeletionRange, collectOverlappingMentions, shiftRemainingMentionKeys, MentionOverlap, } from '../shared/helper/composerHelpers'; import { Icon } from '../shared/icons/Icon'; import { CometChatSendButtonView } from '../shared/views/CometChatSendButtonView/CometChatSendButtonView'; import { CometChatLinkConfirmPopup } from '../shared/views/CometChatLinkConfirmPopup'; import { getUnixTimestampInMilliseconds, messageStatus, } from '../shared/utils/CometChatMessageHelper'; import { MessageTypeConstants, ReceiverTypeConstants, ConversationOptionConstants, MentionsVisibility, MentionsTargetElement, ViewAlignment, EnterKeyBehavior, DISABLED_BUTTON_OPACITY } from '../shared/constants/UIKitConstants'; import { MessageEvents } from '../shared/events'; import { CometChatUIEventHandler } from '../shared/events/CometChatUIEventHandler/CometChatUIEventHandler'; import { CometChatMessageEvents } from '../shared/events/CometChatMessageEvents'; import { ICONS } from './resources'; import { CommonUtils } from '../shared/utils/CommonUtils'; import { isCursorWithinMentionRange, getMentionRangeAtCursor } from '../shared/utils/MentionUtils'; import { stripMarkdown } from '../shared/utils/MarkdownUtils'; import { commonVars } from '../shared/base/vars'; import { permissionUtil } from '../shared/utils/PermissionUtil'; import { CheckPropertyExists } from '../shared/helper/functions'; import { CometChatStickerKeyboard } from '../extensions/Stickers/CometChatStickerKeyboard'; import { ExtensionTypeConstants } from '../extensions/ExtensionConstants'; import { SelectedAttachment, toAttachmentFile } from '../shared/modals/SelectedAttachment'; import { UploadQueueService } from '../shared/services/UploadQueueService'; import { openMultiFileChooser } from '../shared/utils/openMultiFileChooser'; import { getFileUploadLimits, clampToServer, acceptPickedAttachments, toastAttachmentRejection } from '../shared/utils/attachmentLimits'; import { partitionAttachmentsByKind, AttachmentKindGroup } from '../shared/utils/partitionAttachmentsByKind'; import { attachmentCountLabel } from '../shared/utils/conversationUtils'; import { CometChatComposerErrorBanner } from '../shared/views/CometChatComposerErrorBanner'; // §5.0 — canonical DD name (CometChatAttachmentPreview is the back-compat alias) import { CometChatAttachmentTray } from '../shared/views/CometChatAttachmentPreview'; import { CometChatMediaViewer } from '../shared/views/CometChatMediaViewer'; import { StagedVideoDurationProbe } from '../shared/views/CometChatAttachmentPreview/StagedVideoDurationProbe'; const { FileManager, CommonUtil } = NativeModules; // Listener IDs at module scope (v5 pattern) const editMessageListenerID = "editMessageListener_" + Date.now() + "_" + (++__listenerIdCounter); const replyMessageListenerID = "replyMessageListener_" + Date.now() + "_" + (++__listenerIdCounter); const uiEventListenerID = "uiEventListener_" + Date.now() + "_" + (++__listenerIdCounter); const uiEventListenerShowID = "uiEventListenerShow_" + Date.now() + "_" + (++__listenerIdCounter); const uiEventListenerHideID = "uiEventListenerHide_" + Date.now() + "_" + (++__listenerIdCounter); // Mic/sticker animation constants (hoisted to module level) const MIC_ANIM_DURATION = 150; const MIC_SLIDE_DISTANCE = 40; // URL detection regex for paste-over-selection link creation (hoisted to module level) // Note: paste-URL-over-selection is handled natively in iOS/Android editors /** * Styles for the custom rich text formatting toolbar. * Note: theme-dependent values (colors) are applied inline at render time. * Layout values use static constants since they don't vary by theme. */ const richTextToolbarStyles = StyleSheet.create({ btn: { width: 36, height: 36, borderRadius: 6, justifyContent: 'center', alignItems: 'center', }, separator: { width: 1, height: 20, marginHorizontal: 4, }, toolbarScrollContent: { alignItems: 'center', gap: 2, }, }); /** * Toolbar item configuration for the rich text formatting toolbar. * Items with type 'separator' render a vertical divider. * Items with type 'button' render a tappable icon. */ type ToolbarSeparator = { type: 'separator' }; type ToolbarButton = { type: 'button'; key: string; icon: any; iconName?: string; isActive?: (styles: ActiveStylesState) => boolean; onPress: ( inputRef: React.RefObject, selectionPosition: { start: number; end: number }, inputTextRef: React.RefObject, setLinkText: (t: string) => void, setLinkUrl: (u: string) => void, setShowLinkModal: (v: boolean) => void, ) => void; }; type ToolbarItem = ToolbarSeparator | ToolbarButton; const richTextToolbarItems: ToolbarItem[] = [ { type: 'button', key: 'bold', icon: null, iconName: 'format-bold', isActive: (s) => s.bold, onPress: (ref) => ref.current?.toggleBold() }, { type: 'button', key: 'italic', icon: null, iconName: 'format-italic', isActive: (s) => s.italic, onPress: (ref) => ref.current?.toggleItalic() }, { type: 'button', key: 'underline', icon: null, iconName: 'format-underlined', isActive: (s) => s.underline, onPress: (ref) => ref.current?.toggleUnderline() }, { type: 'button', key: 'strikethrough', icon: null, iconName: 'format-strikethrough', isActive: (s) => s.strikethrough, onPress: (ref) => ref.current?.toggleStrikethrough() }, { type: 'separator' }, { type: 'button', key: 'link', icon: null, iconName: 'link', onPress: (_ref, selectionPosition, inputTextRef, setLinkText, setLinkUrl, setShowLinkModal) => { const { start, end } = selectionPosition; const currentText = inputTextRef.current || ''; const selectedText = start !== end ? currentText.substring(start, end) : ''; setLinkText(selectedText); setLinkUrl(''); setShowLinkModal(true); }, }, { type: 'button', key: 'ordered-list', icon: null, iconName: 'format-list-numbered', isActive: (s) => s.blockType === 'numbered' || s.blockType === 'quoteNumbered', onPress: (ref) => ref.current?.setNumberedList() }, { type: 'button', key: 'unordered-list', icon: null, iconName: 'format-list-bulleted', isActive: (s) => s.blockType === 'bullet' || s.blockType === 'quoteBullet', onPress: (ref) => ref.current?.setBulletList() }, { type: 'separator' }, { type: 'button', key: 'blockquote', icon: null, iconName: 'format-quote', isActive: (s) => s.blockType === 'quote' || s.blockType === 'quoteBullet' || s.blockType === 'quoteNumbered', onPress: (ref) => ref.current?.setQuote() }, { type: 'button', key: 'inline-code', icon: null, iconName: 'code', isActive: (s) => s.code, onPress: (ref) => ref.current?.toggleCode() }, { type: 'button', key: 'code-block', icon: null, iconName: 'code-blocks', onPress: (ref) => ref.current?.toggleCodeBlock() }, ]; /** * ActionSheetBoard component for displaying attachment options (v5 pattern) */ const ActionSheetBoard = (props: any) => { const { shouldShow = false, onClose = () => {}, onDismiss, options = [], sheetRef, style } = props; return ( ); }; /** * RecordAudio component for voice recording (v5 pattern) */ const RecordAudio = (props: any) => { const { shouldShow = false, onClose = () => {}, cometChatBottomSheetStyle = {}, sheetRef, onPause = () => {}, onPlay = () => {}, onSend = (_recordedFile: string) => {}, onStop = (_recordedFile: string) => {}, onStart = () => {}, mediaRecorderStyle, } = props; return ( ); }; /** * MessagePreviewTray component for displaying edit/reply message preview (v5 pattern) */ const MessagePreviewTray = (props: any) => { const { shouldShow = false, message = null, onClose = () => {}, title = '' } = props; const { t } = useCometChatTranslation(); if (!shouldShow) return null; // Editing a media caption: caption text is the editable subtitle; a muted overline (media-type icon + // attachment count) restores the context the hidden thumbnail gave. const isMediaCaptionEdit = typeof message?.getCaption === 'function'; return ( ); }; /** * AttachIconButton component for attachment button (v5 pattern) */ const AttachIconButton = (props: { onPress: TouchableWithoutFeedbackProps["onPress"]; icon: ImageSourcePropType | JSX.Element; iconStyle: ImageStyle; }) => { return ( ); }; /** * EmojiButton component for SingleLineTextComposer. * A custom emoji/sticker button with proper styling (no extra padding). * Opens the sticker keyboard panel when pressed. */ interface EmojiButtonProps { user?: CometChat.User; group?: CometChat.Group; composerIdMap: Map; replyToMessage?: CometChat.BaseMessage; closeReplyPreview?: () => void; editorRef?: React.RefObject; } const EmojiButton = ({ user, group, composerIdMap, replyToMessage, closeReplyPreview, editorRef }: EmojiButtonProps) => { const [isPanelOpen, setIsPanelOpen] = React.useState(false); const [keyboardOpen, setKeyboardOpen] = React.useState(false); const theme = useTheme(); const loggedInUser = React.useRef(null); const uiListenerIdRef = React.useRef(`emoji_button_${Date.now()}`); // Use refs to store reply message info to avoid stale closures const replyToMessageRef = React.useRef(replyToMessage); const closeReplyPreviewRef = React.useRef(closeReplyPreview); // Update refs when props change React.useEffect(() => { replyToMessageRef.current = replyToMessage; closeReplyPreviewRef.current = closeReplyPreview; }, [replyToMessage, closeReplyPreview]); // Fetch logged-in user React.useEffect(() => { CometChat.getLoggedinUser().then((u: CometChat.User | null) => { if (u) loggedInUser.current = u; }); }, []); // Keyboard event handling const keyboardShowEvent = Platform.select({ ios: "keyboardWillShow", android: "keyboardDidShow", }) as any; const keyboardHideEvent = Platform.select({ ios: "keyboardWillHide", android: "keyboardDidHide", }) as any; React.useEffect(() => { const keyboardDidShowListener = Keyboard.addListener(keyboardShowEvent, () => { setKeyboardOpen(true); if (isPanelOpen) { closePanel(); } }); const keyboardDidHideListener = Keyboard.addListener(keyboardHideEvent, () => { setKeyboardOpen(false); }); return () => { keyboardDidShowListener.remove(); keyboardDidHideListener.remove(); }; }, [isPanelOpen, keyboardShowEvent, keyboardHideEvent]); // Send sticker message const sendCustomMessage = React.useCallback((sticker: any) => { let receiverId = user?.getUid() || group?.getGuid(); let receiverType = user ? CometChat.RECEIVER_TYPE.USER : group ? CometChat.RECEIVER_TYPE.GROUP : undefined; if (!receiverType) return; let customType = ExtensionTypeConstants.sticker; let parentId = composerIdMap?.get("parentMessageId") || undefined; let customMessage = new CometChat.CustomMessage( receiverId, receiverType, customType, sticker ); customMessage.setCategory(CometChat.CATEGORY_CUSTOM as CometChat.MessageCategory); customMessage.setParentMessageId(parentId); customMessage.setMuid(String(getUnixTimestampInMilliseconds())); if (loggedInUser.current) customMessage.setSender(loggedInUser.current as CometChat.User); if (user || group) { customMessage.setReceiver((user || group)!); } customMessage.shouldUpdateConversation(true); customMessage.setMetadata({ incrementUnreadCount: true }); // Set quoted message if replying const currentReplyMessage = replyToMessageRef.current; if (currentReplyMessage) { customMessage.setQuotedMessage(currentReplyMessage); customMessage.setQuotedMessageId(currentReplyMessage.getId()); } // Close reply preview const currentClosePreview = closeReplyPreviewRef.current; if (currentClosePreview) { currentClosePreview(); } CometChatUIKit.sendCustomMessage(customMessage) .then((res: CometChat.BaseMessage) => { if (currentReplyMessage) { CometChatMessageEvents.emit(CometChatMessageEvents.ccReplyToMessage, { message: res, status: messageStatus.success, }); } }) .catch((err: any) => { console.error("Failed to send sticker:", err); }); }, [user, group, composerIdMap]); const openPanel = React.useCallback(() => { Keyboard.dismiss(); editorRef?.current?.blur(); setIsPanelOpen(true); CometChatUIEventHandler.emitUIEvent(CometChatUIEvents.showPanel, { alignment: ViewAlignment.composerBottom, child: () => , panelId: "sticker", }); }, [sendCustomMessage, editorRef]); const closePanel = React.useCallback(() => { CometChatUIEventHandler.emitUIEvent(CometChatUIEvents.hidePanel, { alignment: ViewAlignment.composerBottom, child: () => null, panelId: "sticker", }); setIsPanelOpen(false); }, []); const togglePanel = React.useCallback(() => { if (isPanelOpen) { closePanel(); } else { if (keyboardOpen) { Keyboard.dismiss(); setTimeout(() => { openPanel(); }, 200); } else { openPanel(); } } }, [isPanelOpen, keyboardOpen, openPanel, closePanel]); // Listen to global UI events React.useEffect(() => { const id = uiListenerIdRef.current; CometChatUIEventHandler.addUIListener(id, { hidePanel: (payload: any) => { if (isPanelOpen) { if (!payload || payload?.panelId === "sticker" || payload?.alignment === ViewAlignment.composerBottom) { setIsPanelOpen(false); } } }, showPanel: (payload: any) => { if (payload?.panelId === "sticker") { setIsPanelOpen(true); } }, }); return () => { CometChatUIEventHandler.removeUIListener(id); }; }, [isPanelOpen]); return ( ); }; /** * Style interface for CometChatSingleLineMessageComposer. * This is a type alias for backward compatibility with v4 patterns. * In v5, styles are defined using DeepPartial. */ export type SingleLineMessageComposerStyleInterface = DeepPartial; /** * Props interface for the CometChatSingleLineMessageComposer component (v5 pattern) * * This interface matches CometChatMessageComposerInterface for full API compatibility. */ export interface CometChatCompactMessageComposerInterface { /** * Message composer identifier. * @type {string | number} */ id?: string | number; /** * CometChat SDK's user object. * @type {CometChat.User} */ user?: CometChat.User; /** * CometChat SDK's group object. * @type {CometChat.Group} */ group?: CometChat.Group; /** * Initial text value for the input. * @type {string} */ text?: string; /** * Placeholder text for the input. * @type {string} */ placeHolderText?: string; /** * Callback triggered when the input text changes. * @param {string} text - The updated text. */ onChangeText?: (text: string) => void; /** * Flag to turn off sound for outgoing messages. * @type {boolean} */ disableSoundForOutgoingMessages?: boolean; /** * Flag to turn off sound for messages (alias for disableSoundForOutgoingMessages). * @type {boolean} */ disableSoundForMessages?: boolean; /** * Custom audio sound to be played while sending messages. * @type {*} */ customSoundForOutgoingMessage?: any; /** * Custom audio sound for messages (alias for customSoundForOutgoingMessage). * @type {*} */ customSoundForMessage?: any; /** * Flag to disable typing events. * @type {boolean} */ disableTypingEvents?: boolean; /** * Initial text to be displayed in the composer. * @type {string} */ initialComposertext?: string; /** * Renders a preview section at the top of the composer. */ HeaderView?: ({ user, group }: { user?: CometChat.User; group?: CometChat.Group }) => JSX.Element; /** * Renders a footer section at the bottom of the composer. */ FooterView?: ({ user, group }: { user?: CometChat.User; group?: CometChat.Group }) => JSX.Element; /** * Callback triggered when the input text changes (alias for onChangeText). * @param {string} text - The updated text. */ onTextChange?: (text: string) => void; /** * Returns the attachment options for the composer. */ attachmentOptions?: ({ user, group, composerId, }: { user?: CometChat.User; group?: CometChat.Group; composerId: Map; }) => CometChatMessageComposerAction[]; /** * Replaces the default Auxiliary Button. */ AuxiliaryButtonView?: ({ user, group, composerId, }: { user?: CometChat.User; group?: CometChat.Group; composerId: string | number; }) => JSX.Element; /** * Replaces the default Secondary Button (attachment button). */ SecondaryButtonView?: ({ user, group, composerId, }: { user?: CometChat.User; group?: CometChat.Group; composerId: string | number; }) => JSX.Element; /** * Replaces the default Send Button. */ SendButtonView?: ({ user, group, composerId, }: { user?: CometChat.User; group?: CometChat.Group; composerId: string | number; }) => JSX.Element; /** * Message id required for threaded messages. * @type {string | number} */ parentMessageId?: string | number; /** * Custom styles for the message composer component. */ style?: DeepPartial; /** * Custom styles for the message composer (alias for style). */ messageComposerStyle?: DeepPartial; /** * Custom styles for the input field. */ inputStyle?: StyleProp; /** * Custom styles for the send button. */ sendButtonStyle?: StyleProp; /** * Custom icon for the send button. */ sendButtonIcon?: ImageSourcePropType; /** * Flag to hide the voice recording button. * @type {boolean} */ hideVoiceRecordingButton?: boolean; /** * Flag to hide voice recording (alias for hideVoiceRecordingButton). * @type {boolean} */ hideVoiceRecording?: boolean; /** * Custom icon URL for voice recording. */ voiceRecordingIconURL?: ImageSourcePropType; /** * Custom styles for the media recorder. */ mediaRecorderStyle?: any; /** * Custom pause icon URL for media recorder. */ pauseIconUrl?: ImageSourcePropType; /** * Custom play icon URL for media recorder. */ playIconUrl?: ImageSourcePropType; /** * Custom record icon URL for media recorder. */ recordIconUrl?: ImageSourcePropType; /** * Custom delete icon URL for media recorder. */ deleteIconUrl?: ImageSourcePropType; /** * Custom stop icon URL for media recorder. */ stopIconUrl?: ImageSourcePropType; /** * Custom submit icon URL for media recorder. */ submitIconUrl?: ImageSourcePropType; /** * Callback triggered when voice recording starts. */ onVoiceRecordingStart?: () => void; /** * Callback triggered when voice recording ends. * @param {string} recordedFile - The path to the recorded file. */ onVoiceRecordingEnd?: (recordedFile: string) => void; /** * Callback triggered when the send button is pressed. * @param {CometChat.BaseMessage} message - The base message object. */ onSendButtonPress?: (message: CometChat.BaseMessage) => void; /** * Callback triggered when an error occurs. * @param {CometChat.CometChatException} error - The error object. */ onError?: (error: CometChat.CometChatException) => void; /** * Override properties for the KeyboardAvoidingView. */ keyboardAvoidingViewProps?: KeyboardAvoidingViewProps; /** * Collection of text formatter classes to apply custom formatting. */ textFormatters?: Array< CometChatMentionsFormatter | CometChatUrlsFormatter | CometChatTextFormatter >; /** * Flag to disable mention functionality. */ disableMentions?: boolean; /** * Flag to disable the special group mention (@all / @channel etc.). * @default false */ disableMentionAll?: boolean; /** * Custom alias label for the group-wide mention. * @default "all" */ mentionAllLabel?: string; /** * Controls image quality when taking pictures from the camera. * @default 20 */ imageQuality?: number; /** * Enables the multiple-attachment workflow (staging tray + multi-upload; a send * fans out into one message per kind). When `false`, attachments use the legacy * single-select, send-immediately path (one picker → one message, no tray). * @default true */ enableMultipleAttachments?: boolean; /** * If true, hides the camera option from the attachment options. */ hideCameraOption?: boolean; /** * If true, hides the image attachment option from the attachment options. */ hideImageAttachmentOption?: boolean; /** * If true, hides the video attachment option from the attachment options. */ hideVideoAttachmentOption?: boolean; /** * If true, hides the audio attachment option from the attachment options. */ hideAudioAttachmentOption?: boolean; /** * If true, hides the file/document attachment option from the attachment options. */ hideFileAttachmentOption?: boolean; /** * If true, hides the polls option from the attachment options. */ hidePollsAttachmentOption?: boolean; /** * If true, hides the collaborative document option. */ hideCollaborativeDocumentOption?: boolean; /** * If true, hides the collaborative whiteboard option. */ hideCollaborativeWhiteboardOption?: boolean; /** * If true, hides the entire attachment button from the composer. */ hideAttachmentButton?: boolean; /** * Custom icon for the attachment button. */ attachmentIcon?: ImageSourcePropType; /** * Callback triggered when the attachment button is clicked. */ onAttachmentClick?: () => void; /** * If true, hides the stickers button from the composer. */ hideStickersButton?: boolean; /** * If true, hides the send button from the composer. */ hideSendButton?: boolean; /** * If true, hides all auxiliary buttons. */ hideAuxiliaryButtons?: boolean; /** * If true, hides the auxiliary button (alias for hideAuxiliaryButtons). */ hideAuxiliaryButton?: boolean; /** * Additional attachment options to append to defaults. */ addAttachmentOptions?: ({ user, group, composerId, }: { user?: CometChat.User; group?: CometChat.Group; composerId: Map; }) => CometChatMessageComposerAction[]; /** * Determines the alignment of auxiliary buttons. * @default "left" */ auxiliaryButtonsAlignment?: "left" | "right"; /** * Determines the alignment of auxiliary buttons (deprecated alias). * @default "right" */ auxiliaryButtonAlignment?: "left" | "right"; /** * Custom send button view for AI agents (only applies to @agentic users) */ AgentSendButtonView?: React.ComponentType<{ isButtonDisabled: boolean; composerRef: any; }>; // ============================================ // Auto-Expand Configuration Props (SingleLineTextComposer specific) // ============================================ /** * Maximum number of lines before scrolling is enabled. * @default 5 */ maxLines?: number; /** * Enable rich text formatting toolbar (bold, italic, underline, strikethrough, code). * When true, an inline toolbar is shown above the text input. * When false, the editor behaves like a plain TextInput. * @default true */ enableRichTextEditor?: boolean; /** * Hides all rich text formatting options. * When true, the toolbar is always visible by default (no toggle needed). * @default false */ hideRichTextFormattingOptions?: boolean; /** * Behavior when Enter key is pressed (Android only — iOS always inserts new line). * @default EnterKeyBehavior.NewLine * @platform Android */ enterKeyBehavior?: EnterKeyBehavior; /** * Show Bold/Italic/Underline/Strikethrough in the text selection context menu. * When true, formatting options appear in the native text selection popup. * @default true */ showTextSelectionMenuItems?: boolean; /** * Maximum number of mentions allowed per message. * @default 10 */ maxMentionLimit?: number; /** * Callback triggered when mention limit is reached. */ onMentionLimitReached?: () => void; /** * Minimum height for the input in pixels. * @default 40 */ minInputHeight?: number; /** * Maximum height for the input in pixels. */ maxInputHeight?: number; } /** * Helper component for icon buttons (v5 pattern) */ const IconButton = (props: { icon?: ImageSourcePropType | JSX.Element; name?: string; onClick: () => void; buttonStyle?: StyleProp; iconStyle?: ImageStyle; disable?: boolean; tintColor?: ColorValue; testID?: string; }) => { const { icon, name, onClick, buttonStyle, iconStyle, disable, tintColor, testID } = props; return ( ); }; /** * CometChatSingleLineMessageComposer - A compact, single-line text input component * for composing and sending text messages. * * This component is API-compatible with CometChatMessageComposer, allowing * developers to easily swap between the two based on their UI requirements. * * Key differences from CometChatMessageComposer: * - Single-line input (no multiline support) * - Text-only messaging (no attachments, voice recording, etc.) * - Compact UI suitable for inline messaging scenarios * * @example * // Basic usage * * * @example * // With custom auxiliary buttons * } * auxiliaryButtonsAlignment="right" * /> */ export const CometChatCompactMessageComposer = React.forwardRef( (props: CometChatCompactMessageComposerInterface, ref) => { const theme = useTheme(); const { t } = useCometChatTranslation(); // Attachment error/info messages show as a red banner just above the tray (rendered below). const [attachmentBannerMessage, setAttachmentBannerMessage] = React.useState(null); const [attachmentBannerHeight, setAttachmentBannerHeight] = React.useState(0); const attachmentBannerTimer = React.useRef | null>(null); const showToast = useCallback((message: string) => { setAttachmentBannerMessage(message); if (attachmentBannerTimer.current) clearTimeout(attachmentBannerTimer.current); attachmentBannerTimer.current = setTimeout(() => setAttachmentBannerMessage(null), 4000); }, []); useEffect(() => () => { if (attachmentBannerTimer.current) clearTimeout(attachmentBannerTimer.current); }, []); const { id, user, group, text, placeHolderText, onChangeText, style, messageComposerStyle, // API compatibility inputStyle, sendButtonStyle, sendButtonIcon, SendButtonView, onSendButtonPress, AuxiliaryButtonView, SecondaryButtonView, auxiliaryButtonsAlignment, auxiliaryButtonAlignment, // deprecated alias hideAuxiliaryButton, hideAuxiliaryButtons, hideAttachmentButton: propHideAttachmentButton, hideStickersButton: propHideStickersButton, hideSendButton, HeaderView, FooterView, parentMessageId, disableTypingEvents: propDisableTypingEvents, disableMentions: propDisableMentions, disableSoundForMessages = true, customSoundForMessage, textFormatters, onError, keyboardAvoidingViewProps, // Attachment props attachmentIcon, attachmentOptions, onAttachmentClick, imageQuality = 20, // Additional attachment options addAttachmentOptions, // Individual hide options for attachments hideCameraOption, hideImageAttachmentOption, hideVideoAttachmentOption, hideAudioAttachmentOption, hideFileAttachmentOption, hidePollsAttachmentOption, hideCollaborativeDocumentOption, hideCollaborativeWhiteboardOption, // Voice recording props hideVoiceRecording: propHideVoiceRecording, voiceRecordingIconURL, mediaRecorderStyle, pauseIconUrl, playIconUrl, recordIconUrl, deleteIconUrl, stopIconUrl, submitIconUrl, onVoiceRecordingStart, onVoiceRecordingEnd, // Auto-expand configuration props maxLines, minInputHeight, maxInputHeight, // Rich text formatting prop enableRichTextEditor = true, hideRichTextFormattingOptions = false, enterKeyBehavior = EnterKeyBehavior.NewLine, showTextSelectionMenuItems = true, maxMentionLimit = 10, onMentionLimitReached, // Agentic user props AgentSendButtonView, // Initial text prop initialComposertext, // Mention configuration props disableMentionAll = false, mentionAllLabel = "all", // §1 — when false, attachments fall back to the legacy single-select, send-immediately // path (no staging tray, no multi-upload/fan-out). Default true = multi-attachment. enableMultipleAttachments = true, } = props; // Auto-expand is always enabled (internal behavior) const autoExpand = true; // ============================================ // Agentic User Support // ============================================ /** * Helper function to check if the current user is an agentic user. * Agentic users have the role '@agentic' and require special handling: * - Auto-hide certain buttons (attachment, stickers, voice recording) * - Disable typing events and mentions * - Apply send button delay * - Track parent message ID for threaded conversations * * @returns {boolean} True if user has role '@agentic' */ const isAgenticUser = useCallback((): boolean => { return checkIsAgenticUser(user); }, [user]); /** * Derived state variables for automatic button hiding for agentic users. * When user is agentic, these buttons are automatically hidden regardless of prop values. * Uses shared helper function for consistency with CometChatMessageComposer. */ const isAgentic = isAgenticUser(); const hideAttachmentButton = deriveHideButton(isAgentic, propHideAttachmentButton); const hideStickersButton = deriveHideButton(isAgentic, propHideStickersButton); const hideVoiceRecordingButton = deriveHideButton(isAgentic, propHideVoiceRecording); /** * Derived state variables for disabling typing events and mentions for agentic users. * When user is agentic, these features are automatically disabled. * Uses shared helper function for consistency with CometChatMessageComposer. */ const disableTypingEvents = deriveDisableFeature(isAgentic, propDisableTypingEvents); const disableMentions = deriveDisableFeature(isAgentic, propDisableMentions); // Resolve alignment (support both prop names) const resolvedAlignment = auxiliaryButtonsAlignment || auxiliaryButtonAlignment || 'right'; // Merge styles using deepMerge (support both style and messageComposerStyle) // For agentic users, hide the divider line const mergedComposerStyle = useMemo(() => { const propStyle = style || messageComposerStyle || {}; const mergedStyle = deepMerge(theme.messageComposerStyles || {}, propStyle); // Hide divider for agentic users (matching CometChatMessageComposer behavior) if (isAgentic) { return { ...mergedStyle, messageInputStyles: { ...mergedStyle.messageInputStyles, dividerStyle: { display: 'none' as const } } }; } return mergedStyle; }, [theme.messageComposerStyles, style, messageComposerStyle, isAgentic]); // Use merged style throughout the component const resolvedStyle = mergedComposerStyle; /** * inputTextRef: holds the current text value without triggering re-renders. * Updated on every keystroke from onContentChange. * inputText state is only updated for programmatic changes (mentions, clear, edit) * to push text to native via the text prop. */ const inputTextRef = React.useRef(initialComposertext ?? text ?? ''); const [inputText, setInputText] = React.useState(initialComposertext ?? text ?? ''); /** * Flag to skip the next onContentChange after a programmatic text update. * Prevents handleTextChange from processing the echo of our own setText. */ const skipNextContentChange = React.useRef(false); /** * Timestamp until which onSelectionChange events should be ignored. * After programmatic setText (e.g. mention insertion), the native side * fires multiple selection change events that reset cursor to 0. * We ignore all selection events for a short window after setText. */ const ignoreSelectionUntil = React.useRef(0); /** * Stores the latest blocks from the RichTextEditor's onContentChange. * Used to convert structured formatting data to markdown when sending. */ const blocksRef = React.useRef([]); /** * Structured blocks to load into the native editor when entering edit mode. * Parsed from the original message's markdown via markdownToBlocks. * Passed as the `initialContent` prop so the editor displays WYSIWYG formatting. * Reset to undefined when edit mode is exited or input is cleared. */ const [editContentBlocks, setEditContentBlocks] = React.useState(undefined); const [isVisible, setIsVisible] = React.useState(false); const [CustomView, setCustomView] = React.useState(null); const [CustomViewHeader, setCustomViewHeader] = React.useState(null); const [CustomViewFooter, setCustomViewFooter] = React.useState(null); const [kbOffset, setKbOffset] = React.useState(59); const [showActionSheet, setShowActionSheet] = React.useState(false); const [actionSheetItems, setActionSheetItems] = React.useState([]); const [showInlineRecorder, setShowInlineRecorder] = React.useState(false); // iOS: holds a picker launch that must wait until the action sheet's modal has // fully dismissed — UIKit can't present the picker while the modal is dismissing. // Fired from the sheet's onDismiss. Unused on Android (picker launches inline). const pendingAttachmentActionRef = React.useRef<(() => void) | null>(null); // Mic/sticker animation: single Animated.Value drives mic slide + fade // 0 = idle (mic visible), 1 = typing (mic hidden) const micAnimValue = React.useRef(new Animated.Value(0)).current; // Separate Animated.Value for mic layout width collapse (JS driver, supports layout props) const micWidthAnim = React.useRef(new Animated.Value(1)).current; const [messagePreview, setMessagePreview] = React.useState<{ message: any; mode: string } | null>(null); const [showMentionList, setShowMentionList] = React.useState(false); const [mentionsSearchData, setMentionsSearchData] = React.useState>([]); const [suggestionListLoader, setSuggestionListLoader] = React.useState(false); const [selectionPosition, setSelectionPosition] = React.useState<{ start: number; end: number }>({ start: 0, end: 0 }); // Active formatting styles state — tracks which styles are active at the cursor position const [activeStyles, setActiveStyles] = React.useState({ bold: false, italic: false, underline: false, strikethrough: false, code: false, codeBlock: false, highlight: false, blockType: 'paragraph', alignment: 'left', }); // Ref mirror of activeStyles — always current (no render-cycle delay). // Used by shouldOpenList so the mention popup isn't blocked by stale state // after exiting a code block. const activeStylesRef = React.useRef(activeStyles); // Link modal state (Slack-style: prefills selected text) const [showLinkModal, setShowLinkModal] = React.useState(false); const [linkText, setLinkText] = React.useState(''); const [linkUrl, setLinkUrl] = React.useState(''); // Link tap dialog state (edit/remove existing links) const [linkTapData, setLinkTapData] = React.useState<{ url: string; text: string; location: number; length: number; } | null>(null); const [linkEditMode, setLinkEditMode] = React.useState(false); const [editLinkUrl, setEditLinkUrl] = React.useState(''); const [editLinkText, setEditLinkText] = React.useState(''); // Track which code button is active (inline code vs code block) const [codeBlockActive, setCodeBlockActive] = React.useState(false); // Focus tracking for floating toolbar visibility const [isFocused, setIsFocused] = React.useState(false); // Multi-attachment state const [selectedAttachments, setSelectedAttachments] = React.useState([]); // §8.2 — full-screen preview of staged media (image/video) tiles; opened on tap when COMPLETED. const [trayViewerVisible, setTrayViewerVisible] = React.useState(false); const [trayViewerIndex, setTrayViewerIndex] = React.useState(0); // Live mirror of the staged count. The action-sheet items freeze an older handlePickFiles // closure (their effect deps don't include selectedAttachments), so reading // `selectedAttachments.length` inside the picker handler would be stale (stuck at the // count when the sheet was built) and the count guard would never see already-staged files. // Read the count from this ref instead so the limit is always enforced against the CURRENT tray. const selectedAttachmentsRef = React.useRef([]); React.useEffect(() => { selectedAttachmentsRef.current = selectedAttachments; }, [selectedAttachments]); // One UploadQueueService per composer mount const uploadQueue = useMemo( () => new UploadQueueService({ onProgress: (fileId, percent) => { setSelectedAttachments(prev => prev.map(a => a.fileId === fileId ? { ...a, uploadState: 'UPLOADING', uploadProgress: percent } : a ) ); }, onFileComplete: (fileId, attachment) => { setSelectedAttachments(prev => prev.map(a => a.fileId === fileId ? { ...a, uploadState: 'COMPLETED', uploadProgress: 100, uploadedAttachment: attachment } : a ) ); }, onFileFailed: (fileId, _error) => { setSelectedAttachments(prev => prev.map(a => a.fileId === fileId ? { ...a, uploadState: 'FAILED', errorKey: 'UPLOAD_FAILED' } : a ) ); }, onFileRejected: (fileId, error) => { // Store the SDK error code; the user sees WHY by tapping the errored tile (no auto-toast, // so several rejections can't spam). See handleTrayTilePress → toastAttachmentRejection. const code = error?.code ?? (error as any)?.getCode?.(); setSelectedAttachments(prev => prev.map(a => a.fileId === fileId ? { ...a, uploadState: 'REJECTED', errorKey: 'UPLOAD_REJECTED', errorCode: code } : a ) ); }, onAllComplete: () => { // Send is driven by the send button, not this callback. }, }), // eslint-disable-next-line react-hooks/exhaustive-deps [] ); // Cancel all in-flight uploads and discard the tray on unmount / conversation switch useEffect(() => { return () => { uploadQueue.cancelAll(); setSelectedAttachments([]); }; // uploadQueue is stable (useMemo with [] deps), so this only runs on unmount // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handlePickFiles = useCallback( async (pickerType: 'media' | 'document' | 'camera' | 'audio') => { // Read the LIVE staged count (ref), not the possibly-stale closure value — see // selectedAttachmentsRef note. This is what makes the count limit hold across picks. const existingCount = selectedAttachmentsRef.current.length; // Server-authoritative upload limits (file.count.max / per-file file.size.max), // clamped tighten-only against any developer override (§5.1 / §6.1). const serverLimits = await getFileUploadLimits(); const maxCount = clampToServer(undefined, serverLimits.fileCountMax); // Always open the picker (even at capacity) so the button never feels broken. Enforcement is // on selection: acceptPickedAttachments rejects the whole over-limit pick + shows the toast. const picked = await openMultiFileChooser(pickerType, existingCount, maxCount); if (picked.length === 0) return; // §6.3 — trim to the remaining count slots. Per-file SIZE is enforced by the SDK // (ERR_FILE_SIZE_EXCEEDED → onFileRejected), not here. const accepted = acceptPickedAttachments(picked, { maxCount, existingCount }, { showToast, t }); if (accepted.length === 0) return; const newAttachments: SelectedAttachment[] = accepted.map(f => ({ fileId: '_' + Math.random().toString(36).substr(2, 9), file: toAttachmentFile(f), uploadState: 'NONE', uploadProgress: 0, })); setSelectedAttachments(prev => { const next = [...prev, ...newAttachments]; // Keep the count-guard ref exact SYNCHRONOUSLY. The useEffect mirror runs only after // paint, so a quick second pick could read a stale (lower) existingCount and let an // extra file past the max (e.g. staging 11 when the limit is 10). Mirroring here makes // the count correct on the very next pick. selectedAttachmentsRef.current = next; return next; }); try { // Pass the target conversation so the SDK can hand receiver/receiverType to the server for // RBAC/SBAC presign authorization. Refs are read fresh (uploadQueue is memoized, refs are not). newAttachments.forEach(a => uploadQueue.enqueue(a, { receiver: chatWithId.current, receiverType: chatWith.current }) ); } catch { // upload queue errors are surfaced per-file via onFileFailed } }, [selectedAttachments.length, uploadQueue] ); // §5.0 — clipboard paste: the native editor extracts pasted image(s)/file(s) to temp files // and fires onPasteMedia. Stage them in the tray with the SAME count + per-file size guards // as the picker (using the live-count ref). Gated by enableMultipleAttachments. const handlePasteMedia = useCallback( async (items: PasteMediaItem[]) => { if (!enableMultipleAttachments || !items?.length) return; const existingCount = selectedAttachmentsRef.current.length; const serverLimits = await getFileUploadLimits(); const maxCount = clampToServer(undefined, serverLimits.fileCountMax); // Same count gate as the picker path; per-file SIZE is enforced by the SDK (see onFileRejected). const accepted = acceptPickedAttachments(items, { maxCount, existingCount }, { showToast, t }); if (accepted.length === 0) return; const newAttachments: SelectedAttachment[] = accepted.map(it => ({ fileId: '_paste_' + Math.random().toString(36).substr(2, 9), file: toAttachmentFile(it), uploadState: 'NONE', uploadProgress: 0, })); setSelectedAttachments(prev => { const next = [...prev, ...newAttachments]; // Keep the count-guard ref exact SYNCHRONOUSLY. The useEffect mirror runs only after // paint, so a quick second pick could read a stale (lower) existingCount and let an // extra file past the max (e.g. staging 11 when the limit is 10). Mirroring here makes // the count correct on the very next pick. selectedAttachmentsRef.current = next; return next; }); try { // Pass the target conversation so the SDK can hand receiver/receiverType to the server for // RBAC/SBAC presign authorization. Refs are read fresh (uploadQueue is memoized, refs are not). newAttachments.forEach(a => uploadQueue.enqueue(a, { receiver: chatWithId.current, receiverType: chatWith.current }) ); } catch { // upload queue errors are surfaced per-file via onFileFailed } }, [enableMultipleAttachments, uploadQueue] ); // §8.2 — staged image/video items (LOCAL URIs) for the full-screen tray preview viewer — the // SAME CometChatMediaViewer used for sent media (see CometChatMediaGridBubble). const trayMediaItems = React.useMemo(() => { return selectedAttachments .filter(a => a.file.type.startsWith('image/') || a.file.type.startsWith('video/')) .map(a => ({ getUrl: () => a.file.uri, getMimeType: () => a.file.type, getName: () => a.file.name, getSize: () => a.file.size || 0, } as unknown as CometChat.Attachment)); }, [selectedAttachments]); // Open the full-screen viewer at the tapped tile. Only COMPLETED media tiles are tappable // (gated by the tile's canPreview), so no upload-state check is needed here. const handleTrayTilePress = useCallback((fileId: string) => { const att = selectedAttachments.find(a => a.fileId === fileId); if (!att) return; // Rejected tile → tap reveals WHY it failed (size, etc.) as a toast. if (att.uploadState === 'REJECTED') { toastAttachmentRejection(att, { showToast, t }); return; } const mediaOnly = selectedAttachments.filter( a => a.file.type.startsWith('image/') || a.file.type.startsWith('video/') ); const idx = mediaOnly.findIndex(a => a.fileId === fileId); if (idx < 0) return; setTrayViewerIndex(idx); setTrayViewerVisible(true); }, [selectedAttachments, showToast, t]); // Store a staged video's probed duration (seconds) so it can be written to metadata on send. const handleVideoDuration = useCallback((fileId: string, durationSeconds: number) => { setSelectedAttachments(prev => prev.map(a => (a.fileId === fileId ? { ...a, durationSeconds } : a)) ); }, []); const handleRemoveAttachment = useCallback( (fileId: string) => { uploadQueue.dequeue(fileId); setSelectedAttachments(prev => { const next = prev.filter(a => a.fileId !== fileId); // Sync the count-guard ref synchronously (see the add-path note) so removing then // immediately picking sees the freed slot instead of a stale, higher count. selectedAttachmentsRef.current = next; return next; }); }, [uploadQueue] ); const handleRetryAttachment = useCallback( (fileId: string) => { const attachment = selectedAttachments.find(a => a.fileId === fileId); if (!attachment) return; setSelectedAttachments(prev => prev.map(a => a.fileId === fileId ? { ...a, uploadState: 'NONE', uploadProgress: 0, errorKey: undefined } : a ) ); uploadQueue.retry(fileId, { ...attachment, uploadState: 'NONE', uploadProgress: 0 }); }, [selectedAttachments, uploadQueue] ); /** * Tracks links inserted via the JS modal. * Maps link display text to URL. Used at send time to wrap * matching text with [text](url) markdown as a fallback * in case the native editor's blocks data is stale. */ const pendingLinksRef = React.useRef>(new Map()); /** * Handles link tap events from the native editor. * Opens the link tap dialog with the tapped link's metadata. */ const onLinkTap = useCallback((data: LinkTapEventData) => { setLinkTapData(data); setLinkEditMode(false); setEditLinkUrl(data.url); setEditLinkText(data.text); }, []); /** * Sets input text programmatically — used for mentions, clear, edit message. * Updates React state (for UI like send button) and pushes to native * via imperative setText command (force=true bypasses typing guard). * The text prop is NOT used (removed from JSX to prevent cursor jumping). */ const setInputTextProgrammatic = React.useCallback((newText: string) => { inputTextRef.current = newText; skipNextContentChange.current = true; setInputText(newText); inputRef.current?.setText?.(newText); }, []); // ============================================ // Warning Message Support // ============================================ /** * State for tracking warning message (e.g., mention limit reached). * When set, displays a warning in the CustomViewHeader area. */ const [warningMessage, setWarningMessage] = React.useState(''); // ============================================ // Reply Message Support // ============================================ /** * State for tracking reply message. * When set, displays a reply preview and attaches the quoted message to sent messages. */ const [replyMessage, setReplyMessage] = React.useState(null); /** * Ref to hold current replyMessage value to avoid stale closures in callbacks. * This is necessary because attachment handlers and other callbacks may capture * stale state values. */ const replyMessageRef = React.useRef(replyMessage); /** * Callback to close the reply preview. * Clears the reply message state and hides the preview. */ const closeReplyPreview = useCallback(() => { setReplyMessage(null); }, []); // ============================================ // Agentic User State Management // ============================================ /** * State for tracking send button delay for agentic users. * When true, the send button is disabled for 1 second after sending. */ const [isSendButtonDisabledForDelay, setIsSendButtonDisabledForDelay] = React.useState(false); /** * State for tracking streaming status for agentic users. * Used to show/hide the stop button during AI response streaming. */ const [isStreaming, setIsStreaming] = React.useState(false); const [showStopButton, setShowStopButton] = React.useState(false); // ============================================ // Auto-Expand Height State Management // ============================================ /** * Resolve the effective min and max heights for auto-expand mode. * Uses props, style overrides, or defaults in that order of precedence. */ const resolvedMinHeight = minInputHeight ?? DEFAULT_MIN_HEIGHT; const resolvedLineHeight = DEFAULT_LINE_HEIGHT; const resolvedMaxHeight = resolveEffectiveMaxHeight({ maxLines, maxHeight: maxInputHeight, lineHeight: resolvedLineHeight, paddingVertical: DEFAULT_PADDING_VERTICAL, }); /** * State for tracking the current input height. * Initialized to minHeight (single-line height). * Only used when autoExpand is true. * */ const [inputHeight, setInputHeight] = React.useState(resolvedMinHeight); /** * State for tracking whether the input is expanded beyond single-line height. * Used for icon alignment and other UI adjustments. * */ const [isExpanded, setIsExpanded] = React.useState(false); // ============================================ // Debouncing for Height Updates // ============================================ /** * Debounce delay in milliseconds. * 16ms corresponds to one frame at 60fps, ensuring smooth updates * while preventing excessive re-renders during rapid typing. * */ const DEBOUNCE_DELAY_MS = 16; /** * Ref to track pending height update timeout. * Used to debounce rapid content size changes and prevent flickering. * */ const pendingHeightUpdateRef = React.useRef(null); /** * Ref to store the latest pending height value. * This ensures that only the final height value within the debounce * window is applied to state (debounce idempotence). * */ const pendingHeightValueRef = React.useRef(null); /** * Cleanup pending height update timeout on unmount. * Prevents memory leaks and state updates on unmounted component. * */ useEffect(() => { return () => { if (pendingHeightUpdateRef.current) { clearTimeout(pendingHeightUpdateRef.current); pendingHeightUpdateRef.current = null; } }; }, []); /** * Apply the debounced height update to state. * Only updates if the new height differs from current height. * * @param newHeight - The new height value to apply * */ const applyHeightUpdate = useCallback((newHeight: number) => { // Only update state if height has changed if (newHeight !== inputHeight) { setInputHeight(newHeight); // Update expanded state based on whether height exceeds minHeight const newIsExpanded = newHeight > resolvedMinHeight; if (newIsExpanded !== isExpanded) { setIsExpanded(newIsExpanded); } } }, [inputHeight, isExpanded, resolvedMinHeight]); /** * Handle content size changes from the TextInput. * Uses debouncing to prevent flickering during rapid content changes. * Calculates the new height using utility functions and updates state * only when the height actually changes. * * This handler supports both expansion (when text is added) and contraction * (when text is deleted). When text is deleted and content height decreases, * the input height shrinks accordingly, returning to minHeight when the * content fits within a single line. * * @param event - The content size change event from TextInput * */ const handleContentSizeChange = useCallback((event: { nativeEvent: { contentSize: { width: number; height: number } } }) => { if (!autoExpand) { return; } const { height: contentHeight } = event.nativeEvent.contentSize; // Calculate the new height using the utility function const newHeight = calculateInputHeight(contentHeight, resolvedMinHeight, resolvedMaxHeight); // Store the latest pending height value pendingHeightValueRef.current = newHeight; // Clear any existing pending update if (pendingHeightUpdateRef.current) { clearTimeout(pendingHeightUpdateRef.current); } // Schedule debounced height update pendingHeightUpdateRef.current = setTimeout(() => { // Apply the latest pending height value if (pendingHeightValueRef.current !== null) { applyHeightUpdate(pendingHeightValueRef.current); pendingHeightValueRef.current = null; } pendingHeightUpdateRef.current = null; }, DEBOUNCE_DELAY_MS); }, [autoExpand, resolvedMinHeight, resolvedMaxHeight, applyHeightUpdate]); // Refs (v5 pattern: use React.useRef) const loggedInUser = React.useRef(null); const chatWith = React.useRef(''); const chatWithId = React.useRef(''); const inputRef = React.useRef(null); const isTyping = React.useRef(null); const allFormatters = React.useRef>(new Map()); const bottomSheetRef = React.useRef(null); const mentionMap = React.useRef>(new Map()); const trackingCharacters = React.useRef([]); const activeCharacter = React.useRef(''); const searchStringRef = React.useRef(''); // Initialize plainTextInputRef with initialComposertext if provided const plainTextInputRef = React.useRef(initialComposertext ?? ''); /** * Syncs mention ranges from mentionMap to the native editor for visual styling. * Converts mentionMap keys ("start_end") into [{start, end}] and dispatches * the setMentionRanges command so the native side can apply bold purple + background. */ const syncMentionRanges = React.useCallback(() => { const ranges: Array<{ start: number; end: number }> = []; mentionMap.current.forEach((_value, key) => { const parts = key.split('_'); const start = parseInt(parts[0], 10); const end = parseInt(parts[1], 10); if (!isNaN(start) && !isNaN(end)) { ranges.push({ start, end }); } }); inputRef.current?.setMentionRanges?.(ranges); }, []); /** * Ref for tracking send button delay timer for agentic users. * Used to clear the timer on unmount to prevent memory leaks. */ const sendButtonDelayTimer = React.useRef(null); /** * Ref for tracking parent message ID for agentic users. * After first message sent to agentic user without parentMessageId prop, * stores the message ID for subsequent messages. */ const parentMessageIdRef = React.useRef(null); // Handle platform-specific keyboard offset (iOS safe area insets) useLayoutEffect(() => { if (Platform.OS === 'ios') { if (Number.isInteger(commonVars.safeAreaInsets.top)) { setKbOffset(commonVars.safeAreaInsets.top ?? 59); return; } CommonUtil?.getSafeAreaInsets?.().then((res: { top: number; bottom: number }) => { if (Number.isInteger(res.top)) { commonVars.safeAreaInsets.top = res.top; commonVars.safeAreaInsets.bottom = res.bottom; setKbOffset(res.top); } }).catch(() => { // Fallback to default offset if native module fails }); } }, []); // Get logged in user useEffect(() => { CometChat.getLoggedinUser() .then((u: CometChat.User | null) => { if (u) { loggedInUser.current = u; } }) .catch((error: CometChat.CometChatException) => { onError?.(error); }); }, []); /** * Subscribe to streaming state observable for agentic users. * Updates isStreaming state when streaming status changes. * Hides stop button when streaming ends. */ useEffect(() => { const sub = streamingState$.subscribe((streaming) => { setIsStreaming(streaming); if (!streaming) setShowStopButton(false); }); return () => sub.unsubscribe(); }, []); /** * Cleanup effect for send button delay timer. * Clears the timer on unmount to prevent memory leaks. */ useEffect(() => { return () => { if (sendButtonDelayTimer.current) { clearTimeout(sendButtonDelayTimer.current); sendButtonDelayTimer.current = null; } }; }, []); /** * Keep replyMessageRef in sync with replyMessage state. * This ensures callbacks always have access to the current reply message value. */ useEffect(() => { replyMessageRef.current = replyMessage; }, [replyMessage]); // Configure receiver from user prop useEffect(() => { if (user) { chatWith.current = ReceiverTypeConstants.user; chatWithId.current = user.getUid(); } }, [user]); // Configure receiver from group prop useEffect(() => { if (group) { chatWith.current = ReceiverTypeConstants.group; chatWithId.current = group.getGuid(); } }, [group]); // Initialize text formatters useEffect(() => { let _formatter = [...(textFormatters || [])]; // Add default mentions formatter if not disabled if (!disableMentions) { const mentionsFormatter = ChatConfigurator.getDataSource().getMentionsFormatter(); if (CometChatUIKit.loggedInUser) { mentionsFormatter.setLoggedInUser(CometChatUIKit.loggedInUser); } mentionsFormatter.setContext?.("composer"); if (resolvedStyle?.mentionsStyle) { mentionsFormatter.setMentionsStyle( resolvedStyle.mentionsStyle as CometChatTheme["mentionsStyle"] ); } // Set target element for mentions (matching MessageComposer pattern) mentionsFormatter.setTargetElement?.(MentionsTargetElement.textinput); // Configure mention all settings if (mentionAllLabel) { mentionsFormatter.setMentionAllLabel(mentionAllLabel); } mentionsFormatter.setDisableMentionAll(disableMentionAll); if (user) { mentionsFormatter.setUser(user); } if (group) { mentionsFormatter.setGroup(group); } _formatter.unshift(mentionsFormatter); } _formatter.forEach((formatter) => { if (id !== undefined) { formatter.setComposerId(id); } if (user) { formatter.setUser(user); } if (group) { formatter.setGroup(group); } // Get tracking character for this formatter const trackingChar = formatter.getTrackingCharacter?.(); // Clone and store formatter using tracking character as key (matching MessageComposer pattern) const newFormatter = CommonUtils.clone(formatter); if (trackingChar) { allFormatters.current.set(trackingChar, newFormatter); // Set up tracking characters for mention detection if (!trackingCharacters.current.includes(trackingChar)) { trackingCharacters.current.push(trackingChar); } } }); }, []); // Initialize input with text prop useEffect(() => { if (text !== undefined) { setInputTextProgrammatic(text); } }, [text]); // Create composerId map for attachment options const composerIdMap = new Map().set('parentMessageId', parentMessageId); /** * Handle custom view click for attachment options */ const handleOnClick = (CustomViewFn: any) => { const view = CustomViewFn( user, group, { uid: user?.getUid(), guid: group?.getGuid(), parentMessageId: parentMessageId, }, { onClose: () => setIsVisible(false), } ); bottomSheetRef.current?.togglePanel(); setTimeout(() => { setCustomView(() => view); setIsVisible(true); }, 200); }; // Initialize attachment options useEffect(() => { const defaultAttachmentOptions = ChatConfigurator.dataSource.getAttachmentOptions( theme, user, group, composerIdMap, { // Individual hide options hideCameraOption, hideImageAttachmentOption, hideVideoAttachmentOption, hideAudioAttachmentOption, hideFileAttachmentOption, hidePollsAttachmentOption, hideCollaborativeDocumentOption, hideCollaborativeWhiteboardOption, // Reply context replyToMessage: replyMessage?.message, closeReplyPreview, } ); setActionSheetItems(() => attachmentOptions && typeof attachmentOptions === 'function' ? attachmentOptions({ user, group, composerId: composerIdMap })?.map((item) => { if (typeof item.CustomView === 'function') return { ...item, onPress: () => handleOnClick(item.CustomView), }; if (typeof item.onPress === 'function') return { ...item, onPress: () => { setShowActionSheet(false); item.onPress?.(user, group); }, }; return { ...item, onPress: () => fileInputHandler(item.id ?? ''), }; }) : [ // Default attachment options ...defaultAttachmentOptions.map((item) => { if (typeof item.CustomView === 'function') return { ...item, onPress: () => handleOnClick(item.CustomView), }; if (typeof item.onPress === 'function') return { ...item, onPress: () => { setShowActionSheet(false); item.onPress(user, group); }, }; return { ...item, onPress: () => fileInputHandler(item.id), }; }), // Additional attachment options ...(addAttachmentOptions && typeof addAttachmentOptions === 'function' ? addAttachmentOptions({ user, group, composerId: composerIdMap })?.map((item) => { if (typeof item.CustomView === 'function') return { ...item, onPress: () => handleOnClick(item.CustomView), }; if (typeof item.onPress === 'function') return { ...item, onPress: () => { setShowActionSheet(false); item.onPress?.(user, group); }, }; return { ...item, onPress: () => fileInputHandler(item.id ?? ''), }; }) : []), ] ); }, [ user, group, id, parentMessageId, hideCameraOption, hideImageAttachmentOption, hideVideoAttachmentOption, hideAudioAttachmentOption, hideFileAttachmentOption, hidePollsAttachmentOption, hideCollaborativeDocumentOption, hideCollaborativeWhiteboardOption, addAttachmentOptions, replyMessage, closeReplyPreview, ]); /** * Handle panel show/hide events * Updates CustomViewHeader and CustomViewFooter based on alignment * */ const handlePanel = useCallback((item: { child?: React.FC | React.ReactNode; alignment?: string }) => { if (item.child) { if (item.alignment === ViewAlignment.composerTop) { // Handle both FC and ReactNode types const childContent = typeof item.child === 'function' ? React.createElement(item.child as React.FC) : item.child; setCustomViewHeader(childContent); } else if (item.alignment === ViewAlignment.composerBottom) { const childContent = typeof item.child === 'function' ? React.createElement(item.child as React.FC) : item.child; setCustomViewFooter(childContent); } } else { if (item.alignment === ViewAlignment.composerTop) { setCustomViewHeader(null); } else if (item.alignment === ViewAlignment.composerBottom) { setCustomViewFooter(null); } } }, []); // Add edit message event listener useEffect(() => { CometChatUIEventHandler.addMessageListener(editMessageListenerID, { ccMessageEdited: (item: { message: any; status: string }) => previewMessage(item), }); return () => { CometChatUIEventHandler.removeMessageListener(editMessageListenerID); }; }, []); /** * Add reply message event listener. * Subscribes to ccReplyToMessage events to display reply preview. */ useEffect(() => { CometChatMessageEvents.addListener( CometChatMessageEvents.ccReplyToMessage, replyMessageListenerID, (data: any) => { if (data.status === messageStatus.inprogress) { previewReplyMessage(data.message); } } ); return () => { CometChatMessageEvents.removeListener( CometChatMessageEvents.ccReplyToMessage, replyMessageListenerID ); }; }, []); // Add UI event listener for suggestion data, bottom sheet toggle, and compose message useEffect(() => { CometChatUIEventHandler.addUIListener(uiEventListenerID, { // Handle suggestion data for mentions ccSuggestionData(item: { id: string | number; data: Array }) { if (activeCharacter.current && id === item?.id) { // Check if mention limit warning should be shown (matching MessageComposer pattern) const warningView = getMentionLimitView(); if (warningView) { return; } setMentionsSearchData(item?.data); setSuggestionListLoader(false); } }, // Handle bottom sheet toggle events (for custom views) ccToggleBottomSheet: (item: { botView?: boolean; child?: React.FC | React.ReactNode }) => { if (item?.botView) { // Handle botView for custom views - convert FC to ReactNode if needed const childContent = typeof item.child === 'function' ? React.createElement(item.child as React.FC) : (item.child || null); setCustomView(childContent); return; } // Toggle bottom sheet visibility setIsVisible(false); bottomSheetRef.current?.togglePanel(); }, // Handle compose message events (for AI smart replies, etc.) ccComposeMessage: (textData: { text?: string }) => { setIsVisible(false); bottomSheetRef.current?.togglePanel(); // Update input with received text if (textData?.text !== undefined) { plainTextInputRef.current = textData.text; setInputTextProgrammatic(textData.text); onChangeText?.(textData.text); } }, }); return () => { CometChatUIEventHandler.removeUIListener(uiEventListenerID); }; }, [id]); // Handle panel hide events — always clear the content to avoid // a stale separator line when hidePanel passes child: () => null // (a truthy function that renders nothing). const handleHidePanel = useCallback((item: { child?: React.FC | React.ReactNode; alignment?: string }) => { if (item.alignment === ViewAlignment.composerTop) { setCustomViewHeader(null); } else if (item.alignment === ViewAlignment.composerBottom) { setCustomViewFooter(null); } }, []); // Add panel show/hide listeners useEffect(() => { CometChatUIEventHandler.addUIListener(uiEventListenerShowID, { showPanel: (item: { child?: React.FC; alignment?: string }) => handlePanel(item), }); CometChatUIEventHandler.addUIListener(uiEventListenerHideID, { hidePanel: (item: { child?: React.FC; alignment?: string }) => handleHidePanel(item), }); return () => { CometChatUIEventHandler.removeUIListener(uiEventListenerShowID); CometChatUIEventHandler.removeUIListener(uiEventListenerHideID); }; }, []); /** * Camera callback handler for processing captured images */ const cameraCallback = async (cameraImage: any) => { if (CheckPropertyExists(cameraImage, 'error')) { return; } const { name, uri, type } = cameraImage; const file = { name, type, uri, }; sendMediaMessage( chatWithId.current, file, MessageTypeConstants.image, chatWith.current ); }; /** * File input handler for different file types. * Image / video / file route through the multi-attachment upload flow. * Camera and legacy types (audio, etc.) fall through to the original single-send path. */ // The actual picker presentation. Split out from fileInputHandler so it can be // launched inline (Android) or deferred to the sheet's onDismiss (iOS). const launchPicker = async (fileType: string) => { // §1 — MULTI (default): route image/video/file/camera into the staging tray. if (enableMultipleAttachments) { if (fileType === MessageTypeConstants.image || fileType === MessageTypeConstants.video) { await handlePickFiles('media'); return; } if (fileType === MessageTypeConstants.file) { await handlePickFiles('document'); return; } if (fileType === MessageTypeConstants.audio) { await handlePickFiles('audio'); return; } if (fileType === MessageTypeConstants.takePhoto) { if (!(await permissionUtil.startResourceBasedTask(['camera']))) { return; } await handlePickFiles('camera'); return; } } // §1 — LEGACY single-select, send-immediately (flag=false, or audio/other types): // one picker → one MediaMessage, no tray/multi-upload. if (fileType === MessageTypeConstants.takePhoto) { if (!(await permissionUtil.startResourceBasedTask(['camera']))) return; const onCapture = (result: any) => { if (result && !CheckPropertyExists(result, 'error') && result.uri) { sendMediaMessage(chatWithId.current, { name: result.name, type: result.type, uri: result.uri }, MessageTypeConstants.image, chatWith.current); } }; if (Platform.OS === 'ios') FileManager.openCamera('image', onCapture); else FileManager.openCamera('image', 50, onCapture); return; } if (Platform.OS === 'ios' && fileType === MessageTypeConstants.video) { NativeModules.VideoPickerModule.pickVideo((file: any) => { if (file.uri) { sendMediaMessage(chatWithId.current, file, MessageTypeConstants.video, chatWith.current); } }); } else { FileManager.openFileChooser(fileType, async (fileInfo: any) => { if (CheckPropertyExists(fileInfo, 'error')) return; const { name, uri, type } = fileInfo; sendMediaMessage(chatWithId.current, { name, type, uri }, fileType, chatWith.current); }); } }; const fileInputHandler = (fileType: string) => { // Always close the attachment sheet first. // iOS: UIKit can't present the picker while the sheet's modal is still // dismissing, so stash the launch and fire it from the modal's onDismiss // (see the ActionSheetBoard below). Android has no such restriction, so launch // immediately as the sheet closes. if (Platform.OS === 'ios') { pendingAttachmentActionRef.current = () => { void launchPicker(fileType); }; setShowActionSheet(false); return; } setShowActionSheet(false); void launchPicker(fileType); }; /** * Send all pre-uploaded attachments as a single multi-attachment media message. * Uses SDK-native setAttachments() so data.attachments survives the server round-trip * and groupAttachments() can render the grid correctly on receive. */ const sendMultiAttachmentMediaMessage = useCallback(() => { // Guard: never ship a partial batch. If any staged tile errored (FAILED/REJECTED), block // the send until the user removes it (the send button is also disabled in this state). if (selectedAttachmentsRef.current.some(a => a.uploadState === 'FAILED' || a.uploadState === 'REJECTED')) { return; } // Write each staged video/audio's probed duration into its uploaded attachment's metadata // BEFORE setAttachments() serializes it — uploadedAttachment is the SAME object that flows into // the outgoing message, so BOTH bubbles read duration from metadata immediately (no reliance on // the shared native SoundPlayer, which races to 00:00 when several audio cards mount at once). selectedAttachmentsRef.current.forEach(a => { if ((a.file.type.startsWith('video/') || a.file.type.startsWith('audio/')) && a.durationSeconds && a.durationSeconds > 0 && a.uploadedAttachment) { a.uploadedAttachment.setMetadata({ ...(a.uploadedAttachment.getMetadata?.() || {}), duration: a.durationSeconds, }); } }); const completed = uploadQueue.completedAttachments; if (completed.length === 0) { return; } // Prefer the SDK's upload batch id (DD §5.3) as the message batchId; captured BEFORE cancelAll // releases the request. Fall back to a client timestamp id when the SDK doesn't expose one. const sdkBatchId = uploadQueue.batchId; // Reset the queue immediately after capturing this batch's attachments. // The completed Map accumulates across batches — without this reset the next // send would include all previous batches' attachments as well. uploadQueue.cancelAll(); // §7 — FAN OUT: partition the completed tiles by kind and send ONE MediaMessage per // non-empty kind (Images → Videos → Files; picker audio → file), all sharing one // batchId. Each message gets its own muid; the caption goes on the LAST message only. const groups = partitionAttachmentsByKind(completed); if (groups.length === 0) return; const batchId = sdkBatchId ?? String(getUnixTimestampInMilliseconds()); // §8.4 — total attachments across the whole batch, so the conversation-list preview can // aggregate ("N attachments") from just the last fanned-out message it receives. const batchTotalCount = groups.reduce((sum, g) => sum + g.attachments.length, 0); const caption = plainTextInputRef.current.trim(); const currentReplyMessage = replyMessageRef.current; const replyMessageId = currentReplyMessage?.message?.getId?.() ?? null; // Fresh MediaMessage for a group — used for both the optimistic echo and the send. const buildMessage = (group: AttachmentKindGroup, index: number, muid: string) => { const m = new CometChat.MediaMessage(chatWithId.current, null, group.type, chatWith.current); if (loggedInUser.current) m.setSender(loggedInUser.current as CometChat.User); m.setMuid(muid); m.setAttachments(group.attachments); // §7/§8.3a batch contract — lets the message list group the fanned-out messages. m.setMetadata({ batchId, batchIndex: index, batchSize: groups.length, batchTotalCount }); if (index === groups.length - 1 && caption) m.setCaption(caption); // caption on LAST only if (parentMessageId) m.setParentMessageId(parentMessageId as number); // thread parent on ALL // Quoted reply on the FIRST message of the batch only — otherwise every fanned-out bubble // shows the "replying to X" quote. (Thread parentMessageId above stays on all.) if (replyMessageId && currentReplyMessage?.message && index === 0 && typeof m.setQuotedMessage === 'function') { m.setQuotedMessage(currentReplyMessage.message); (m as any).quotedMessage = currentReplyMessage.message; (m as any).quotedMessageId = replyMessageId; } return m; }; // Flag a fanned message's optimistic echo as FAILED and push it back to the list. Mirrors the full // composer: the message list marks failures via `ccMessageSent` + status:error (it has no // `ccMessageError` handler), and the bubble's error icon reads `metaData.error` / `metadata.error`. // Without stamping BOTH here, a failed send would render as a blank/in-progress bubble with no // indication — and for a first/middle batch member, get collapsed away entirely (§12.1). const markSendFailed = (msg: CometChat.MediaMessage) => { const currentData = msg.getData?.() || {}; msg.setData({ ...currentData, metaData: { ...((currentData as any).metaData || {}), error: true } }); const md = (msg.getMetadata?.() as Record) || {}; msg.setMetadata({ ...md, error: true }); CometChatUIEventHandler.emitMessageEvent(MessageEvents.ccMessageSent, { message: msg, status: messageStatus.error, }); }; groups.forEach((group, index) => { const muid = `${batchId}_${index}`; // Optimistic (in-progress) bubble for this kind's message, keyed by its muid. CometChatUIEventHandler.emitMessageEvent(MessageEvents.ccMessageSent, { message: buildMessage(group, index, muid), status: messageStatus.inprogress, }); // §7/§12.1 — independent per-message send: one kind can fail while others deliver. const outgoing = buildMessage(group, index, muid); CometChat.sendMediaMessage(outgoing) .then((msg: any) => { CometChatUIEventHandler.emitMessageEvent(MessageEvents.ccMessageSent, { message: msg, status: messageStatus.success, }); }) .catch(() => markSendFailed(outgoing)); }); // ENG-36991 — clear the swipe-to-reply preview FIRST, before the teardown below. clearInputBox() // calls the native RichTextEditor clear(), which on some real Android devices can throw and skip // everything after it — leaving the reply preview stuck (worked on the emulator, not on device). setReplyMessage(null); replyMessageRef.current = null; setSelectedAttachments([]); setShowActionSheet(false); clearInputBox(); if (!disableSoundForMessages) { CometChatSoundManager.play( CometChatSoundManager.SoundOutput.outgoingMessage, customSoundForMessage ); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [uploadQueue, parentMessageId, disableSoundForMessages, customSoundForMessage]); /** * Send media message (image, video, audio, file) * Includes reply message support for quoted messages. */ const sendMediaMessage = ( receiverId: string, messageInput: { name: string; type: string; uri: string }, messageType: string, receiverType: string, extraMetadata?: Record ) => { setShowActionSheet(false); // Capture current reply message using ref to avoid stale closures const currentReplyMessage = replyMessageRef.current; const replyMessageId = currentReplyMessage?.message?.getId?.() ?? null; const mediaMessage = new CometChat.MediaMessage( receiverId, messageInput, messageType, receiverType ); // The optimistic echo (localMessage) and the message actually sent // (mediaMessage) MUST share ONE muid. CometChatMessageList reconciles the // in-progress bubble with the delivered message via messageEdited(), which // matches by muid — and when no match is found it does nothing, so the // delivered message is dropped and the optimistic bubble is orphaned (no // echo until a refetch). Two independent getUnixTimestampInMilliseconds() // calls can land in different milliseconds, so they must not be used // separately here. This mirrors the working multi-attachment path, which // builds both messages with the same `${batchId}_${index}` muid. const sharedMuid = String(getUnixTimestampInMilliseconds()); if (loggedInUser.current) mediaMessage.setSender(loggedInUser.current as CometChat.User); mediaMessage.setReceiver((user || group)!); mediaMessage.setType(messageType); mediaMessage.setMuid(sharedMuid); mediaMessage.setData({ type: messageType, category: CometChat.CATEGORY_MESSAGE, name: messageInput.name, file: messageInput, url: messageInput.uri, sender: loggedInUser.current, }); // setMetadata MUST run AFTER setData: the SDK stores metadata inside `data`, and // setData replaces `data` wholesale — so tagging before setData would be wiped from // the payload sent to the server (e.g. audioType:"voice_note" would never round-trip). if (extraMetadata) { mediaMessage.setMetadata({ ...(mediaMessage.getMetadata() || {}), ...extraMetadata }); } if (parentMessageId) { mediaMessage.setParentMessageId(parentMessageId as number); } /** * Handle reply message - set quoted message for media replies. * Uses shared helper for consistency with CometChatMessageComposer. */ if (replyMessageId && currentReplyMessage?.message) { setQuotedMessageSafe(mediaMessage, currentReplyMessage.message, replyMessageId); } // Create local message for immediate UI feedback const localMessage = new CometChat.MediaMessage( receiverId, messageInput, messageType, receiverType ); if (loggedInUser.current) localMessage.setSender(loggedInUser.current as CometChat.User); localMessage.setReceiver((user || group)!); localMessage.setType(messageType); // Same muid as the sent mediaMessage above — see note there. This is what // lets messageEdited() replace this optimistic bubble with the delivered one. localMessage.setMuid(sharedMuid); localMessage.setData({ type: messageType, category: CometChat.CATEGORY_MESSAGE, name: messageInput.name, file: messageInput, url: messageInput.uri, sender: loggedInUser.current, attachments: [messageInput], }); // setMetadata AFTER setData (see note above) so the optimistic bubble matches the sent one. if (extraMetadata) { localMessage.setMetadata({ ...(localMessage.getMetadata() || {}), ...extraMetadata }); } if (parentMessageId) { localMessage.setParentMessageId(parentMessageId as number); } // Set quoted message on local message for UI feedback if (replyMessageId && currentReplyMessage?.message) { setQuotedMessageSafe(localMessage, currentReplyMessage.message, replyMessageId); (localMessage as any).quotedMessage = currentReplyMessage.message; } // Clear reply preview after preparing message setReplyMessage(null); // Emit in-progress event CometChatUIEventHandler.emitMessageEvent(MessageEvents.ccMessageSent, { message: localMessage, status: messageStatus.inprogress, }); // Play sound if enabled if (!disableSoundForMessages) { playAudio(); } // Send message via SDK CometChat.sendMediaMessage(mediaMessage) .then((message: CometChat.BaseMessage) => { CometChatUIEventHandler.emitMessageEvent(MessageEvents.ccMessageSent, { message: message, status: messageStatus.success, }); }) .catch((error: CometChat.CometChatException) => { onError?.(error); (localMessage as any).data.metaData = { error: true }; CometChatUIEventHandler.emitMessageEvent(MessageEvents.ccMessageSent, { message: localMessage, status: messageStatus.error, }); }); }; /** * Play outgoing message sound */ const playAudio = () => { if (customSoundForMessage) { CometChatSoundManager.play( CometChatSoundManager.SoundOutput.outgoingMessage, customSoundForMessage ); } else { CometChatSoundManager.play( CometChatSoundManager.SoundOutput.outgoingMessage ); } }; /** * Send recorded audio as a media message * Called when voice recording is completed and submitted */ const buildRecordedAudioFileName = (recordedFile: string): string => ((recordedFile || '').split(/[?#]/)[0]?.split('/').pop() || `audio-recording-${Date.now()}.m4a`).replace(/^audio-merged/, 'audio-recording'); const _sendRecordedAudio = (recordedFile: string) => { // Invoke the onVoiceRecordingEnd callback if provided onVoiceRecordingEnd?.(recordedFile); const fileObj = { name: buildRecordedAudioFileName(recordedFile), type: 'audio/mp4', uri: recordedFile, }; // §8.1a — tag recorded audio as a voice note so it renders in the WAVEFORM bubble // (CometChatAudioBubble), distinct from picked/shared audio files which use the // headphone+filename bubble. Always tagged (independent of enableMultipleAttachments). const voiceNoteMeta = { audioType: 'voice_note' }; // always tag → recorded audio is ALWAYS the waveform bubble sendMediaMessage( chatWithId.current, fileObj, MessageTypeConstants.audio, chatWith.current, voiceNoteMeta ); }; /** * Handle inline audio recorder submit. * Sends the recorded audio file and hides the inline recorder. */ const handleInlineRecorderSubmit = useCallback((recordedFile: string) => { const fileObj = { name: buildRecordedAudioFileName(recordedFile), type: 'audio/mp4', uri: recordedFile, }; // §8.1a — tag recorded audio as a voice note (always → waveform bubble). const voiceNoteMeta = { audioType: 'voice_note' }; // always tag → recorded audio is ALWAYS the waveform bubble sendMediaMessage(chatWithId.current, fileObj, MessageTypeConstants.audio, chatWith.current, voiceNoteMeta); setShowInlineRecorder(false); }, [enableMultipleAttachments]); /** * Handle inline audio recorder cancel. * Hides the inline recorder without sending. */ const handleInlineRecorderCancel = useCallback(() => { setShowInlineRecorder(false); }, []); /** * Handle voice recording start * Called when recording begins */ const handleVoiceRecordingStart = () => { onVoiceRecordingStart?.(); }; /** * Clear the input field. * When autoExpand is enabled, also resets the height to minHeight. * This ensures the input returns to its collapsed state when cleared. * */ const clearInputBox = useCallback(() => { setInputTextProgrammatic(''); plainTextInputRef.current = ''; blocksRef.current = []; pendingLinksRef.current.clear(); setEditContentBlocks(undefined); // Clear the RichTextEditor content. Guard the native call — on some real devices it can throw // if the view is mid-teardown, which would otherwise abort whatever runs after clearInputBox(). try { inputRef.current?.clear(); } catch {} // When autoExpand is enabled, reset height to minHeight // This handles the edge case of clearing all text if (autoExpand) { setInputHeight(resolvedMinHeight); setIsExpanded(false); } }, [autoExpand, resolvedMinHeight]); /** * Start typing indicator */ const startTyping = useCallback(() => { if (disableTypingEvents) { return; } // Check if user is blocked if (user && (user.getBlockedByMe() || user.getHasBlockedMe())) { return; } if (isTyping.current) { clearTimeout(isTyping.current); isTyping.current = null; } else { const typingNotification = new CometChat.TypingIndicator( chatWithId.current, chatWith.current ); CometChat.startTyping(typingNotification); } isTyping.current = setTimeout(() => { // End typing inline to avoid circular dependency if (disableTypingEvents) { return; } if (isTyping.current) { clearTimeout(isTyping.current); isTyping.current = null; } const typingNotification = new CometChat.TypingIndicator( chatWithId.current, chatWith.current ); CometChat.endTyping(typingNotification); }, 500); }, [disableTypingEvents, user]); /** * End typing indicator */ const endTyping = useCallback(() => { if (disableTypingEvents) { return; } if (isTyping.current) { clearTimeout(isTyping.current); isTyping.current = null; } const typingNotification = new CometChat.TypingIndicator( chatWithId.current, chatWith.current ); CometChat.endTyping(typingNotification); }, [disableTypingEvents]); /** * Check if cursor is within a mention range */ /** * Escape special regex characters */ const escapeRegExp = (string: string): string => { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }; /** * Extract text from cursor position to find search string */ const extractTextFromCursor = (inputText: string, cursorPosition: number): string => { const leftText = inputText.substring(0, cursorPosition); const escapedPrefixes = trackingCharacters.current.map(escapeRegExp).join('|'); const pattern = new RegExp(`(${escapedPrefixes})([\\w\\s]*)$`); const match = leftText.match(pattern); if (match) { return match[2] || ''; } return ''; }; /** * Check if mention list should be opened. * Suppressed when cursor is inside a code block or inline code span * (Req 12.1, 12.2, 12.3). */ const shouldOpenList = ( selection: { start: number; end: number }, searchString: string, tracker: string ): boolean => { if (disableMentions) return false; // Suppress mention picker inside code or code block contexts. // Read from ref to avoid stale closure after code block exit. const styles = activeStylesRef.current; if (styles.code || styles.codeBlock) return false; return ( selection.start === selection.end && !isCursorWithinMentionRange(mentionMap.current, selection.start - searchString.length) && trackingCharacters.current.includes(tracker) && (searchString === '' ? (plainTextInputRef.current[selection.start - 2]?.length === 1 && plainTextInputRef.current[selection.start - 2]?.trim()?.length === 0) || plainTextInputRef.current[selection.start - 2] === undefined : true) && (plainTextInputRef.current[selection.start - 1]?.length === 1 && plainTextInputRef.current[selection.start - 1]?.trim()?.length === 0 ? searchString.length > 0 : true) ); }; /** * Open mention list based on cursor position */ let openListTimeoutId: NodeJS.Timeout | null = null; const openList = (selection: { start: number; end: number }) => { if (disableMentions) return; if (openListTimeoutId) { clearTimeout(openListTimeoutId); } openListTimeoutId = setTimeout(() => { const searchString = extractTextFromCursor(plainTextInputRef.current, selection.start); const tracker = searchString ? plainTextInputRef.current[selection.start - (searchString.length + 1)] : plainTextInputRef.current[selection.start - 1]; if (shouldOpenList(selection, searchString, tracker)) { activeCharacter.current = tracker; searchStringRef.current = searchString; setShowMentionList(true); setSuggestionListLoader(true); const formatter = allFormatters.current.get(tracker); if (formatter instanceof CometChatMentionsFormatter) { const shouldShowMentionList = formatter.getVisibleIn() === MentionsVisibility.both || (formatter.getVisibleIn() === MentionsVisibility.usersConversationOnly && user) || (formatter.getVisibleIn() === MentionsVisibility.groupsConversationOnly && group); if (shouldShowMentionList) { formatter.search(searchString); } } else if (formatter) { formatter.search(searchString); } } else { activeCharacter.current = ''; searchStringRef.current = ''; setShowMentionList(false); setMentionsSearchData([]); } }, 100); }; /** * Remove overlapping mentions from text and map. * Tracks total shift for position adjustment and updates formatter suggestion items. * Clears warning message if below mention limit. * * @param text - The original text * @param overlaps - Array of overlapping mentions to remove * @param map - The mention map to update * @returns Object with newText and totalShift */ const removeMentionsFromTextAndMap = ( text: string, overlaps: MentionOverlap[], map: Map ): { newText: string; totalShift: number } => { let adjustment = 0; let newText = text; overlaps.forEach(({ key, value, start, end }) => { const adjStart = start + adjustment; const adjEnd = end + adjustment; newText = newText.slice(0, adjStart) + newText.slice(adjEnd); map.delete(key); adjustment -= adjEnd - adjStart; // Keep formatter in sync if (value.id && !ifIdExists(value.id, map)) { const trackingChar = value.trackingCharacter; if (trackingChar) { const fmt = allFormatters.current.get(trackingChar); if (fmt instanceof CometChatMentionsFormatter) { const users = fmt.getSuggestionItems().filter((u: SuggestionItem) => u.id !== value.id); fmt.setSuggestionItems(users); if (!getMentionLimitView(fmt)) setWarningMessage(''); } } } }); return { newText, totalShift: adjustment }; }; /** * Main helper to handle mention deletion. * Orchestrates mention deletion using helper functions. * Returns true if mentions were deleted, false otherwise. * Updates cursor position after deletion. * * @param oldText - The text before deletion * @param newText - The text after deletion (from TextInput) * @returns True if mentions were deleted and handled, false otherwise */ const deleteMentionHelper = (oldText: string, newText: string): boolean => { const deletionLen = oldText.length - newText.length; const range = calcDeletionRange(selectionPosition, deletionLen); // Verify the deletion actually happened at the calculated range. // Block-type toggles (list/blockquote) remove prefixes at line starts, // not at the cursor position. If the calculated range doesn't match // the actual deletion, skip mention deletion to avoid false positives. const expectedAfterDeletion = oldText.substring(0, range.start) + oldText.substring(range.end); if (expectedAfterDeletion !== newText) return false; const deletionMentionMap = new Map(mentionMap.current); const overlaps = collectOverlappingMentions(range, deletionMentionMap); if (overlaps.length === 0) return false; const { newText: finalText, totalShift } = removeMentionsFromTextAndMap( oldText, overlaps, deletionMentionMap ); shiftRemainingMentionKeys(deletionMentionMap, range.start, totalShift); plainTextInputRef.current = finalText; setInputTextProgrammatic(finalText); onChangeText?.(finalText); mentionMap.current = deletionMentionMap; // Update cursor position to the start of the first deleted mention const firstStart = overlaps[0].start; ignoreSelectionUntil.current = Date.now() + 500; InteractionManager.runAfterInteractions(() => { inputRef.current?.setSelection?.(firstStart); setSelectionPosition({ start: firstStart, end: firstStart }); syncMentionRanges(); }); return true; }; /** * Handle text input change * Tracks cursor position and updates mention map positions. * Checks for deletion and calls deleteMentionHelper before normal processing. */ const handleTextChange = (newText: string) => { const oldText = plainTextInputRef.current; // Check if this is a deletion - call deleteMentionHelper before normal processing if (oldText.length > newText.length) { const handled = deleteMentionHelper(oldText, newText); if (handled) { // Mention deletion was handled, skip normal processing startTyping(); return; } } const removing = plainTextInputRef.current.length > newText.length; const adding = plainTextInputRef.current.length < newText.length; const textDiff = newText.length - plainTextInputRef.current.length; const notAtLast = (selectionPosition.start + textDiff) < newText.length; // Update plain text input ref plainTextInputRef.current = newText; inputTextRef.current = newText; setInputText(newText); onChangeText?.(newText); startTyping(); // Update mention map positions based on text changes let decr = 0; const newMentionMap = new Map(mentionMap.current); mentionMap.current.forEach((value, key) => { const position = { start: parseInt(key.split('_')[0]), end: parseInt(key.split('_')[1]), }; // Runs when cursor before the mention and before the last position if ( notAtLast && (selectionPosition.start - 1 <= position.start || selectionPosition.start - textDiff <= position.start) ) { if (removing) { decr = selectionPosition.end - selectionPosition.start - textDiff; position.start = position.start - decr; position.end = position.end - decr; } else if (adding) { decr = selectionPosition.end - selectionPosition.start + textDiff; position.start = position.start + decr; position.end = position.end + decr; } if (removing || adding) { const newKey = `${position.start}_${position.end}`; if (position.start >= 0) { newMentionMap.set(newKey, value); } newMentionMap.delete(key); } } // Code to delete mention from hashmap if it's been modified const expectedMentionPos = plainTextInputRef.current.substring(position.start, position.end); if (expectedMentionPos !== value.promptText) { const newKey = `${position.start}_${position.end}`; newMentionMap.delete(newKey); // Try to recover the mention by searching for its promptText in the new text. // This handles block-type toggles (list/blockquote) where prefixes // shift text positions but the mention text is still present. const promptText = value.promptText || ''; let recovered = false; if (promptText) { // Search near the expected position first, then fall back to full text const searchFrom = Math.max(0, position.start - 20); let localIdx = plainTextInputRef.current.indexOf(promptText, searchFrom); // Fallback: if not found near expected position, search from beginning if (localIdx === -1 && searchFrom > 0) { localIdx = plainTextInputRef.current.indexOf(promptText, 0); } while (localIdx !== -1) { const recoveredKey = `${localIdx}_${localIdx + promptText.length}`; if (!newMentionMap.has(recoveredKey)) { newMentionMap.set(recoveredKey, value); recovered = true; break; } localIdx = plainTextInputRef.current.indexOf(promptText, localIdx + 1); } } // Mention text truly gone — remove from formatters if (!recovered && value.id && !ifIdExists(value.id, newMentionMap)) { const trackingChar = value.trackingCharacter; if (trackingChar) { const targetedFormatter = allFormatters.current.get(trackingChar); if (targetedFormatter) { const existingCCUsers = [...targetedFormatter.getSuggestionItems()]; const userPosition = existingCCUsers.findIndex( (item: SuggestionItem) => item.id === value.id ); if (userPosition !== -1) { existingCCUsers.splice(userPosition, 1); (targetedFormatter as CometChatMentionsFormatter).setSuggestionItems(existingCCUsers); if (!getMentionLimitView(targetedFormatter as CometChatMentionsFormatter)) { setWarningMessage(''); } } } } } } }); mentionMap.current = newMentionMap; // Sync mention visual styling after text change shifts mention positions syncMentionRanges(); }; /** * Handle selection change for mention tracking */ const handleSelectionChange = (event: { nativeEvent: { selection: { start: number; end: number } } }) => { const { selection } = event.nativeEvent; if (Date.now() < ignoreSelectionUntil.current) { return; } const cursorPos = selection.start; const mentionRange = getMentionRangeAtCursor(mentionMap.current, cursorPos); if (mentionRange) { const distanceToStart = cursorPos - mentionRange.start; const distanceToEnd = mentionRange.end - cursorPos; let targetPosition: number; if (distanceToStart <= distanceToEnd) { targetPosition = mentionRange.start; } else { targetPosition = mentionRange.end; } if (targetPosition !== cursorPos) { InteractionManager.runAfterInteractions(() => { inputRef.current?.setSelection?.(targetPosition); setSelectionPosition({ start: targetPosition, end: targetPosition }); }); return; } } setSelectionPosition(selection); openList(selection); }; /** * Insert mention at specific position in the mention map */ const insertMentionAt = ( map: Map, insertAt: number, key: string, value: SuggestionItem ): Map => { const mentionsArray = Array.from(map); mentionsArray.splice(insertAt, 0, [key, value]); return new Map(mentionsArray); }; /** * Check if an ID exists in the mention map */ const ifIdExists = (id: string, hashmap: Map): boolean => { for (const [, value] of hashmap) { if (value.id === id) { return true; } } return false; }; /** * Handle mention selection from suggestion list */ const onMentionPress = (item: SuggestionItem) => { // Enforce maxMentionLimit — prevent inserting if limit reached if (mentionMap.current.size >= maxMentionLimit) { onMentionLimitReached?.(); setShowMentionList(false); setMentionsSearchData([]); return; } setShowMentionList(false); setMentionsSearchData([]); const promptText = item.promptText || ''; const notAtLast = selectionPosition.start < plainTextInputRef.current.length; const textDiff = plainTextInputRef.current.length + promptText.length - searchStringRef.current.length - plainTextInputRef.current.length; let incr = 0; let mentionPos = 0; const newMentionMap = new Map(mentionMap.current); const targetedFormatter = allFormatters.current.get(activeCharacter.current); if (targetedFormatter) { const existingCCUsers = [...targetedFormatter.getSuggestionItems()]; const userAlreadyExists = existingCCUsers.find( (existingUser: SuggestionItem) => existingUser.id === item.id ); if (!userAlreadyExists) { const cometchatUIUserArray: Array = [...existingCCUsers]; cometchatUIUserArray.push(item); (targetedFormatter as CometChatMentionsFormatter).setSuggestionItems(cometchatUIUserArray); } } mentionMap.current.forEach((value, key) => { const position = { start: parseInt(key.split('_')[0]), end: parseInt(key.split('_')[1]), }; if (!(selectionPosition.start <= position.start)) { mentionPos += 1; } // Delete mention from hashmap if cursor is within it if ( position.end === selectionPosition.end || (selectionPosition.start > position.start && selectionPosition.end <= position.end) ) { const newKey = `${position.start}_${position.end}`; newMentionMap.delete(newKey); mentionPos -= 1; } if (notAtLast && selectionPosition.start - 1 <= position.start) { incr = selectionPosition.end - selectionPosition.start + textDiff; const newKey = `${position.start + incr}_${position.end + incr}`; newMentionMap.set(newKey, value); newMentionMap.delete(key); } }); mentionMap.current = newMentionMap; // Update the input text with the mention const updatedPlainTextInput = `${plainTextInputRef.current.substring( 0, selectionPosition.start - (1 + searchStringRef.current.length) )}${promptText + ' '}${plainTextInputRef.current.substring( selectionPosition.end, plainTextInputRef.current.length )}`; plainTextInputRef.current = updatedPlainTextInput; const key = selectionPosition.start - (1 + searchStringRef.current.length) + '_' + (selectionPosition.start - (searchStringRef.current.length + 1) + promptText.length); const updatedMap = insertMentionAt(mentionMap.current, mentionPos, key, { ...item, trackingCharacter: activeCharacter.current, }); mentionMap.current = updatedMap; // Calculate cursor position after the mention + space const newCursorPosition = selectionPosition.start - (searchStringRef.current.length + 1) + (promptText.length) + 1; // +1 for the space after mention // Update the input text state — ignore native selection events for 500ms ignoreSelectionUntil.current = Date.now() + 500; setInputTextProgrammatic(updatedPlainTextInput); // Set cursor position and sync mention styling to native editor setTimeout(() => { inputRef.current?.setSelection?.(newCursorPosition); // Update our tracking state without triggering a re-render to native setSelectionPosition({ start: newCursorPosition, end: newCursorPosition }); // Apply mention visual styling (bold purple + background pill) in native editor syncMentionRanges(); }, 100); }; /** * Handle end reached for suggestion list pagination */ const onSuggestionListEndReached = () => { // Pagination can be implemented here if needed }; /** * Get mention limit warning view * Displays a warning when the mention limit is reached. * Also sets the warningMessage state for display in CustomViewHeader. * * @param targettedFormatterParam - Optional formatter to check (defaults to active formatter) * @returns boolean - true if warning should be shown, false otherwise */ const getMentionLimitView = (targettedFormatterParam?: CometChatMentionsFormatter): boolean => { const targetedFormatter = allFormatters.current.get(activeCharacter.current) ?? targettedFormatterParam; if (!(targetedFormatter instanceof CometChatMentionsFormatter)) { return false; } let shouldWarn = false; let limit: number | undefined; // Check if formatter has limit capabilities if (targetedFormatter.getLimit && targetedFormatter.getLimit()) { limit = targetedFormatter.getLimit(); if (targetedFormatter.getUniqueUsersList && targetedFormatter.getUniqueUsersList()?.size >= limit) { shouldWarn = true; } } if (!shouldWarn) { setWarningMessage(''); return false; } const errorString = targetedFormatter.getErrorString ? targetedFormatter.getErrorString() : `${t('MENTION_UPTO')} ${limit} ${limit === 1 ? t('TIME') : t('TIMES')} ${t('AT_A_TIME')}.`; setWarningMessage(errorString); return true; }; /** * Render mention limit warning view in suggestion list * Uses the warningMessage state set by getMentionLimitView */ const renderMentionLimitWarning = () => { if (!warningMessage) return null; return ( {warningMessage} ); }; /** * Convert mentions to underlying text format for sending * Iterates through mentionMap and replaces prompt text with underlying text */ const getRegexString = (str: string): string => { // Get an array of the entries in the map using the spread operator const entries = [...mentionMap.current.entries()].reverse(); let uidInput = str; // Iterate over the array in reverse order entries.forEach(([key, value]) => { const [start, end] = key.split('_').map(Number); const pre = uidInput.substring(0, start); const post = uidInput.substring(end); uidInput = pre + value.underlyingText + post; }); return uidInput; }; /** * Replace mention display names with underlying tokens in the given text. * Uses name-based indexOf (not position-based splicing) because markdown * syntax added by blocksToMarkdown shifts character positions. * Entries are sorted left-to-right so indexOf finds the correct occurrence * when the same display name appears multiple times. */ const replaceMentionsWithTokens = (text: string): string => { if (mentionMap.current.size === 0) return text; const entries = [...mentionMap.current.entries()].sort((a, b) => { return parseInt(a[0].split('_')[0]) - parseInt(b[0].split('_')[0]); }); let result = text; let offset = 0; for (const [, value] of entries) { const displayName = value.promptText || ''; const underlying = value.underlyingText || ''; if (!displayName || !underlying) continue; const idx = result.indexOf(displayName, offset); if (idx === -1) continue; result = result.substring(0, idx) + underlying + result.substring(idx + displayName.length); offset = idx + underlying.length; } return result; }; /** * Preview message for edit mode * Called when an edit message event is received * Populates the input with the original message text */ const previewMessage = ({ message, status }: { message: any; status: string }) => { if (status === messageStatus.inprogress) { // Populate formatter SuggestionItems from the message's mentioned users. // Only handleComposerPreview is needed — getFormattedText returns JSX // which is unused here since we feed plain text to markdownToBlocks. allFormatters.current.forEach((formatter: CometChatTextFormatter | CometChatMentionsFormatter) => { formatter.handleComposerPreview?.(message); }); // Resolve mention tokens (<@uid:...>, <@all:...>) to display names // and build mentionMap so the native editor can apply mention styling. // Media messages carry their editable text in the caption, not `.text`. const rawText = typeof message?.getCaption === "function" ? (message.getCaption() ?? '') : (message?.text ?? ''); const mentionFormatter = allFormatters.current.get('@'); let resolvedText = rawText; const newMentionMap = new Map(); if (mentionFormatter instanceof CometChatMentionsFormatter) { const suggestionItems = mentionFormatter.getSuggestionItems(); const itemLookup = new Map(); for (const item of suggestionItems) { itemLookup.set(item.id, item); } // Single-pass: collect matches, build resolved text via array join const mentionRegex = /<@(?:uid|all):(.*?)>/g; let match: RegExpExecArray | null; const segments: string[] = []; let lastIndex = 0; const mentionPositions: Array<{ start: number; length: number; item: SuggestionItem }> = []; let resolvedLength = 0; while ((match = mentionRegex.exec(rawText)) !== null) { // Append text before this match const before = rawText.substring(lastIndex, match.index); segments.push(before); resolvedLength += before.length; const item = itemLookup.get(match[1]); const displayText = item?.promptText ?? `@${match[1]}`; segments.push(displayText); if (item) { mentionPositions.push({ start: resolvedLength, length: displayText.length, item }); } resolvedLength += displayText.length; lastIndex = match.index + match[0].length; } // Append trailing text segments.push(rawText.substring(lastIndex)); resolvedText = segments.join(''); // Build mentionMap from collected positions for (const mp of mentionPositions) { const key = `${mp.start}_${mp.start + mp.length}`; newMentionMap.set(key, new SuggestionItem({ ...mp.item, trackingCharacter: '@', })); } } mentionMap.current = newMentionMap; // Set the message preview state with original message object // (preserves SDK prototype chain for CometChatMessagePreview formatting) setMessagePreview({ message: message, mode: ConversationOptionConstants.edit, }); // Parse markdown into structured blocks and load into the native editor const blocks = markdownToBlocks(resolvedText); inputTextRef.current = resolvedText; plainTextInputRef.current = resolvedText; blocksRef.current = blocks; // Do NOT call setInputText here — that would push text via the React // text prop, which races with setContent and can strip formatting or // trigger handleTextChange that corrupts mentionMap. Instead, update // only the refs and the inputText state silently after setContent loads. // Load structured blocks into the native editor. setTimeout(() => { inputRef.current?.setContent?.(blocks); inputRef.current?.focus(); // Update inputText state AFTER setContent so the text prop doesn't // race with attributed text. The native editor already has the content. setInputText(resolvedText); // Apply mention styling after native editor finishes processing setContent setTimeout(syncMentionRanges, 100); }, 100); } }; /** * Preview message for reply mode. * Called when a ccReplyToMessage event is received. * Clears any active edit preview before showing the reply preview. */ const previewReplyMessage = (message: any) => { // Clear edit preview if it exists if (messagePreview) { setMessagePreview(null); mentionMap.current = new Map(); plainTextInputRef.current = ''; syncMentionRanges(); } // Set the reply message state setReplyMessage({ message: message, mode: ConversationOptionConstants.reply, }); // Focus input after setting preview try { inputRef.current?.focus(); } catch (error) { // Ignore focus errors } }; /** * Expose ref methods for programmatic control. * Allows parent components to: * - Trigger edit mode programmatically * - Send text messages * - Get current input text * - Clear the input field * - Reset streaming state */ useImperativeHandle(ref, () => ({ previewMessageForEdit: previewMessage, sendTextMessage, getText: () => plainTextInputRef.current, setText: (text: string) => setInputTextProgrammatic(text), clear: () => { clearInputBox(); inputRef.current?.clear(); }, // Rich text formatting methods — exposed so developers can build custom toolbars. // These call the same native methods as the built-in inline toolbar. // Use these when enableRichTextEditor={false} and you want to provide your own UI controls. /** Toggles bold formatting on the current selection or at the cursor position. */ toggleBold: () => inputRef.current?.toggleBold(), /** Toggles italic formatting on the current selection or at the cursor position. */ toggleItalic: () => inputRef.current?.toggleItalic(), /** Toggles underline formatting on the current selection or at the cursor position. */ toggleUnderline: () => inputRef.current?.toggleUnderline(), /** Toggles strikethrough formatting on the current selection or at the cursor position. */ toggleStrikethrough: () => inputRef.current?.toggleStrikethrough(), /** Toggles inline code formatting on the current selection or at the cursor position. */ toggleCode: () => inputRef.current?.toggleCode(), /** Toggles text highlight/background color on the current selection. Pass an optional hex color string. */ toggleHighlight: (color?: string) => inputRef.current?.toggleHighlight(color), /** Converts the current line/block to a heading (toggles between heading and paragraph). */ setHeading: () => inputRef.current?.setHeading(), /** Converts the current line/block to a bullet (unordered) list item. */ setBulletList: () => inputRef.current?.setBulletList(), /** Converts the current line/block to a numbered (ordered) list item. */ setNumberedList: () => inputRef.current?.setNumberedList(), /** Converts the current line/block to a blockquote. */ setQuote: () => inputRef.current?.setQuote(), /** Converts the current line/block to a checklist item with a toggleable checkbox. */ setChecklist: () => inputRef.current?.setChecklist(), /** Resets the current line/block back to a normal paragraph. */ setParagraph: () => inputRef.current?.setParagraph(), /** Inserts a hyperlink at the current selection. Wraps selected text or inserts new link text. */ insertLink: (url: string, text: string) => inputRef.current?.insertLink(url, text), /** Undoes the last editing action. */ undo: () => inputRef.current?.undo(), /** Redoes the last undone editing action. */ redo: () => inputRef.current?.redo(), /** Removes all inline formatting (bold, italic, underline, etc.) from the current selection. */ clearFormatting: () => inputRef.current?.clearFormatting(), /** Increases the indentation level of the current line/block (for lists and quotes). */ indent: () => inputRef.current?.indent(), /** Decreases the indentation level of the current line/block. */ outdent: () => inputRef.current?.outdent(), /** Sets text alignment for the current line/block: 'left', 'center', or 'right'. */ setAlignment: (alignment: 'left' | 'center' | 'right') => inputRef.current?.setAlignment(alignment), /** Programmatically focuses the editor (opens keyboard). */ focus: () => inputRef.current?.focus(), /** Programmatically blurs the editor (dismisses keyboard). */ blur: () => inputRef.current?.blur(), /** * Reset streaming state and stop any active streaming. * Resets showStopButton and isStreaming to false. * Calls stopStreamingForRunId() with error handling. */ resetStreaming: () => { setShowStopButton(false); setIsStreaming(false); try { stopStreamingForRunId(); } catch (error) { // Silently handle streaming stop errors // The UI state is already reset } }, })); /** * Update CustomViewHeader when warningMessage changes. * Displays an info icon and warning text when mention limit is reached. * Clears the header when warning is cleared. */ useLayoutEffect(() => { if (warningMessage) { setCustomViewHeader( {warningMessage} ); return; } setCustomViewHeader(null); }, [warningMessage, theme, resolvedStyle]); /** * Parse a markdown string into the block format expected by the native * editor's setContent(blocks:) / initialContentJson. * * This is the inverse of blocksToMarkdown. It handles: * - Code blocks (``` ... ```) → { type: "codeBlock", text, styles: [] } * - Blockquotes (> ...) → { type: "quote", text, styles: [] } * - Bullet lists (- ...) → { type: "bullet", text, styles } * - Numbered lists (1. ...) → { type: "numbered", text, styles } * - Inline styles (bold **, italic _, underline , strikethrough ~~, code `) * → { type: "paragraph", text, styles: [{style, start, end}] } */ const markdownToBlocks = (markdown: string): any[] => { if (!markdown) return []; /** * Parse inline markdown styles from a line of text. * Returns the plain text and an array of style spans. * Supports nested styles (e.g. **bold underline**). */ const parseInlineStyles = (line: string): { text: string; styles: any[] } => { const styles: any[] = []; let plain = ''; // Helper: recursively parse inner content and offset style positions const parseNested = (content: string, offset: number): string => { const inner = parseInlineStyles(content); for (const s of inner.styles) { styles.push({ style: s.style, start: s.start + offset, end: s.end + offset }); } return inner.text; }; let i = 0; while (i < line.length) { // Markdown link: [text](url) if (line[i] === '[') { const closeBracket = line.indexOf('](', i + 1); if (closeBracket !== -1) { const closeParen = line.indexOf(')', closeBracket + 2); if (closeParen !== -1) { const start = plain.length; const linkText = line.substring(i + 1, closeBracket); const linkUrl = line.substring(closeBracket + 2, closeParen); plain += linkText; styles.push({ style: 'link', start, end: plain.length, url: linkUrl }); i = closeParen + 1; continue; } } } // Triple-backtick inline code block: ```...``` if (line[i] === '`' && line[i + 1] === '`' && line[i + 2] === '`') { const closeIdx = line.indexOf('```', i + 3); if (closeIdx !== -1) { const start = plain.length; const content = line.substring(i + 3, closeIdx); plain += content; styles.push({ style: 'code', start, end: plain.length }); i = closeIdx + 3; continue; } } // Inline code: `...` (no nesting inside code) if (line[i] === '`') { const closeIdx = line.indexOf('`', i + 1); if (closeIdx !== -1) { const start = plain.length; const content = line.substring(i + 1, closeIdx); plain += content; styles.push({ style: 'code', start, end: plain.length }); i = closeIdx + 1; continue; } } // Bold: **...** if (line[i] === '*' && line[i + 1] === '*') { const closeIdx = line.indexOf('**', i + 2); if (closeIdx !== -1) { const start = plain.length; const content = line.substring(i + 2, closeIdx); const innerText = parseNested(content, start); plain += innerText; styles.push({ style: 'bold', start, end: plain.length }); i = closeIdx + 2; continue; } } // Strikethrough: ~~...~~ if (line[i] === '~' && line[i + 1] === '~') { const closeIdx = line.indexOf('~~', i + 2); if (closeIdx !== -1) { const start = plain.length; const content = line.substring(i + 2, closeIdx); const innerText = parseNested(content, start); plain += innerText; styles.push({ style: 'strikethrough', start, end: plain.length }); i = closeIdx + 2; continue; } } // Underline: text (HTML tag, case-insensitive) if (line[i] === '<' && line.substring(i, i + 3).toLowerCase() === '') { const closeIdx = line.toLowerCase().indexOf('', i + 3); if (closeIdx !== -1) { const start = plain.length; const content = line.substring(i + 3, closeIdx); const innerText = parseNested(content, start); plain += innerText; styles.push({ style: 'underline', start, end: plain.length }); i = closeIdx + 4; continue; } } // Italic: _..._ if (line[i] === '_') { const closeIdx = line.indexOf('_', i + 1); if (closeIdx !== -1) { const start = plain.length; const content = line.substring(i + 1, closeIdx); const innerText = parseNested(content, start); plain += innerText; styles.push({ style: 'italic', start, end: plain.length }); i = closeIdx + 1; continue; } } // Plain character plain += line[i]; i++; } return { text: plain, styles }; }; const lines = markdown.split('\n'); const blocks: any[] = []; let i = 0; while (i < lines.length) { const line = lines[i]; // Code block fence: ``` if (line.trimEnd() === '```' || line.startsWith('```')) { const afterOpen = line.substring(3); const firstClose = afterOpen.indexOf('```'); if (firstClose > 0) { // Has closing ``` on the same line const afterFirstBlock = afterOpen.substring(firstClose + 3).trim(); if (afterFirstBlock.length === 0) { // Single ```content``` on this line — treat as one code block const content = afterOpen.substring(0, firstClose); blocks.push({ type: 'codeBlock', text: content, styles: [] }); i++; continue; } else { // Multiple ```...``` or mixed content — treat as paragraph with inline code styles const { text, styles } = parseInlineStyles(line); blocks.push({ type: 'paragraph', text, styles }); i++; continue; } } // Standalone ``` or ```langId — multi-line fenced code block i++; const codeLines: string[] = []; while (i < lines.length && lines[i].trimEnd() !== '```') { codeLines.push(lines[i]); i++; } // Each code line becomes its own codeBlock block (matching native editor format) if (codeLines.length === 0) { blocks.push({ type: 'codeBlock', text: '', styles: [] }); } else { for (const cl of codeLines) { blocks.push({ type: 'codeBlock', text: cl, styles: [] }); } } // Skip closing fence if (i < lines.length) i++; continue; } // Blockquote + bullet: > - ... if (line.startsWith('> - ')) { const content = line.substring(4); const { text, styles } = parseInlineStyles(content); blocks.push({ type: 'quoteBullet', text, styles }); i++; continue; } // Blockquote + numbered: > 1. ... if (/^> \d+\.\s/.test(line)) { const content = line.replace(/^> \d+\.\s/, ''); const { text, styles } = parseInlineStyles(content); blocks.push({ type: 'quoteNumbered', text, styles }); i++; continue; } // Blockquote: > ... if (line.startsWith('> ')) { const content = line.substring(2); const { text, styles } = parseInlineStyles(content); blocks.push({ type: 'quote', text, styles }); i++; continue; } // Bullet list: - ... if (line.startsWith('- ')) { const content = line.substring(2); const { text, styles } = parseInlineStyles(content); blocks.push({ type: 'bullet', text, styles }); i++; continue; } // Numbered list: 1. ... (any digit prefix) if (/^\d+\.\s/.test(line)) { const content = line.replace(/^\d+\.\s/, ''); const { text, styles } = parseInlineStyles(content); blocks.push({ type: 'numbered', text, styles }); i++; continue; } // Heading: # ... if (line.startsWith('# ')) { const content = line.substring(2); const { text, styles } = parseInlineStyles(content); blocks.push({ type: 'heading', text, styles }); i++; continue; } // Default: paragraph with inline styles const { text, styles } = parseInlineStyles(line); blocks.push({ type: 'paragraph', text, styles }); i++; } return blocks; }; /** * Convert RichTextEditor blocks to markdown string. * Handles overlapping style ranges (e.g. bold+italic+underline on same text). */ const blocksToMarkdown = (blocks: any[]): string => { if (!blocks || blocks.length === 0) return ''; // Marker map for each style type — open/close pairs // Most markers are symmetric (same open and close), but underline uses HTML tags. const markerMap: Record = { bold: { open: '**', close: '**' }, italic: { open: '_', close: '_' }, underline: { open: '', close: '' }, strikethrough: { open: '~~', close: '~~' }, code: { open: '`', close: '`' }, }; // Order matters: outer markers wrap inner ones const styleOrder = ['bold', 'underline', 'strikethrough', 'italic', 'code']; const applyInlineStyles = (text: string, styles: any[]): string => { if (!styles || styles.length === 0 || !text) return text; // Build mention ranges by finding mention promptText within this block's text. // This is more robust than position-based mapping because mentionMap positions // are based on plain text (with list prefixes) while block text has prefixes stripped. const mentionRanges: Array<[number, number]> = []; mentionMap.current.forEach((value, _key) => { const pt = value.promptText || ''; if (pt) { let searchFrom = 0; // Find all occurrences of this mention's promptText in the block text while (searchFrom < text.length) { const idx = text.indexOf(pt, searchFrom); if (idx === -1) break; mentionRanges.push([idx, idx + pt.length]); searchFrom = idx + pt.length; } } }); // Split styles around mention ranges so mentions stay clean. const filteredStyles: any[] = []; for (const s of styles) { if (s.style === 'link' || mentionRanges.length === 0) { filteredStyles.push(s); continue; } const sStart = Math.max(0, s.start); const sEnd = Math.min(text.length, s.end); // Collect non-mention sub-ranges of this style let cursor = sStart; // Sort mention ranges for consistent processing const sorted = [...mentionRanges].sort((a, b) => a[0] - b[0]); for (const [mStart, mEnd] of sorted) { if (mStart >= sEnd || mEnd <= sStart) continue; // no overlap // Add the part before the mention if (cursor < mStart) { filteredStyles.push({ ...s, start: cursor, end: mStart }); } cursor = Math.max(cursor, mEnd); } // Add the part after the last mention if (cursor < sEnd) { filteredStyles.push({ ...s, start: cursor, end: sEnd }); } } // Collect all boundary positions where style sets change const boundaries = new Set(); boundaries.add(0); boundaries.add(text.length); for (const s of filteredStyles) { boundaries.add(Math.max(0, s.start)); boundaries.add(Math.min(text.length, s.end)); } const sorted = Array.from(boundaries).sort((a, b) => a - b); // Build segments with their active style sets const segActiveStyles: string[][] = []; const segTexts: string[] = []; const segLinkUrls: string[] = []; for (let i = 0; i < sorted.length - 1; i++) { const segStart = sorted[i]; const segEnd = sorted[i + 1]; const segment = text.substring(segStart, segEnd); if (segment.length === 0) continue; const active: string[] = []; let linkUrl = ''; for (const s of filteredStyles) { const sStart = Math.max(0, s.start); const sEnd = Math.min(text.length, s.end); if (sStart <= segStart && sEnd >= segEnd) { if (s.style === 'link' && s.url) { linkUrl = s.url; } else if (markerMap[s.style] !== undefined && !active.includes(s.style)) { active.push(s.style); } } } segActiveStyles.push(active); segTexts.push(segment); segLinkUrls.push(linkUrl); } if (segTexts.length === 0) return text; // Build markdown with properly nested markers. // We maintain a stack of currently open styles. Between segments, // we close styles that are ending (popping from stack) and open // styles that are starting (pushing onto stack). This ensures // markers are always properly nested. let result = ''; let openStack: string[] = []; // styles currently open, in nesting order for (let i = 0; i < segTexts.length; i++) { const curr = segActiveStyles[i]; // Determine which styles in styleOrder should be active const desired = styleOrder.filter(s => curr.includes(s)); // Close styles that are open but not desired (pop from top of stack) // We must close in reverse stack order (innermost first) const toClose: string[] = []; const toReopen: string[] = []; // Find styles to close: anything in openStack not in desired // But we may need to close styles above them too (and reopen) const newStack: string[] = []; for (const s of openStack) { if (desired.includes(s)) { newStack.push(s); } } // Close everything from top of stack down to what we need // Then reopen what should stay if (openStack.length > 0) { // Find the deepest style that needs to close let closeFrom = -1; for (let j = openStack.length - 1; j >= 0; j--) { if (!desired.includes(openStack[j])) { closeFrom = j; break; } } if (closeFrom >= 0) { // Close everything from top down to closeFrom for (let j = openStack.length - 1; j >= closeFrom; j--) { result += markerMap[openStack[j]].close; if (desired.includes(openStack[j])) { toReopen.push(openStack[j]); } } openStack = openStack.slice(0, closeFrom); // Reopen styles that should stay (in their original order) for (const s of toReopen.reverse()) { result += markerMap[s].open; openStack.push(s); } } } // Open new styles that are desired but not yet open for (const s of desired) { if (!openStack.includes(s)) { result += markerMap[s].open; openStack.push(s); } } // Add segment text (with link wrapping if needed) if (segLinkUrls[i]) { result += `[${segTexts[i]}](${segLinkUrls[i]})`; } else { result += segTexts[i]; } } // Close all remaining open styles (innermost first) for (let j = openStack.length - 1; j >= 0; j--) { result += markerMap[openStack[j]].close; } return result; }; // Strip trailing empty list items (bullet/numbered) so "1. text\n2. " sends as "1. text" let blockCount = blocks.length; while (blockCount > 0) { const last = blocks[blockCount - 1]; if ((last.type === 'bullet' || last.type === 'numbered' || last.type === 'quoteBullet' || last.type === 'quoteNumbered') && !(last.text || '').trim()) { blockCount--; } else { break; } } const lines: string[] = []; let inCodeBlock = false; let orderedCounter = 0; for (let i = 0; i < blockCount; i++) { const block = blocks[i]; const text = block.text || ''; const styles: any[] = block.styles || []; const blockType = block.type || 'paragraph'; if (blockType === 'codeBlock') { if (!inCodeBlock) { // Check if this is a single-line code block (no consecutive codeBlock after it) const nextBlock = i + 1 < blocks.length ? blocks[i + 1] : null; if (!nextBlock || (nextBlock.type || 'paragraph') !== 'codeBlock') { // Single-line: emit ```content``` (no newlines) lines.push(`\`\`\`${text}\`\`\``); orderedCounter = 0; continue; } // Multi-line: use fenced format lines.push('```'); inCodeBlock = true; } // Code block lines use raw text (no inline style markers) lines.push(text); } else { // Close code block fence if we were inside one if (inCodeBlock) { lines.push('```'); inCodeBlock = false; } const styledText = applyInlineStyles(text, styles); switch (blockType) { case 'bullet': case 'quoteBullet': // Bullet lines don't reset the counter (Slack behavior) lines.push(blockType === 'quoteBullet' ? `> - ${styledText}` : `- ${styledText}`); break; case 'numbered': orderedCounter++; lines.push(`${orderedCounter}. ${styledText}`); break; case 'quoteNumbered': orderedCounter++; lines.push(`> ${orderedCounter}. ${styledText}`); break; case 'quote': lines.push(`> ${styledText}`); break; case 'heading': lines.push(`# ${styledText}`); break; default: orderedCounter = 0; lines.push(styledText); break; } } } // Close any trailing code block fence if (inCodeBlock) { lines.push('```'); } return lines.join('\n'); }; /** * Send text message * Includes agentic user support for: * - Parent message ID tracking * - Send button delay * - Streaming start * - Reply message support */ const sendTextMessage = () => { const trimmedText = inputText.trim(); if (trimmedText.length === 0) { return; } // Check if in edit mode - call editMessage instead if (messagePreview !== null) { editMessage(messagePreview.message); return; } // Capture current reply message before clearing (for quoted message) const currentReplyMessage = replyMessage; const replyMessageId = currentReplyMessage?.message?.getId?.() ?? null; // Convert blocks to markdown if rich text is enabled, otherwise use plain text let textToSend = enableRichTextEditor && blocksRef.current.length > 0 ? blocksToMarkdown(blocksRef.current) : trimmedText; // Apply pending links: replace link display text with [text](url) markdown if (pendingLinksRef.current.size > 0) { pendingLinksRef.current.forEach((url, displayText) => { // Only replace if the text isn't already wrapped in markdown link syntax const escaped = displayText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const alreadyLinked = new RegExp(`\\[${escaped}\\]\\(`); if (!alreadyLinked.test(textToSend)) { // Use a function replacement to avoid $ special patterns in replacement string textToSend = textToSend.replace(displayText, () => `[${displayText}](${url})`); } }); pendingLinksRef.current.clear(); } // Process mentions to underlying text format before sending. // Mention map positions are based on plain text (plainTextInputRef), // but blocksToMarkdown adds markdown syntax (```, `, **, etc.) that // shifts character positions. So when mentions exist, do a name-based // find-and-replace on the markdown text instead of position-based splicing. let processedText: string; if (mentionMap.current.size > 0) { processedText = replaceMentionsWithTokens(textToSend); } else { processedText = getRegexString(textToSend); } let textMessage = new CometChat.TextMessage( chatWithId.current, processedText, chatWith.current ); if (loggedInUser.current) textMessage.setSender(loggedInUser.current as CometChat.User); textMessage.setReceiver((user || group)!); textMessage.setMuid(String(getUnixTimestampInMilliseconds())); // Handle parent message ID for threaded messages // For agentic users, use tracked parent message ID if no prop provided if (parentMessageId) { textMessage.setParentMessageId(parentMessageId as number); } else if (isAgenticUser() && parentMessageIdRef.current) { textMessage.setParentMessageId(parentMessageIdRef.current); } /** * Handle reply message - set quoted message for replies. * Uses shared helper for consistency with CometChatMessageComposer. */ if (replyMessageId && currentReplyMessage?.message) { setQuotedMessageSafe(textMessage, currentReplyMessage.message, replyMessageId); } // Process text through formatters allFormatters.current.forEach((formatter) => { textMessage = formatter.handlePreMessageSend(textMessage); }); clearInputBox(); // Clear mention map after sending mentionMap.current = new Map(); endTyping(); /** * Clear reply preview after preparing message. */ setReplyMessage(null); // If custom send handler is provided, use it if (onSendButtonPress) { onSendButtonPress(textMessage); return; } // Emit in-progress event CometChatUIEventHandler.emitMessageEvent(MessageEvents.ccMessageSent, { message: textMessage, status: messageStatus.inprogress, }); // Play sound if enabled if (!disableSoundForMessages) { playAudio(); } /** * Enable 1-second delay for agentic users after sending. * This prevents rapid-fire messages to AI agents. */ if (isAgenticUser()) { setIsSendButtonDisabledForDelay(true); if (sendButtonDelayTimer.current) { clearTimeout(sendButtonDelayTimer.current); } sendButtonDelayTimer.current = setTimeout(() => { setIsSendButtonDisabledForDelay(false); }, 1000); } // Send message via SDK CometChat.sendMessage(textMessage) .then((message: CometChat.BaseMessage) => { /** * For agentic users without parentMessageId prop: * - Store the message ID as parent for subsequent messages * - Start streaming for the message */ if (isAgenticUser() && !parentMessageId && message?.getId) { const messageId = typeof message.getId() === 'string' ? Number(message.getId()) : message.getId(); // Store parent message ID for subsequent messages if (!isNaN(messageId) && !parentMessageIdRef.current) { parentMessageIdRef.current = messageId; } // Start streaming for agentic user messages if (messageId && !isNaN(messageId)) { startStreamingForRunId(String(messageId)); } } CometChatUIEventHandler.emitMessageEvent(MessageEvents.ccMessageSent, { message: message, status: messageStatus.success, }); }) .catch((error: CometChat.CometChatException) => { onError?.(error); (textMessage as any).data.metaData = { error: true }; CometChatUIEventHandler.emitMessageEvent(MessageEvents.ccMessageSent, { message: textMessage, status: messageStatus.error, }); }); }; /** * Edit an existing message * Called when in edit mode and user submits the edited text */ const editMessage = (originalMessage: any) => { endTyping(); // Convert blocks to markdown and replace mention display names with tokens const trimmedText = inputText.trim(); let textToSend = enableRichTextEditor && blocksRef.current.length > 0 ? blocksToMarkdown(blocksRef.current) : trimmedText; textToSend = replaceMentionsWithTokens(textToSend); // Media caption edit — keep the media (attachments) and update only the caption, // instead of replacing the media message with a text message. const isMediaEdit = typeof originalMessage?.getCaption === "function" && typeof originalMessage?.setCaption === "function"; let outgoing: any; if (isMediaEdit) { // Same approach as the text edit below: build a FRESH message rather than reusing the one // the server sent us (which carries stale metadata + the prior moderation verdict). A clean // payload is re-moderated by the server just like a fresh send. Attachments are carried over // so the media survives the caption edit. const mediaMessage = new CometChat.MediaMessage( chatWithId.current, undefined as any, originalMessage.getType(), chatWith.current ); mediaMessage.setId( originalMessage.getId ? originalMessage.getId() : originalMessage.id ); const existingAttachments = originalMessage.getAttachments?.() ?? []; if (existingAttachments.length) mediaMessage.setAttachments(existingAttachments); mediaMessage.setCaption(textToSend); if (parentMessageId) { mediaMessage.setParentMessageId(parentMessageId as number); } outgoing = mediaMessage; } else { // Create new text message with edited content const textMessage = new CometChat.TextMessage( chatWithId.current, textToSend, chatWith.current ); // Set the message ID from the original message textMessage.setId(originalMessage.id); if (parentMessageId) { textMessage.setParentMessageId(parentMessageId as number); } outgoing = textMessage; } // Clear input and preview clearInputBox(); setMessagePreview(null); // If custom send handler is provided, use it if (onSendButtonPress) { onSendButtonPress(outgoing); return; } // Play sound if enabled if (!disableSoundForMessages) { playAudio(); } // Edit message via SDK CometChat.editMessage(outgoing) .then((editedMessage: CometChat.BaseMessage) => { CometChatUIEventHandler.emitMessageEvent(MessageEvents.ccMessageEdited, { message: editedMessage, status: messageStatus.success, }); }) .catch((error: CometChat.CometChatException) => { onError?.(error); }); }; /** * Check if the text has actual user content beyond formatting markers. * When a user taps bullet, numbered list, blockquote, or applies inline * formatting without typing content, the editor inserts markers but no * real content — send should stay disabled. * * Handles: empty code block fences, empty inline format markers * (** **, _ _, , ~~ ~~, ` `), nested empty formatting, * and block prefixes (• , N. , ▎ ). */ const hasActualContent = (text: string): boolean => { let stripped = text; // Remove code block fences and keep only inner content stripped = stripped.replace(/```[\s\S]*?```/g, (m) => m.slice(3, -3)); // Remove inline format markers (keep inner content) stripped = stripped.replace(/\*\*(.+?)\*\*/g, '$1'); stripped = stripped.replace(/(.+?)<\/u>/gi, '$1'); stripped = stripped.replace(/~~(.+?)~~/g, '$1'); stripped = stripped.replace(/`(.+?)`/g, '$1'); stripped = stripped.replace(/(?\s*<\/u>/gi, ''); stripped = stripped.replace(/~~\s*~~/g, ''); stripped = stripped.replace(/`\s*`/g, ''); stripped = stripped.replace(/(? line.replace(/^(>\s)?(-\s|•\s|‧\s|▎\s|\d+\.\s)+/, '').trim()) .join(''); return stripped.length > 0; }; /** * Mic button slide/fade animation effect. * Triggers forward animation (slide out + fade) when text appears, * and reverse animation (slide in + fade in) when text is cleared. * Rapid toggling is handled by Animated API cancellation — calling * .start() on a new animation cancels the previous one. */ const hasContent = hasActualContent(inputText); // Hide the mic (→ show send) when there's text OR staged attachments — you can't record a voice // note on top of a media message, and the design shows only Send once media is attached. const micCollapsed = hasContent || selectedAttachments.length > 0; useEffect(() => { if (hideVoiceRecordingButton) return; // Run visual animation (native driver) and layout collapse (JS driver) in parallel Animated.parallel([ // Visual: slide + fade (native driver for 60fps) Animated.timing(micAnimValue, { toValue: micCollapsed ? 1 : 0, duration: MIC_ANIM_DURATION, useNativeDriver: true, }), // Layout: width collapse (JS driver, needed for layout props) Animated.timing(micWidthAnim, { toValue: micCollapsed ? 0 : 1, duration: MIC_ANIM_DURATION, useNativeDriver: false, }), ]).start(); }, [micCollapsed, hideVoiceRecordingButton]); /** * Check if send button should be disabled. * For agentic users, also considers: * - Streaming state (disabled while AI is responding) * - Send button delay (disabled for 1 second after sending) */ const hasPendingUploads = selectedAttachments.some( a => a.uploadState === 'NONE' || a.uploadState === 'UPLOADING' ); // Any errored (FAILED/REJECTED) staged attachment blocks send entirely — the user must // ✕-remove the errored file(s) first, so we never ship a partial batch. const hasErroredUploads = selectedAttachments.some( a => a.uploadState === 'FAILED' || a.uploadState === 'REJECTED' ); const isSendDisabled = isStreaming || isSendButtonDisabledForDelay || hasPendingUploads || hasErroredUploads || (!hasActualContent(inputText) && selectedAttachments.length === 0); /** * Get send button tint color based on state */ const getSendButtonTint = () => { if (isSendDisabled) { return theme.color.iconSecondary; } return resolvedStyle?.sendIconStyle?.tintColor || theme.color.primary; }; /** * Get attachment icon tint color */ const getAttachmentIconTint = () => { return resolvedStyle?.attachmentIconStyle?.tintColor || theme.color.iconSecondary; }; /** * Render secondary button (left side, for API compatibility) * By default, shows the attachment button (📎) which opens the attachment action sheet. * For agentic users, the attachment button is automatically hidden. */ const renderSecondaryButton = () => { // Hide attachment button for agentic users if (hideAttachmentButton) { return null; } let content: JSX.Element; if (SecondaryButtonView) { content = ( ); } else { // Default attachment button - opens action sheet. Always enabled (even at capacity) so it never // feels broken; an over-limit pick is rejected on selection (acceptPickedAttachments + count toast). const handleAttachmentClick = () => { if (onAttachmentClick) { onAttachmentClick(); } setShowActionSheet(true); }; // Always show attachment button by default (per requirements doc) // The button opens the attachment action sheet with default options from ChatConfigurator // Use SVG icon name for proper tintColor support content = ( ); } // Edit mode: attachments are disabled (greyed, non-interactive) — only Send and Sticker stay active. return messagePreview ? ( {content} ) : content; }; /** * Render auxiliary button * Uses custom EmojiButton component with proper styling (no extra padding). * For agentic users, auxiliary buttons are automatically hidden. * When placed on the right (next to mic), slides/fades with the same animation as the mic button. */ const renderAuxiliaryButton = () => { // Hide auxiliary buttons if prop is set if (hideAuxiliaryButton || hideAuxiliaryButtons) { return null; } // Hide stickers button if prop is set if (hideStickersButton) { return null; } // For agentic users, hide default auxiliary buttons (but allow custom ones) if (isAgenticUser() && !AuxiliaryButtonView) { return null; } let content: JSX.Element; // If custom AuxiliaryButtonView is provided, use it if (AuxiliaryButtonView) { content = ( ); } else { // Use custom EmojiButton with proper styling (no extra padding) content = ( ); } // No animation needed for sticker — it naturally shifts via flexbox // when the mic button's layout collapses return content; }; /** * Default agent send button view component. * Uses CometChatSendButtonView for agentic users with streaming support. */ const DefaultAgentSendButtonView = useCallback( ({ isButtonDisabled, composerRef }: { isButtonDisabled: boolean; composerRef: any }) => ( ), [isStreaming, showStopButton] ); /** * Render send button * Always shows send button with disabled/enabled states based on input content. * When input is empty, send button is disabled with inactive tint color. * When input has text, send button is enabled with active tint color. * For agentic users, renders the agent send button with streaming support. */ const renderSendButton = () => { if (hideSendButton) { return null; } // For agentic users, use the agent send button view if (isAgenticUser()) { const disabled = isStreaming || inputText.trim().length === 0 || (messagePreview !== null) || isSendButtonDisabledForDelay; const SendButtonComponent = AgentSendButtonView || DefaultAgentSendButtonView; // Create a ref-like object that matches what CometChatSendButtonView expects const composerRef = { current: { sendTextMessage: () => { if (!disabled) { sendTextMessage(); } } } }; return ; } if (SendButtonView) { return ( ); } // Always show send button with disabled/enabled state based on input content // Figma spec: 32×32 circle, primary bg when active, background4 when disabled const sendIconSize = theme.spacing.spacing.s5; const sendIconColor = isSendDisabled ? (theme.color.iconSecondary as string) : (theme.color.primaryButtonIcon as string); const handleSendPress = selectedAttachments.length > 0 ? sendMultiAttachmentMediaMessage : sendTextMessage; return ( {sendButtonIcon ? ( ) : ( )} ); }; /** * Render voice recording button with slide/fade animation. * Shows/hides based on hideVoiceRecordingButton (derived from prop or agentic user status). * For agentic users, voice recording is automatically hidden. * The button slides right (translateX 0→30) and fades out (opacity 1→0) when text is entered, * and reverses when text is cleared. Layout collapses to width:0 after animation completes. */ const RecordAudioButtonView = () => { // Preserve guard: hideVoiceRecordingButton or agentic returns null before wrapper if (hideVoiceRecordingButton) { return null; } // Hide voice recording button when inline recorder is active if (showInlineRecorder) { return null; } // Two nested Animated.Views to avoid native/JS driver conflict: // Outer: JS driver — animates width + margin for layout collapse (eats the gap) // Inner: native driver — animates opacity + translateX for smooth visuals return ( { // Show inline recorder setTimeout(() => setShowInlineRecorder(true), 50); }} buttonStyle={Style.iconButton} iconStyle={Style.icon} tintColor={getAttachmentIconTint()} /> ); }; // Container styles - outer wrapper with padding (matches MessageComposer) const wrapperStyle: StyleProp = [ Style.wrapper, { backgroundColor: resolvedStyle?.containerStyle?.backgroundColor || theme.color.background3, }, ]; // Inner container styles with border (matches MessageComposer: messageInputStyles.containerStyle) const containerStyle: StyleProp = [ Style.container, resolvedStyle?.containerStyle, { backgroundColor: resolvedStyle?.messageInputStyles?.containerStyle?.backgroundColor || theme.color.background1, borderColor: resolvedStyle?.messageInputStyles?.containerStyle?.borderColor || theme.color.borderDefault, }, ]; // Input styles - use messageInputStyles from v5 theme const textInputStyle: StyleProp = [ Style.textInput, resolvedStyle?.messageInputStyles?.textStyle, { color: resolvedStyle?.messageInputStyles?.textStyle?.color || theme.color.textPrimary, // Apply dynamic height when autoExpand is enabled ...(autoExpand ? { height: inputHeight } : {}), }, inputStyle, ]; // Separator style (matches Figma: Seprator) const separatorStyle: StyleProp = [ Style.separator, resolvedStyle?.messageInputStyles?.dividerStyle, ]; return ( <> {/* Modal for custom views */} { setIsVisible(false); }} presentationStyle="pageSheet" > {CustomView && CustomView} {/* KeyboardAvoidingView wrapper */} {/* Outer wrapper with padding (matches Figma: Message Composer) */} {/* Error banner floats ABOVE the composer, over the message list. Anchored to the outer wrapper (bottom:100%), NOT the bordered container which clips via overflow:hidden. */} { const h = e.nativeEvent.layout.height; if (h > 0 && h !== attachmentBannerHeight) setAttachmentBannerHeight(h); }} style={{ position: 'absolute', left: 0, right: 0, // Sit fully ABOVE the composer with a gap: top = -(measured banner height + gap), so it // clears the composer regardless of text length. Hidden until measured (avoids a flash). top: -(attachmentBannerHeight + theme.spacing.spacing.s3), paddingHorizontal: theme.spacing.padding.p2, // match the composer's side inset opacity: attachmentBannerHeight > 0 ? 1 : 0, }} pointerEvents="box-none" > setAttachmentBannerMessage(null)} /> {/* Inner container with border (matches Figma: Base_Message Composer) */} {/* Action Sheet for attachments */} setShowActionSheet(false)} // iOS: launch the deferred picker only after the sheet's modal has // fully dismissed, so UIKit isn't asked to present while dismissing. onDismiss={() => { const action = pendingAttachmentActionRef.current; pendingAttachmentActionRef.current = null; action?.(); }} style={resolvedStyle?.attachmentOptionsStyles} /> {/* Voice Recording - removed bottom sheet, using inline recorder instead */} {HeaderView ? ( ) : CustomViewHeader ? ( typeof CustomViewHeader === 'function' ? ( ) : ( CustomViewHeader ) ) : null} {/* Mention Suggestion List */} {showMentionList && plainTextInputRef.current.length > 0 && mentionsSearchData.length > 0 && ( {renderMentionLimitWarning()} )} {/* Message Preview Tray for Edit Mode */} { setMessagePreview(null); clearInputBox(); }} /> {replyMessage && replyMessage.message && ( )} {/* Conditional: Show inline recorder OR input row */} {showInlineRecorder ? ( ) : ( <> {trayMediaItems.length > 0 && ( setTrayViewerVisible(false)} /> )} {selectedAttachments.length > 0 && ( )} {/* Top Field - Input row with icons (matches Figma: Top Field) */} {/* Left icons container - dynamic alignment based on expansion state */} {/* Secondary button (attachment) */} {renderSecondaryButton()} {/* Auxiliary button on left if alignment is 'left' */} {resolvedAlignment === 'left' && renderAuxiliaryButton()} {/* Rich Text Editor - replaces TextInput with same styling */} { // Tab key inserts indentation on web (Req 3.1); no-op on native (Req 3.2) if (e.key === 'Tab') { e.preventDefault(); inputRef.current?.setText?.(inputText + '\t'); } }, } : {})} > { const newText = event.nativeEvent.text; // Store blocks for markdown conversion when sending. // The RichTextEditor wrapper already parses blocksJson into blocks, // so event.nativeEvent.blocks is always populated. if (event.nativeEvent.blocks && event.nativeEvent.blocks.length > 0) { blocksRef.current = event.nativeEvent.blocks; } if (skipNextContentChange.current) { skipNextContentChange.current = false; plainTextInputRef.current = newText; inputTextRef.current = newText; return; } handleTextChange(newText); }} onSelectionChange={(event: any) => { const { start, end } = event.nativeEvent; handleSelectionChange({ nativeEvent: { selection: { start, end } } }); }} onActiveStylesChange={(styles: ActiveStylesState) => { activeStylesRef.current = styles; setActiveStyles(styles); // Sync codeBlockActive from native codeBlock field setCodeBlockActive(!!styles.codeBlock); }} onSizeChange={(height: number) => { if (autoExpand) { // Snap to resolvedMinHeight when height is within 2px of it // to prevent sub-pixel layout drift between initial empty state // and post-clear empty state on iOS (UITextView.sizeThatFits // returns slightly different values for these two states) const snapped = Math.abs(height - resolvedMinHeight) <= 2 ? resolvedMinHeight : height; const clamped = Math.max(resolvedMinHeight, Math.min(snapped, resolvedMaxHeight)); setInputHeight(clamped); const newIsExpanded = clamped > resolvedMinHeight; if (newIsExpanded !== isExpanded) { setIsExpanded(newIsExpanded); } } }} onFocus={() => { setIsFocused(true); startTyping(); }} onBlur={() => { setIsFocused(false); endTyping(); }} placeholder={placeHolderText || (isAgenticUser() ? t('ASK_ANYTHING') : t('ENTER_YOUR_MESSAGE_HERE'))} placeholderTextColor={resolvedStyle?.messageInputStyles?.placeHolderTextColor as string || theme.color.textTertiary as string} variant="plain" showToolbar={false} maxHeight={resolvedMaxHeight} numberOfLines={maxLines ?? 5} textStyle={{ fontSize: (resolvedStyle?.messageInputStyles?.textStyle as any)?.fontSize ?? 14, color: (resolvedStyle?.messageInputStyles?.textStyle?.color || theme.color.textPrimary) as string, fontFamily: (resolvedStyle?.messageInputStyles?.textStyle as any)?.fontFamily, }} codeBackgroundColor={theme.color.neutral200 as string} codeBorderColor={theme.color.neutral300 as string} codeTextColor={(theme.mode === 'dark' ? theme.color.extendedPrimary900 : theme.color.primary) as string} codeFontSize={theme.typography.caption1.regular.fontSize as number} onLinkTap={onLinkTap} enterKeyBehavior={enterKeyBehavior as string} showTextSelectionMenuItems={showTextSelectionMenuItems} onSendRequest={() => { if (!isSendDisabled) { if (selectedAttachments.length > 0) { sendMultiAttachmentMediaMessage(); } else { sendTextMessage(); } } }} onPasteMedia={handlePasteMedia} /> {/* Right icons container - dynamic alignment based on expansion state */} {/* Auxiliary button (stickers/emoji) on right — slides with mic width collapse */} {resolvedAlignment === 'right' && !hideVoiceRecordingButton && ( renderAuxiliaryButton() )} {resolvedAlignment === 'right' && hideVoiceRecordingButton && renderAuxiliaryButton()} {/* Voice recording button - matches Figma design */} {!hideVoiceRecordingButton && !isAgenticUser() && } {/* Send button */} {renderSendButton()} {/* Attachment preview strip — BELOW the input row (design: tray under the input, icons stay inline on the input row). Only shown when files are queued. */} {selectedAttachments.length > 0 && ( )} {/* Rich text toolbar — shown below input row */} {enableRichTextEditor && !hideRichTextFormattingOptions && ( {richTextToolbarItems.map((item, index) => { if (item.type === 'separator') { return ; } // Slack-style: disable inline format buttons inside code blocks; // block-level buttons (lists, quote, code-block) stay enabled. const codeBlockAllowedKeys = ['ordered-list', 'unordered-list', 'blockquote', 'code-block']; const isDisabled = codeBlockActive && !codeBlockAllowedKeys.includes(item.key); let isActive = item.isActive?.(activeStyles) ?? false; // Distinguish inline code vs code block highlighting if (item.key === 'inline-code') { isActive = activeStyles.code && !codeBlockActive; } else if (item.key === 'code-block') { isActive = codeBlockActive; } const iconColor = isDisabled ? theme.color.iconSecondary : isActive ? theme.color.iconPrimary : theme.color.iconSecondary; return ( { if (item.key === 'inline-code') { setCodeBlockActive(false); } // Code-block active state is driven by native // onActiveStylesChange — don't eagerly set it here // since the button is a toggle. item.onPress(inputRef, selectionPosition, inputTextRef, setLinkText, setLinkUrl, setShowLinkModal); }} > ); })} )} )} {/* Separator line - only show when there's footer content (matches Figma: Seprator) */} {(FooterView || CustomViewFooter) && } {FooterView ? ( ) : CustomViewFooter ? ( typeof CustomViewFooter === 'function' ? ( ) : ( CustomViewFooter ) ) : null} {/* Link insertion popup (reuses CometChatLinkConfirmPopup in insert mode) */} setShowLinkModal(false)} onEdit={(newUrl, newText) => { inputRef.current?.insertLink(newUrl, newText); pendingLinksRef.current.set(newText, newUrl); setShowLinkModal(false); }} onRemove={() => setShowLinkModal(false)} /> {/* Link tap edit/remove dialog (shown when user taps an existing link) */} { setLinkTapData(null); setLinkEditMode(false); inputRef.current?.focus(); }} onEdit={(newUrl, newText) => { if (linkTapData) { (inputRef.current as any)?.updateLink( linkTapData.location, linkTapData.length, newUrl, newText, ); } setLinkTapData(null); setLinkEditMode(false); }} onRemove={() => { if (linkTapData) { (inputRef.current as any)?.removeLink(linkTapData.location, linkTapData.length); } setLinkTapData(null); setLinkEditMode(false); }} /> ); } );