import { RefObject, useEffect, useRef } from 'react'; import { IMedia } from '../types'; import { useStore } from '../services/store'; /** * Minimum fraction of the thumbnail that must be visible in the viewport * before it is counted as "viewed". * * 0.5 (50 %) is intentional: carousel shoulder thumbnails are only partially * visible (typically < 30 %), so they are excluded automatically. */ const VISIBILITY_THRESHOLD = 0.5; /** * Module-level registry that prevents a `thumbnailViewed` event from firing * more than once for the same media within the same widget instance. * * Keyed by widgetId → Set of media IDs that have already fired. * This also handles infinite-carousel clones, which share the same media.id * as the real slide. */ const viewedByWidget = new Map>(); function getOrCreateViewedSet(widgetId: string): Set { if (!viewedByWidget.has(widgetId)) { viewedByWidget.set(widgetId, new Set()); } return viewedByWidget.get(widgetId)!; } /** * Observes `elementRef` and fires a single `thumbnailViewed` analytics event * the first time the thumbnail is visible to the user. * * Two strategies are used: * * 1. Wall / Mosaic / Mobile carousel — IntersectionObserver (viewport). * Items are only rendered/visible when scrolled into view, so the observer * fires only for genuinely visible thumbnails. * * 2. Desktop carousel : A thumbnail is counted as viewed when its postIndex * falls within the current visible window [currentPosition, currentPosition + contentSize). * This effect re-runs on every arrow navigation. (We cannot use IntersectionObserver here because of the carousel loop cloning technique) */ export function useThumbnailViewEvent( elementRef: RefObject, media: IMedia, position: number, ): void { const store = useStore(); const triggeredRef = useRef(false); const observerRef = useRef(undefined); const widgetId = store.data.id; const triggerEvent = store.triggerEvent; const isDesktopCarousel = store.data.settings.type === 'carousel' && !store.isMobile; const contentSize = Math.max(store.data.settings.content_size ?? 1, 1); const currentPosition = store.currentPosition; // ── Strategy 1: desktop carousel — position-based ──────────────────────── useEffect(() => { if (!isDesktopCarousel) return; if (triggeredRef.current) return; const el = elementRef.current; if (!el) return; if (el.closest('[data-carousel-clone="true"]')) return; const viewedSet = getOrCreateViewedSet(widgetId); if (viewedSet.has(media.id)) { triggeredRef.current = true; return; } const isInVisibleWindow = media.postIndex >= currentPosition && media.postIndex < currentPosition + contentSize; if (isInVisibleWindow) { triggeredRef.current = true; viewedSet.add(media.id); triggerEvent('thumbnailViewed', { post: media, position }, widgetId); } // Re-run when the arrow moves the carousel to a new position. // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentPosition, isDesktopCarousel]); // ── Strategy 2: wall / mosaic / mobile carousel — IntersectionObserver ─── useEffect(() => { if (isDesktopCarousel) return; const el = elementRef.current; if (!el) return; if (el.closest('[data-carousel-clone="true"]')) return; const viewedSet = getOrCreateViewedSet(widgetId); if (viewedSet.has(media.id)) { triggeredRef.current = true; return; } observerRef.current = new IntersectionObserver( (entries) => { const [entry] = entries; if (entry?.isIntersecting && !triggeredRef.current) { triggeredRef.current = true; viewedSet.add(media.id); triggerEvent('thumbnailViewed', { post: media, position }, widgetId); observerRef.current?.disconnect(); } }, { root: null, // viewport threshold: VISIBILITY_THRESHOLD, }, ); observerRef.current.observe(el); return () => { observerRef.current?.disconnect(); }; // Only mount once — triggeredRef / viewedSet guard against duplicates. // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDesktopCarousel]); }