import { debounce, throttle } from 'es-toolkit/function'; import { ConversationType, getChannel } from 'tuikit-atomicx-react/chat'; import { useStore } from 'zustand'; import { createStore } from 'zustand/vanilla'; import { MessageContentType } from '../types/messageInput'; import { transformTextWithEmojiNameToKey, transformTextWithEmojiKeyToName } from '../utils'; import { createTypingCustomData } from '../utils/chatTypingStatus'; import { deleteLocalConversationDraft } from '../utils/conversationDraftStorage'; import { convertInputContentToEditorNode, convertEditorContent, trimInputContent, blobUrlToFile, } from '../utils/messageInput'; import type { InputContent } from '../types/messageInput'; import type { Editor } from '@tiptap/react'; import type { MessageInfo, SendMessagePayload, SendMessageInputOption, OfflinePushInfo } from 'tuikit-atomicx-react/chat'; // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- interface ChatUIState { // Quote quotedMessage: MessageInfo | undefined; // Editor editor: Editor | null; pendingInputCommand: { id: number; type: 'set' | 'insert' | 'focus' | 'blur'; content?: string | InputContent[]; focus?: boolean; } | null; // MessageList UI highlightMessageIDSet: Set; recalledMessageIDSet: Set; listMode: 'latest' | 'fragment'; enableReadReceipt: boolean | undefined; isPeerTyping: boolean; // Locate message (Search -> MessageList) pendingLocateMessage: MessageInfo | null; // Forward forwardMessages: MessageInfo[]; isForwardModalOpen: boolean; } // --------------------------------------------------------------------------- // Actions // --------------------------------------------------------------------------- interface ChatUIActions { // Quote setQuotedMessage: (message: MessageInfo | undefined) => void; clearQuotedMessage: () => void; // Editor setEditorInstance: (editor: Editor | null) => void; setInputContent: (content: string | InputContent[], focus?: boolean) => void; insertInputContent: (content: string | InputContent[], focus?: boolean) => void; focusInput: () => void; blurInput: () => void; clearPendingInputCommand: (id: number) => void; // MessageList UI highlightMessage: (params: { messageID: string; duration: number }) => void; setListMode: (mode: 'latest' | 'fragment') => void; setEnableReadReceipt: (value: boolean | undefined) => void; setIsPeerTyping: (value: boolean) => void; // Locate message setPendingLocateMessage: (info: MessageInfo | null) => void; // Forward openForwardModal: (messages: MessageInfo[]) => void; closeForwardModal: () => void; // Send message (editor → serialize → Channel.sendMessage) sendInputMessage: (setOfflinePushInfo?: (payload: SendMessagePayload) => OfflinePushInfo | undefined) => Promise; enterTyping: (channelID?: string) => Promise; leaveTyping: (channelID?: string) => Promise; } // --------------------------------------------------------------------------- // Initial state // --------------------------------------------------------------------------- const initialState: ChatUIState = { quotedMessage: undefined, editor: null, pendingInputCommand: null, highlightMessageIDSet: new Set(), recalledMessageIDSet: new Set(), listMode: 'latest', enableReadReceipt: undefined, isPeerTyping: false, pendingLocateMessage: null, forwardMessages: [], isForwardModalOpen: false, }; const TYPING_AUTO_CLEAR_DURATION = 5 * 1000; const TYPING_ENTER_THROTTLE_DURATION = 5 * 1000; const TYPING_RECENT_RECEIVED_MESSAGE_DURATION = 30 * 1000; let inputCommandID = 0; function getNextInputCommandID() { inputCommandID += 1; return inputCommandID; } function hasRecentPeerMessage(messageList: MessageInfo[]): boolean { const latestPeerMessage = [...messageList].reverse().find(message => !message.isSentBySelf); const latestPeerMessageTime = latestPeerMessage?.timestamp?.getTime(); return typeof latestPeerMessageTime === 'number' && Date.now() - latestPeerMessageTime <= TYPING_RECENT_RECEIVED_MESSAGE_DURATION; } // --------------------------------------------------------------------------- // Send message helpers // --------------------------------------------------------------------------- function normalizeMessageInput(message: string | InputContent[]): string | InputContent[] | null { if (typeof message === 'string') { const trimmed = message.trim(); return trimmed || null; } const trimmed = trimInputContent(message); return trimmed.length > 0 ? trimmed : null; } type SendFn = (payload: SendMessagePayload, option?: SendMessageInputOption) => Promise; async function sendStructuredContent( content: InputContent[], baseOption: SendMessageInputOption, sendMessage: SendFn, setOfflinePushInfo?: (payload: SendMessagePayload) => OfflinePushInfo | undefined, ) { const textBuffer: string[] = []; const mentionedUserIDs: string[] = []; /** Merge offlinePushInfo from resolver into option for a given payload */ const buildOption = (payload: SendMessagePayload, extra?: Partial): SendMessageInputOption => { const offlinePushInfo = setOfflinePushInfo?.(payload); return { ...baseOption, ...extra, ...(offlinePushInfo ? { offlinePushInfo } : {}), }; }; const flushTextBuffer = async () => { if (textBuffer.length === 0) { return; } const textWithEmojiName = transformTextWithEmojiKeyToName(textBuffer.join('')); const text = transformTextWithEmojiNameToKey(textWithEmojiName); textBuffer.length = 0; const payload: SendMessagePayload = { type: 'textMessage', text }; await sendMessage(payload, buildOption(payload, { atUserList: mentionedUserIDs.length > 0 ? [...mentionedUserIDs] : [], })); mentionedUserIDs.length = 0; }; for (const item of content) { switch (item.type) { case MessageContentType.TEXT: textBuffer.push(item.content); break; case MessageContentType.EMOJI: textBuffer.push(item.content.key); break; case MessageContentType.MENTION: mentionedUserIDs.push(item.content.id); textBuffer.push(item.content.mentionSuggestionChar + item.content.label); break; case MessageContentType.IMAGE: await flushTextBuffer(); if (item.content instanceof File) { const payload: SendMessagePayload = { type: 'imageMessage', file: item.content }; await sendMessage(payload, buildOption(payload)); } else { const file = await blobUrlToFile(item.content); if (file) { const payload: SendMessagePayload = { type: 'imageMessage', file }; await sendMessage(payload, buildOption(payload)); } } break; case MessageContentType.VIDEO: await flushTextBuffer(); { const payload: SendMessagePayload = { type: 'videoMessage', file: item.content, duration: 0 }; await sendMessage(payload, buildOption(payload)); } break; case MessageContentType.FILE: await flushTextBuffer(); { const payload: SendMessagePayload = { type: 'fileMessage', file: item.content.file }; await sendMessage(payload, buildOption(payload)); } break; default: console.warn(`[useChatUIState] Unknown message type: ${(item as any)?.type}, skipping`); break; } } await flushTextBuffer(); } // --------------------------------------------------------------------------- // Store // --------------------------------------------------------------------------- function createChatUIStore(channelKey: string) { let hasEnteredTyping = false; const sendTypingStart = throttle(async (targetChannelID?: string) => { const channel = getChannel(targetChannelID ?? channelKey); const snapshot = channel.getSnapshot(); const conversationID = snapshot.activeConversationID; if (!conversationID || snapshot.activeConversation?.type !== ConversationType.C2C) { return; } if (!hasRecentPeerMessage(snapshot.messageList)) { return; } await snapshot.sendMessage( { type: 'customMessage', customData: createTypingCustomData(true), }, { onlineUserOnly: true }, ); hasEnteredTyping = true; }, TYPING_ENTER_THROTTLE_DURATION, { edges: ['leading'] }); return createStore()((set, get) => ({ ...initialState, // ---- Quote ---- setQuotedMessage: message => set({ quotedMessage: message }), clearQuotedMessage: () => set({ quotedMessage: undefined }), // ---- Editor ---- setEditorInstance: (newEditor) => { const { editor: prev } = get(); if (prev) { prev.destroy(); } set({ editor: newEditor }); }, setInputContent: (content, focus = true) => { const { editor } = get(); if (!editor) { set({ pendingInputCommand: { id: getNextInputCommandID(), type: 'set', content, focus, }, }); return; } if (typeof content === 'string') { editor.commands.setContent(content, true); } else { editor.commands.setContent(content.map(convertInputContentToEditorNode), true); } if (focus) { editor.commands.focus(); } }, insertInputContent: (content, focus = true) => { const { editor } = get(); if (!editor) { set({ pendingInputCommand: { id: getNextInputCommandID(), type: 'insert', content, focus, }, }); return; } if (typeof content === 'string') { editor.commands.insertContent(content); } else { editor.commands.insertContent(content.map(convertInputContentToEditorNode)); } if (focus) { editor.commands.focus(); } }, focusInput: () => { const { editor } = get(); if (editor) { editor.commands.focus(); return; } if (get().pendingInputCommand?.type === 'insert') { return; } set({ pendingInputCommand: { id: getNextInputCommandID(), type: 'focus', }, }); }, blurInput: () => { const { editor } = get(); if (editor) { editor.commands.blur(); return; } set({ pendingInputCommand: { id: getNextInputCommandID(), type: 'blur', }, }); }, clearPendingInputCommand: (id) => { if (get().pendingInputCommand?.id === id) { set({ pendingInputCommand: null }); } }, // ---- MessageList UI ---- highlightMessage: ({ messageID, duration }) => { const next = new Set(get().highlightMessageIDSet); next.add(messageID); set({ highlightMessageIDSet: next }); setTimeout(() => { const after = new Set(get().highlightMessageIDSet); after.delete(messageID); set({ highlightMessageIDSet: after }); }, duration); }, setListMode: mode => set({ listMode: mode }), setEnableReadReceipt: value => set({ enableReadReceipt: value }), setIsPeerTyping: (() => { const clearPeerTyping = debounce(() => { set({ isPeerTyping: false }); }, TYPING_AUTO_CLEAR_DURATION); return (value: boolean) => { clearPeerTyping.cancel(); set({ isPeerTyping: value }); if (value) { clearPeerTyping(); } }; })(), // ---- Locate message ---- setPendingLocateMessage: info => set({ pendingLocateMessage: info }), // ---- Forward ---- openForwardModal: messages => set({ forwardMessages: messages, isForwardModalOpen: true }), closeForwardModal: () => set({ forwardMessages: [], isForwardModalOpen: false }), // ---- Send message ---- sendInputMessage: async (setOfflinePushInfo?: (payload: SendMessagePayload) => OfflinePushInfo | undefined) => { const channel = getChannel(channelKey); const snapshot = channel.getSnapshot(); const conversationID = snapshot.activeConversationID; const { quotedMessage, enableReadReceipt } = get(); // Serialize from editor const { editor: editorInstance } = get(); let rawInput: string | InputContent[] | undefined; if (editorInstance) { rawInput = convertEditorContent(editorInstance.getJSON()); } // Normalize const messageToSend = normalizeMessageInput(rawInput ?? ''); if (!messageToSend) { return; } const baseOption: SendMessageInputOption = { needReadReceipt: enableReadReceipt || false, quotedMessage: quotedMessage || undefined, }; const clearConversationDraft = async () => { if (!conversationID) { return; } await deleteLocalConversationDraft(conversationID).catch(() => {}); await snapshot.setConversationDraft(conversationID, '').catch(() => {}); }; const clearQuotedMessageAfterSend = () => { if (quotedMessage) { set({ quotedMessage: undefined }); } }; // Clear editor content before async sends to avoid double-send on re-trigger editorInstance?.commands.setContent('', true); // Handle plain text if (typeof messageToSend === 'string') { const payload: SendMessagePayload = { type: 'textMessage', text: transformTextWithEmojiNameToKey(messageToSend) }; const offlinePushInfo = setOfflinePushInfo?.(payload); await snapshot.sendMessage(payload, { ...baseOption, ...(offlinePushInfo ? { offlinePushInfo } : {}), }); clearQuotedMessageAfterSend(); clearConversationDraft(); return; } // Handle structured content await sendStructuredContent(messageToSend, baseOption, snapshot.sendMessage, setOfflinePushInfo); clearQuotedMessageAfterSend(); clearConversationDraft(); }, enterTyping: async (channelID?: string) => { await sendTypingStart(channelID); }, leaveTyping: async (channelID?: string) => { const channel = getChannel(channelID ?? channelKey); const snapshot = channel.getSnapshot(); const conversationID = snapshot.activeConversationID; if (!conversationID || !hasEnteredTyping) { return; } await snapshot.sendMessage( { type: 'customMessage', customData: createTypingCustomData(false), }, { onlineUserOnly: true }, ); hasEnteredTyping = false; sendTypingStart.cancel(); }, })); } const chatUIStoreMap = new Map>(); const chatUIStore = createChatUIStore('default'); chatUIStoreMap.set('default', chatUIStore); // --------------------------------------------------------------------------- // Hook // --------------------------------------------------------------------------- function useChatUIState(channelID = 'default'): ChatUIState & ChatUIActions { if (!chatUIStoreMap.has(channelID)) { chatUIStoreMap.set(channelID, createChatUIStore(channelID)); } const store = chatUIStoreMap.get(channelID) ?? chatUIStore; return useStore(store); } export { useChatUIState, chatUIStore, }; export type { ChatUIState, ChatUIActions, };