import { useRef, useEffect, useCallback } from 'react'; import throttle from 'es-toolkit/compat/throttle'; import { useChatContext } from 'tuikit-atomicx-react/chat'; import type { MessageInfo } from 'tuikit-atomicx-react/chat'; interface UseReadReceiptOptions { /** * Whether to enable read receipt feature */ enabled: boolean; /** * Selector or DOM reference for the message list container */ containerSelector: string | HTMLElement; /** * Selector for message elements, default is '[data-message-id]' */ messageSelector?: string; /** * Intersection ratio threshold, default is 0.5 (50%) */ intersectionThreshold?: number; /** * Throttle delay for batch sending, default is 1000ms */ delay?: number; /** * Function to extract message ID from DOM element */ getMessageIDFromDom: (dom: Element) => string; /** * Function to decide if a message should send read receipt */ shouldSendReadReceipt?: (message: MessageInfo) => boolean; } export function useReadReceipt({ enabled, containerSelector, messageSelector = '[data-message-id]', getMessageIDFromDom, shouldSendReadReceipt = message => !message.isSentBySelf, intersectionThreshold = 0.5, delay = 1000, }: UseReadReceiptOptions) { const { messageList, sendMessageReadReceipts } = useChatContext(); // IntersectionObserver instance const observerRef = useRef(null); // MutationObserver instance — watches for new message elements added to the container const mutationObserverRef = useRef(null); // Message objects pending to send read receipt const pendingReadReceiptMessages = useRef>(new Map()); // Message IDs that have already been processed const processedMessageIds = useRef>(new Set()); // Keep messageList in a ref so throttled callback always reads latest const messageListRef = useRef(messageList); messageListRef.current = messageList; // Throttled function to batch send read receipts const sendBatchReadReceipts = useCallback( throttle(() => { if (pendingReadReceiptMessages.current.size === 0) { return; } const messagesToSend = Array.from(pendingReadReceiptMessages.current.values()); sendMessageReadReceipts(messagesToSend) .catch((error) => { console.error("[ReadReceipt]", error); }); pendingReadReceiptMessages.current.clear(); }, delay, { leading: false, trailing: true }), [delay, sendMessageReadReceipts], ); // Core logic to check and process a message for read receipt const checkMessageAndSendReadReceipt = useCallback((messageID: string) => { if (processedMessageIds.current.has(messageID)) { return false; } const message = messageListRef.current.find(m => m.msgID === messageID); if (!message) { return false; } const shouldSend = shouldSendReadReceipt(message); if (shouldSend) { pendingReadReceiptMessages.current.set(messageID, message); processedMessageIds.current.add(messageID); sendBatchReadReceipts(); return true; } return false; }, [shouldSendReadReceipt, sendBatchReadReceipts]); // Try to register a single DOM element with the IntersectionObserver. // Skips elements that are already processed or do not need a read receipt. const tryObserveElement = useCallback((element: Element) => { if (!observerRef.current) { return; } const messageID = getMessageIDFromDom(element); if (!messageID || processedMessageIds.current.has(messageID)) { return; } const message = messageListRef.current.find(m => m.msgID === messageID); if (message && shouldSendReadReceipt(message)) { observerRef.current.observe(element); } }, [getMessageIDFromDom, shouldSendReadReceipt]); // Scan the container and register all eligible message elements. // Called once after the initial render is settled. const observeAllExistingElements = useCallback(() => { if (!observerRef.current || !enabled) { return; } const container = typeof containerSelector === 'string' ? document.querySelector(containerSelector) : containerSelector; if (!container) { return; } const messageElements = container.querySelectorAll(messageSelector); messageElements.forEach((element) => { const messageID = getMessageIDFromDom(element); if (!messageID || processedMessageIds.current.has(messageID)) { return; } const message = messageListRef.current.find(m => m.msgID === messageID); if (message && shouldSendReadReceipt(message)) { observerRef.current?.observe(element); } }); }, [enabled, containerSelector, messageSelector, getMessageIDFromDom, shouldSendReadReceipt]); // Initialize IntersectionObserver + MutationObserver useEffect(() => { if (!enabled) { return; } const container = typeof containerSelector === 'string' ? document.querySelector(containerSelector) : containerSelector; if (!container) { return; } // IntersectionObserver: fires when a registered message element enters the viewport observerRef.current = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting && entry.intersectionRatio >= intersectionThreshold) { const messageID = getMessageIDFromDom(entry.target); if (messageID) { checkMessageAndSendReadReceipt(messageID); } // Unobserve immediately — each message only needs to be seen once observerRef.current?.unobserve(entry.target); } }); }, { threshold: intersectionThreshold, root: container, }, ); // MutationObserver: fires whenever child nodes are added to the container subtree. // This is the single source of truth for "a new message element appeared in the DOM". // It covers every scenario — initial load, new messages arriving, back-to-latest — without // requiring callers to manually invoke observeMessageList at each of those sites. mutationObserverRef.current = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type !== 'childList') { continue; } mutation.addedNodes.forEach((node) => { if (!(node instanceof Element)) { return; } // The added node itself might be a message element if (node.matches(messageSelector)) { tryObserveElement(node); } // Or it might be a wrapper that contains message elements node.querySelectorAll(messageSelector).forEach(tryObserveElement); }); } }); mutationObserverRef.current.observe(container, { childList: true, subtree: true }); // Also scan whatever is already in the DOM at setup time observeAllExistingElements(); // eslint-disable-next-line consistent-return return () => { observerRef.current?.disconnect(); observerRef.current = null; mutationObserverRef.current?.disconnect(); mutationObserverRef.current = null; sendBatchReadReceipts.flush(); }; }, [enabled, containerSelector, getMessageIDFromDom, intersectionThreshold, checkMessageAndSendReadReceipt, sendBatchReadReceipts, tryObserveElement, observeAllExistingElements]); // Public API — callers only need to call resetProcessedMessages when switching conversations return { resetProcessedMessages: () => { processedMessageIds.current.clear(); pendingReadReceiptMessages.current.clear(); }, }; }