@cometchat/chat-uikit-react-native
Version:
Ready-to-use Chat UI Components for React Native
554 lines (481 loc) • 16.8 kB
text/typescript
/**
* Shared helper functions for CometChat message composers.
* These utilities are used by both CometChatMessageComposer and CometChatSingleLineMessageComposer
* to ensure consistent behavior and maintainability.
*
* DESIGN PRINCIPLE: All composer functionality should be extracted into reusable helpers
* to ensure feature parity and plug-and-play compatibility between composers.
*
* @module composerHelpers
*/
//@ts-ignore
import { CometChat } from '@cometchat/chat-sdk-react-native';
// ============================================================================
// AGENTIC USER HELPERS
// ============================================================================
/**
* Check if a user is an agentic user (AI agent).
* 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
*
* @param user - The CometChat user object to check
* @returns True if the user has role '@agentic'
*/
export const isAgenticUser = (user?: CometChat.User): boolean => {
return user?.getRole?.() === '@agentic';
};
/**
* Derive button visibility based on agentic user status and prop value.
* When user is agentic, buttons are automatically hidden regardless of prop values.
*
* @param isAgentic - Whether the user is an agentic user
* @param propValue - The prop value for hiding the button
* @returns True if the button should be hidden
*/
export const deriveHideButton = (isAgentic: boolean, propValue?: boolean): boolean => {
return isAgentic ? true : (propValue ?? false);
};
/**
* Derive feature disabled state based on agentic user status and prop value.
* When user is agentic, features are automatically disabled regardless of prop values.
*
* @param isAgentic - Whether the user is an agentic user
* @param propValue - The prop value for disabling the feature
* @returns True if the feature should be disabled
*/
export const deriveDisableFeature = (isAgentic: boolean, propValue?: boolean): boolean => {
return isAgentic ? true : (propValue ?? false);
};
/**
* Configuration for agentic user send button delay.
*/
export interface AgenticSendDelayConfig {
/** Whether the user is agentic */
isAgentic: boolean;
/** Current delay state setter */
setDelayState: (value: boolean) => void;
/** Timer ref for cleanup */
timerRef: React.MutableRefObject<NodeJS.Timeout | null>;
/** Delay duration in milliseconds (default: 1000) */
delayMs?: number;
}
/**
* Apply send button delay for agentic users.
* Prevents rapid-fire messages to AI agents by disabling send button for 1 second.
*
* @param config - Configuration for the delay
*/
export const applyAgenticSendDelay = (config: AgenticSendDelayConfig): void => {
const { isAgentic, setDelayState, timerRef, delayMs = 1000 } = config;
if (!isAgentic) return;
setDelayState(true);
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
setDelayState(false);
}, delayMs);
};
/**
* Configuration for tracking parent message ID for agentic users.
*/
export interface AgenticParentMessageConfig {
/** Whether the user is agentic */
isAgentic: boolean;
/** Parent message ID from props (if provided) */
parentMessageIdProp?: number;
/** Ref to store tracked parent message ID */
parentMessageIdRef: React.MutableRefObject<number | null>;
/** The sent message object */
message: any;
}
/**
* Track parent message ID for agentic users.
* For the first message sent to an agentic user without a parentMessageId prop,
* stores the message ID as the parent for subsequent messages.
*
* @param config - Configuration for parent message tracking
* @returns The message ID if it was stored, null otherwise
*/
export const trackAgenticParentMessageId = (config: AgenticParentMessageConfig): number | null => {
const { isAgentic, parentMessageIdProp, parentMessageIdRef, message } = config;
if (!isAgentic || parentMessageIdProp || !message?.getId) {
return null;
}
const messageId = typeof message.getId() === 'string'
? Number(message.getId())
: message.getId();
if (!isNaN(messageId) && !parentMessageIdRef.current) {
parentMessageIdRef.current = messageId;
return messageId;
}
return null;
};
/**
* Get the parent message ID to use for a message.
* Prioritizes prop value, then tracked value for agentic users.
*
* @param parentMessageIdProp - Parent message ID from props
* @param isAgentic - Whether the user is agentic
* @param parentMessageIdRef - Ref containing tracked parent message ID
* @returns The parent message ID to use, or null if none
*/
export const getParentMessageId = (
parentMessageIdProp?: number,
isAgentic?: boolean,
parentMessageIdRef?: React.MutableRefObject<number | null>
): number | null => {
if (parentMessageIdProp) {
return parentMessageIdProp;
}
if (isAgentic && parentMessageIdRef?.current) {
return parentMessageIdRef.current;
}
return null;
};
// ============================================================================
// REPLY MESSAGE HELPERS
// ============================================================================
/**
* Interface for reply message state used by composers.
*/
export interface ReplyMessageState {
message: any;
mode: string;
}
/**
* Interface for message preview state used by composers (edit mode).
*/
export interface MessagePreviewState {
message: any;
mode: string;
}
/**
* Get the reply message ID from a reply message state.
*
* @param replyMessage - The reply message state
* @returns The message ID or null if not available
*/
export const getReplyMessageId = (replyMessage: ReplyMessageState | null): number | null => {
return replyMessage?.message?.getId?.() ?? null;
};
/**
* Check if a reply message is active.
*
* @param replyMessage - The reply message state
* @returns True if there is an active reply message
*/
export const hasActiveReply = (replyMessage: ReplyMessageState | null): boolean => {
return replyMessage !== null && replyMessage.message !== null;
};
/**
* Set quoted message on a CometChat message for reply functionality.
* Uses SDK's built-in quoted message functionality with error handling.
*
* @param message - The message to set the quoted message on (TextMessage or MediaMessage)
* @param replyMessage - The original message being replied to
* @param replyMessageId - The ID of the original message
* @returns True if quoted message was set successfully, false otherwise
*/
export const setQuotedMessageSafe = (
message: CometChat.TextMessage | CometChat.MediaMessage,
replyMessage: any,
replyMessageId: number | string
): boolean => {
try {
if (typeof (message as any).setQuotedMessage === 'function') {
(message as any).setQuotedMessage(replyMessage);
}
if (typeof (message as any).setQuotedMessageId === 'function') {
(message as any).setQuotedMessageId(replyMessageId);
}
return true;
} catch (error) {
// Silently handle errors - quoted message is optional
return false;
}
};
/**
* Configuration for handling reply message on send.
*/
export interface ReplyMessageSendConfig {
/** The message being sent */
message: CometChat.TextMessage | CometChat.MediaMessage;
/** Current reply message state */
replyMessage: ReplyMessageState | null;
/** Callback to clear reply state after handling */
clearReplyState?: () => void;
}
/**
* Handle reply message when sending a message.
* Sets quoted message if reply is active and optionally clears reply state.
*
* @param config - Configuration for reply handling
* @returns True if quoted message was set, false otherwise
*/
export const handleReplyOnSend = (config: ReplyMessageSendConfig): boolean => {
const { message, replyMessage, clearReplyState } = config;
const replyMessageId = getReplyMessageId(replyMessage);
if (replyMessageId && replyMessage?.message) {
const success = setQuotedMessageSafe(message, replyMessage.message, replyMessageId);
if (clearReplyState) {
clearReplyState();
}
return success;
}
return false;
};
// ============================================================================
// SEND BUTTON STATE HELPERS
// ============================================================================
/**
* Configuration for determining send button disabled state.
*/
export interface SendButtonStateConfig {
/** Current input text */
inputText: string;
/** Whether streaming is active (for agentic users) */
isStreaming?: boolean;
/** Whether send button delay is active (for agentic users) */
isSendButtonDelayed?: boolean;
/** Whether in edit mode */
isEditMode?: boolean;
}
/**
* Determine if send button should be disabled.
* Considers input text, streaming state, and delay state.
*
* @param config - Configuration for send button state
* @returns True if send button should be disabled
*/
export const isSendButtonDisabled = (config: SendButtonStateConfig): boolean => {
const { inputText, isStreaming = false, isSendButtonDelayed = false, isEditMode = false } = config;
// In edit mode, only check if text is empty
if (isEditMode) {
return inputText.trim().length === 0;
}
return isStreaming || inputText.trim().length === 0 || isSendButtonDelayed;
};
/**
* Get send button tint color based on disabled state.
*
* @param isDisabled - Whether the button is disabled
* @param activeTint - Tint color when active
* @param inactiveTint - Tint color when inactive
* @returns The appropriate tint color
*/
export const getSendButtonTint = (
isDisabled: boolean,
activeTint: string,
inactiveTint: string
): string => {
return isDisabled ? inactiveTint : activeTint;
};
// ============================================================================
// MESSAGE CREATION HELPERS
// ============================================================================
/**
* Configuration for creating a text message.
*/
export interface CreateTextMessageConfig {
/** Receiver ID (user UID or group GUID) */
receiverId: string;
/** Message text */
text: string;
/** Receiver type ('user' or 'group') */
receiverType: string;
/** Logged in user */
sender: CometChat.User;
/** Receiver (user or group) */
receiver: CometChat.User | CometChat.Group;
/** Message unique ID */
muid: string;
/** Parent message ID for threaded messages */
parentMessageId?: number | null;
}
/**
* Create a text message with common configuration.
*
* @param config - Configuration for the text message
* @returns Configured TextMessage object
*/
export const createTextMessage = (config: CreateTextMessageConfig): CometChat.TextMessage => {
const { receiverId, text, receiverType, sender, receiver, muid, parentMessageId } = config;
const textMessage = new CometChat.TextMessage(receiverId, text, receiverType);
textMessage.setSender(sender);
textMessage.setReceiver(receiver);
textMessage.setMuid(muid);
if (parentMessageId) {
textMessage.setParentMessageId(parentMessageId);
}
return textMessage;
};
/**
* Configuration for creating a media message.
*/
export interface CreateMediaMessageConfig {
/** Receiver ID (user UID or group GUID) */
receiverId: string;
/** Media file */
file: any;
/** Media type (image, video, audio, file) */
mediaType: string;
/** Receiver type ('user' or 'group') */
receiverType: string;
/** Logged in user */
sender: CometChat.User;
/** Receiver (user or group) */
receiver: CometChat.User | CometChat.Group;
/** Message unique ID */
muid: string;
/** Parent message ID for threaded messages */
parentMessageId?: number | null;
}
/**
* Create a media message with common configuration.
*
* @param config - Configuration for the media message
* @returns Configured MediaMessage object
*/
export const createMediaMessage = (config: CreateMediaMessageConfig): CometChat.MediaMessage => {
const { receiverId, file, mediaType, receiverType, sender, receiver, muid, parentMessageId } = config;
const mediaMessage = new CometChat.MediaMessage(receiverId, file, mediaType, receiverType);
mediaMessage.setSender(sender);
mediaMessage.setReceiver(receiver);
mediaMessage.setMuid(muid);
if (parentMessageId) {
mediaMessage.setParentMessageId(parentMessageId);
}
return mediaMessage;
};
// ============================================================================
// MENTION HELPERS
// ============================================================================
/**
* Interface for mention overlap detection.
*/
export interface MentionOverlap {
key: string;
value: any;
start: number;
end: number;
}
/**
* Parse a mention key string into start/end positions.
* Mention keys are in format "start_end".
*
* @param key - The mention key string
* @returns Object with start and end positions, or undefined if invalid
*/
export const parseMentionKey = (key: string): { start: number; end: number } | undefined => {
const [startStr, endStr] = key.split('_');
const start = Number(startStr);
const end = Number(endStr);
const isValid = Number.isFinite(start) && Number.isFinite(end);
if (!isValid && __DEV__) {
console.warn(`Invalid mention key: "${key}" (expected "start_end")`);
}
return isValid ? { start, end } : undefined;
};
/**
* Calculate the deletion range based on selection and deletion length.
*
* @param selection - Current selection with start and end positions
* @param deletionLength - Number of characters being deleted
* @returns The range of text being deleted
*/
export const calcDeletionRange = (
selection: { start: number; end: number },
deletionLength: number
): { start: number; end: number } => {
return selection.start === selection.end
? { start: Math.max(0, selection.start - deletionLength), end: selection.start }
: { start: selection.start, end: selection.end };
};
/**
* Collect all mentions that overlap with a deletion range.
*
* @param range - The deletion range
* @param mentionMap - Map of mention keys to mention data
* @returns Array of overlapping mentions sorted by start position
*/
export const collectOverlappingMentions = <T>(
range: { start: number; end: number },
mentionMap: Map<string, T>
): MentionOverlap[] => {
const overlaps: MentionOverlap[] = [];
mentionMap.forEach((value, key) => {
const mentionRange = parseMentionKey(key);
if (!mentionRange) return;
const { start, end } = mentionRange;
if (range.start < end && range.end > start) {
overlaps.push({ key, value, start, end });
}
});
overlaps.sort((a, b) => a.start - b.start);
return overlaps;
};
/**
* Shift remaining mention keys after deletion.
* Updates mention positions to account for removed text.
*
* @param map - The mention map to update
* @param shiftStart - Position after which to shift
* @param delta - Amount to shift (negative for deletion)
*/
export const shiftRemainingMentionKeys = <T>(
map: Map<string, T>,
shiftStart: number,
delta: number
): void => {
if (delta === 0) return;
const shifted = new Map<string, T>();
map.forEach((val, key) => {
const range = parseMentionKey(key);
if (!range) return;
const { start, end } = range;
if (start > shiftStart) {
shifted.set(`${start + delta}_${end + delta}`, val);
} else {
shifted.set(key, val);
}
});
map.clear();
shifted.forEach((v, k) => map.set(k, v));
};
// ============================================================================
// UTILITY HELPERS
// ============================================================================
/**
* Generate a unique message ID using Unix timestamp in milliseconds.
*
* @returns Unique message ID string
*/
export const generateMessageMuid = (): string => {
return String(Date.now());
};
/**
* Check if text is empty or contains only whitespace.
*
* @param text - The text to check
* @returns True if text is empty or whitespace only
*/
export const isTextEmpty = (text: string): boolean => {
return text.trim().length === 0;
};
/**
* Safely get message ID from a message object.
* Handles both string and number ID types.
*
* @param message - The message object
* @returns The message ID as a number, or null if not available
*/
export const getMessageIdSafe = (message: any): number | null => {
if (!message?.getId) return null;
const id = message.getId();
const numId = typeof id === 'string' ? Number(id) : id;
return isNaN(numId) ? null : numId;
};