import * as React from 'react'; import { FC, useState, useCallback, MouseEvent, KeyboardEvent, useRef, useEffect, FocusEvent } from 'react'; import { style } from 'typestyle'; import { EventMap, IMedia } from '../../types'; import { useStore } from '../../services/store'; import ThumbnailPlayIcon from '../Icons/ThumbnailPlay'; import { Helpers } from '../../services/helpers'; import { onKeyDownEventCallback } from '../../utils/onKeyDown'; import useBorderRadius from '../../hooks/useBorderRadius'; import useCarouselThumbnailVideoLoading from '../../hooks/useCarouselThumbnailVideoLoading'; import { useThumbnailViewEvent } from '../../hooks/useThumbnailViewEvent'; import { Packshot, getHeightPercentage } from '../Packshot'; import { ugcZIndex, videoZIndex, activeSquareAspectRatio, activePortraitAspectRatio, activeStoriesAspectRatio, thumbnailTransitionDurationSec, } from '../consts'; import { resolveCarouselType } from '../../utils/carousel'; import ThumbnailPills from '../ThumbnailPills'; // Module-level cache of image URLs that have been successfully loaded by any Thumbnail instance. const loadedImageUrls = new Set(); const ASPECT_RATIOS = { square: 100, portrait: 125, stories: 177.78, } as const; const ACTIVE_SLIDE_ASPECT_RATIOS = { square: activeSquareAspectRatio, portrait: activePortraitAspectRatio, stories: activeStoriesAspectRatio, } as const; const SMALL_THUMBNAIL_WIDTH_PX = 250; interface ThumbnailProps { media: IMedia; position: number; isCarouselClone?: boolean; } export const Thumbnail: FC = ({ media, position, isCarouselClone = false }) => { const store = useStore(); const thumbnailImage = media.thumbnail ?? media.image; const alreadyLoaded = !!thumbnailImage && loadedImageUrls.has(thumbnailImage); const [imageVisible, setImageVisible] = useState(alreadyLoaded); const [isLoaded, setIsLoaded] = useState(alreadyLoaded); const [isMuted, setIsMuted] = useState(true); const [isHovered, setIsHovered] = useState(false); const [thumbnailWidth, setThumbnailWidth] = useState(0); const thumbnailRef = useRef(null); const videoRef = useRef(null); const { thumbnail_corners: cornerType, thumbnail_shape: thumbnailShape = 'square', thumbnail_packshot: thumbnailPackshot, shop_this_look_display: shopThisLookDisplay, type: widgetLayout, thumbnail_video_autoplay_hover: videoAutoplayOnHover = true, show_attribution: showAttribution = true, } = store.data.settings; const borderRadius = useBorderRadius(100, 100, cornerType); useThumbnailViewEvent(thumbnailRef, media, position); const productListMode = shopThisLookDisplay === 'product_list'; const carouselType = resolveCarouselType(store.data.settings); const isSpotlightCarousel = widgetLayout === 'carousel' && !store.isMobile && carouselType === 'spotlight'; const isUniformCarousel = widgetLayout === 'carousel' && !store.isMobile && carouselType === 'uniform'; const allowSpotlightGrow = isSpotlightCarousel && !store.productListUniformMode; const isActiveSlide = store.currentPosition === media.postIndex; const { isCarouselThumbnailVideoEnabled, shouldLoadCarouselVideo, handleCarouselVideoReady, } = useCarouselThumbnailVideoLoading(media); const shouldHideUsername = store.isMobile && (store.data.settings.type !== 'carousel' || // (1) mobile and mosaic/wall, (typeof store.data.settings.mobile_carousel_content_size !== 'undefined' && store.data.settings.mobile_carousel_content_size !== 1)); // (2) mobile and content size is more that 1 const isLayoutWithSmallThumbnailRule = widgetLayout === 'wall' || widgetLayout === 'mosaic' || (widgetLayout === 'carousel' && carouselType === 'buy_box'); const isSmallDesktopThumbnail = isLayoutWithSmallThumbnailRule && !store.isMobile && thumbnailWidth > 0 && thumbnailWidth < SMALL_THUMBNAIL_WIDTH_PX; // Show the inline video only when the thumbnail is loaded and the current // carousel video is ready to unlock shoulder videos, or on desktop hover. const shouldShowInlineActiveVideo = (productListMode || store.isMobile) && widgetLayout !== 'carousel' && isActiveSlide; const shouldShowVideo = media.type === 'video' && media.video && isLoaded && !isCarouselClone && ((isCarouselThumbnailVideoEnabled && shouldLoadCarouselVideo) || shouldShowInlineActiveVideo || (!store.isMobile && videoAutoplayOnHover && isHovered)); const isMobileCarouselVideo = store.isMobile && widgetLayout === 'carousel' && media.type === 'video'; // Attribution is visible on the active product list slide, mobile video slides, or on desktop hover. const shouldShowAttribution = showAttribution && ((isActiveSlide && productListMode) || (isActiveSlide && store.isMobile && media.type === 'video') || (isHovered && !store.isMobile)); const totalPosts = store.data.content.medias.length; const postNumber = Math.min(Math.max(media.postIndex + 1, 1), Math.max(totalPosts, 1)); const handleEvent = useCallback( (eventName: keyof EventMap, originalEvent?: Event) => { store.triggerEvent(eventName, { post: media, position, originalEvent }, store.data.id); }, [media, position, store], ); const handleImageLoad = useCallback(() => { if (thumbnailImage) loadedImageUrls.add(thumbnailImage); setIsLoaded(true); handleEvent('thumbnailLoaded'); }, [handleEvent, thumbnailImage]); const handleImageError = useCallback(() => { // Remove a broken thumbnail from the content list so it does not stay in the carousel. handleEvent('thumbnailUnavailable'); store.setStoreState((state) => ({ ...state, data: { ...state.data, content: { ...state.data.content, medias: state.data.content.medias .filter((_, idx) => idx !== media.postIndex) .map((m, idx) => ({ ...m, postIndex: idx })), }, }, })); }, [handleEvent, media.postIndex, store]); // Detect image load via off-DOM Image() so background-image is the only fetch. useEffect(() => { if (!imageVisible || isLoaded || !thumbnailImage) return; const img = new Image(); img.onload = handleImageLoad; img.onerror = () => handleImageError(); img.src = thumbnailImage; return () => { img.onload = null; img.onerror = null; }; }, [imageVisible, isLoaded, thumbnailImage, handleImageLoad, handleImageError]); const handleOpen = useCallback( (e: MouseEvent | KeyboardEvent) => { // Open the selected post and keep the carousel position in sync. store.setStoreState((state) => ({ ...state, currentPosition: media.postIndex, })); if (store.data.settings.shop_this_look_display === 'product_list') { if (!media.products || media.products.length === 0) { return; // don't open product list if no products } } handleEvent('thumbnailClicked', e.nativeEvent); store.setStoreState((state) => ({ ...state, post: media, currentPosition: media.postIndex, })); Helpers.pushPostToHistory(media); }, [handleEvent, media, store], ); // Lazy-load thumbnail images. // In carousel mode, load items inside around the current slide (visible item + 2 on each side) // In other layouts, load when the thumbnail enters the viewport useEffect(() => { if (imageVisible) return; if (widgetLayout === 'carousel') { const contentSize = store.isMobile ? (store.data.settings.mobile_carousel_content_size || 1) : (store.data.settings.content_size || 1); const total = store.data.content.medias.length; const buffer = 2; const windowSize = contentSize + buffer * 2; if (total <= windowSize) { // Small carousel — just load everything setImageVisible(true); return; } // Circular window: start = currentPosition - buffer (mod total) const start = ((store.currentPosition - buffer) % total + total) % total; // Check if postIndex falls within [start, start + windowSize) on the ring const dist = ((media.postIndex - start) % total + total) % total; if (dist < windowSize) { setImageVisible(true); } return; } const el = thumbnailRef.current; if (!el) return; const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { setImageVisible(true); observer.unobserve(entry.target); // stop since we don't need updates } }); }, { rootMargin: '0px', threshold: 0.1 }, // no margin around viewport, trigger when 10% visible ); observer.observe(el); // eslint-disable-next-line consistent-return return () => observer.unobserve(el); }, [widgetLayout, imageVisible, store.currentPosition, media.postIndex, store.isMobile, store.data.settings.content_size, store.data.settings.mobile_carousel_content_size, store.data.content.medias.length]); // Track thumbnail width to hide username when the thumbnail is too small (desktop only) useEffect(() => { const el = thumbnailRef.current; if (!el) return; const observer = new ResizeObserver((entries) => { setThumbnailWidth(entries[0].contentRect.width); }); observer.observe(el); return () => observer.disconnect(); }, []); const showMobilePlayer = store.isMobile && store.data.settings.mobile_player_display !== false; const showDesktopPlayer = !store.isMobile && productListMode && store.data.settings.display_desktop_player === true; // When a portal clone is active, hide the original active thumbnail so it // does not interfere with the cloned preview that is shown instead. const cloneIsActive = productListMode && !!store.post && !showMobilePlayer && !showDesktopPlayer; let imageClassName = ''; if (isLoaded) { imageClassName = isActiveSlide && !cloneIsActive ? activeImageClass : inactiveImageClass; } // Get the aspect ratio based on active state and mode const getAspectRatio = () => { if (allowSpotlightGrow && isActiveSlide) { return ACTIVE_SLIDE_ASPECT_RATIOS[thumbnailShape]; } return ASPECT_RATIOS[thumbnailShape]; }; // Calculate packshot height percentage, adjusting for active slides const getAdjustedPackshotHeight = () => { const baseHeight = getHeightPercentage(thumbnailShape, store.isMobile); if (allowSpotlightGrow && isActiveSlide) { // Calculate ratio between standard and active aspect ratios const aspectRatio = ASPECT_RATIOS[thumbnailShape]; const activeAspectRatio = ACTIVE_SLIDE_ASPECT_RATIOS[thumbnailShape]; const ratio = aspectRatio / activeAspectRatio; // Apply this ratio to maintain consistent visual height return baseHeight * ratio; } return baseHeight; }; const handleMouseEnter = useCallback(() => { handleEvent('thumbnailHover'); setIsHovered(true); }, [handleEvent]); const handleMouseLeave = useCallback(() => { setIsHovered(false); }, []); const handleFocus = useCallback((e: FocusEvent) => { // Don't update position when modal is open - let focus stay in the modal if (store.post) { return; } // Only update carousel position for keyboard-driven focus (Tab navigation). // When focus comes from a pointer/mouse click, :focus-visible is not set, so we // skip navigation here — handleOpen will take care of it in the same event batch // and the carousel will update without triggering a visible slide animation first. if (!e.currentTarget.matches(':focus-visible')) { return; } // Update carousel position when thumbnail receives focus (e.g., via Tab navigation) // This ensures the carousel slides one thumbnail at a time, staying in sync if (store.currentPosition !== media.postIndex) { store.setStoreState((state) => ({ ...state, currentPosition: media.postIndex, })); } }, [media.postIndex, store]); // Play/pause the thumbnail video based on active-slide state. useEffect(() => { const video = videoRef.current; if (!video || !shouldShowVideo) return; if (isActiveSlide) { video.play().catch(() => { // Silent catch – browser may block autoplay }); } else { video.pause(); } }, [shouldShowVideo, isActiveSlide]); // Control video playback based on hover state useEffect(() => { if (!isMobileCarouselVideo && !store.isMobile && videoAutoplayOnHover && videoRef.current) { if (isHovered) { videoRef.current.play().catch(() => { // Silent catch for browsers that block autoplay }); } else if (!isActiveSlide || !productListMode) { videoRef.current.pause(); } } }, [isHovered, videoAutoplayOnHover, store.isMobile, isActiveSlide, productListMode]); return (
onKeyDownEventCallback(e, handleOpen)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onFocus={handleFocus} tabIndex={store.post ? -1 : 0} aria-hidden={store.post ? 'true' : undefined} {...(store.post ? { inert: '' } : {})} role='link' aria-haspopup='dialog' aria-label={`Open preview of ${media.source} content created by ${media.username}. Item ${ postNumber } of ${totalPosts}.`} >
{media.type === 'video' && !shouldShowVideo ? : null} {shouldShowVideo ? ( ) : null} setIsMuted(!isMuted)} showUsername={!!media.username && isLoaded && !isSmallDesktopThumbnail} showAttribution={shouldShowAttribution} isMobile={store.isMobile} hideUsername={shouldHideUsername} classPrefix={store.config.classPrefix || ''} showUsernameTransition={!store.isMobile} /> {thumbnailPackshot && media.products && media.products.length > 0 && isLoaded && !(isActiveSlide && isUniformCarousel && productListMode && store.post !== null) ? (
); }; const styles = { thumbnail: () => ({ width: '100%', position: 'relative' as const, cursor: 'pointer', }), image: (cornerRadius: string) => ({ position: 'relative' as const, flex: 1, margin: 4, backgroundSize: 'cover', backgroundPosition: 'center', backgroundColor: '#282828', display: 'flex', flexDirection: 'column' as const, alignItems: 'center', overflow: 'hidden', borderRadius: cornerRadius, }), } as const; const containerClass = style({ position: 'absolute', height: '100%', width: '100%', top: 0, left: 0, display: 'flex', $nest: { '&:hover .username': { opacity: 1, }, }, }); const activeImageClass = style({ zIndex: ugcZIndex, transition: `transform ${thumbnailTransitionDurationSec}s ease`, }); const inactiveImageClass = style({ zIndex: 1, transition: `transform ${thumbnailTransitionDurationSec}s ease`, }); const videoClass = style({ width: '100%', height: '100%', objectFit: 'cover', position: 'absolute', top: 0, left: 0, zIndex: videoZIndex, }); const thumbnailGrowTransitionClass = style({ transition: 'padding-top 0.4s ease', }); export default Thumbnail;