/** * TipTap Mention Extension Configuration * @description Enables @ mention functionality in the editor * Data management is handled by MentionSuggestion component via GroupSettingState */ import React from 'react'; import Mention from '@tiptap/extension-mention'; import { flushSync } from 'react-dom'; import { createRoot } from 'react-dom/client'; import tippy from 'tippy.js'; import { ConversationType, GroupType, getChannel, GroupStoreRaw as GroupStore } from 'tuikit-atomicx-react/chat'; import MentionSuggestion from './MentionSuggestion'; import type { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion'; import type { Instance as TippyInstance } from 'tippy.js'; interface MentionComponentRef { onKeyDown: (props: SuggestionKeyDownProps) => boolean; } /** * Create Mention extension for @ member functionality * @param isMentionEnabled Determines whether new mentions can be created * @returns Configured Mention extension */ export function createMentionExtension(isMentionEnabled: () => boolean = () => true) { return Mention.configure({ deleteTriggerWithBackspace: true, HTMLAttributes: { class: 'uikit-message-input-mention-tag', }, suggestion: { char: '@', // Allow '@' to trigger after any character, not only start-of-line or whitespace. allowedPrefixes: null, allow: () => isMentionEnabled(), // Items are now managed inside the component via GroupSettingState items: () => [], render: () => { let componentRef: MentionComponentRef | null = null; let popup: TippyInstance[] | null = null; let reactRoot: ReturnType | null = null; let containerElement: HTMLDivElement | null = null; // Stable ref callback - created once and reused const stableRefCallback = (ref: MentionComponentRef) => { componentRef = ref; }; const destroy = () => { if (popup) { popup[0]?.destroy(); popup = null; } if (reactRoot) { reactRoot.unmount(); reactRoot = null; } if (containerElement?.parentNode) { containerElement.parentNode.removeChild(containerElement); containerElement = null; } componentRef = null; }; // Callback for component to control popup visibility (hide/show, not destroy) const handleVisibilityChange = (visible: boolean) => { if (popup?.[0]) { if (visible) { popup[0].show(); } else { popup[0].hide(); } } }; // Render function - encapsulates rendering logic const renderComponent = (props: SuggestionProps) => { // Use flushSync to ensure synchronous rendering (avoid React 18 batching issues) flushSync(() => { if (!reactRoot || !containerElement) { return; } reactRoot.render( React.createElement(MentionSuggestion, { ...props, ref: stableRefCallback, onVisibilityChange: handleVisibilityChange, }), ); }); }; return { onStart: (props: SuggestionProps) => { const { activeConversation, } = getChannel().getSnapshot(); // Derive group info from GroupStore const groupID = activeConversation?.conversationID?.startsWith('GROUP') ? activeConversation.conversationID.slice(5) : undefined; const groupInfo = groupID ? GroupStore.getState().joinedGroupList.find(g => g.groupID === groupID) : undefined; // Only render in group chats (exclude AVChatRoom) // Use memberCount from group metadata (available without loading member list) if ( activeConversation?.type === ConversationType.Group && groupInfo?.memberCount && groupInfo.memberCount > 1 && groupInfo.groupType !== GroupType.AVChatRoom ) { // Create container element containerElement = document.createElement('div'); containerElement.className = 'uikit-mention-suggestion-container'; // Create React root // Using createRoot (React 18+) instead of ReactRenderer to ensure proper rendering reactRoot = createRoot(containerElement); // Initial render with flushSync for synchronous rendering renderComponent(props); if (!props.clientRect || !containerElement) { return; } // Wait for React to render before creating tippy popup requestAnimationFrame(() => { if (!containerElement) { return; } popup = tippy('body', { getReferenceClientRect: props.clientRect as () => DOMRect, appendTo: () => document.body, content: containerElement, showOnCreate: true, interactive: true, trigger: 'manual', placement: 'top-start', }); }); } }, onUpdate(props: SuggestionProps) { // Re-render with updated props using the stable ref callback // flushSync ensures synchronous update without recreating ref closures renderComponent(props); if (!props.clientRect) { return; } popup?.[0]?.setProps({ getReferenceClientRect: props.clientRect as () => DOMRect, }); }, onKeyDown(props: SuggestionKeyDownProps) { if (props.event.key === 'Escape') { destroy(); return true; } return componentRef?.onKeyDown?.(props) || false; }, onExit() { destroy(); }, }; }, }, }); }