// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { Range } from '@tanstack/react-virtual'; import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useId, } from 'react'; import type { PressEvent } from 'react-aria'; import { VisuallyHidden } from 'react-aria'; import { LRUCache } from 'lru-cache'; import { FunItemButton } from '../base/FunItem'; import { FunPanel, FunPanelBody, FunPanelFooter, FunPanelHeader, } from '../base/FunPanel'; import { FunScroller } from '../base/FunScroller'; import { FunSearch } from '../base/FunSearch'; import { FunSubNav, FunSubNavIcon, FunSubNavListBox, FunSubNavListBoxItem, } from '../base/FunSubNav'; import { FunWaterfallContainer, FunWaterfallItem } from '../base/FunWaterfall'; import type { FunGifsSection } from '../constants'; import { FunGifsCategory, FunSectionCommon } from '../constants'; import { FunKeyboard } from '../keyboard/FunKeyboard'; import type { WaterfallKeyboardState } from '../keyboard/WaterfallKeyboardDelegate'; import { WaterfallKeyboardDelegate } from '../keyboard/WaterfallKeyboardDelegate'; import { useInfiniteQuery } from '../data/infinite'; import { missingCaseError } from '../../../util/missingCaseError'; import { strictAssert } from '../../../util/assert'; import type { GifsPaginated } from '../data/gifs'; import { drop } from '../../../util/drop'; import { useFunContext } from '../FunProvider'; import { FunResults, FunResultsButton, FunResultsFigure, FunResultsHeader, FunResultsSpinner, } from '../base/FunResults'; import { FunStaticEmoji } from '../FunEmoji'; import { emojiVariantConstant } from '../data/emojis'; import { FunLightboxPortal, FunLightboxBackdrop, FunLightboxDialog, FunLightboxProvider, useFunLightboxKey, } from '../base/FunLightbox'; import type { tenorDownload } from '../data/tenor'; import { FunGif } from '../FunGif'; import type { LocalizerType } from '../../../types/I18N'; import { isAbortError } from '../../../util/isAbortError'; import * as log from '../../../logging/log'; import * as Errors from '../../../types/errors'; const MAX_CACHE_SIZE = 50 * 1024 * 1024; // 50 MB const FunGifBlobCache = new LRUCache({ maxSize: MAX_CACHE_SIZE, sizeCalculation: blob => blob.size, }); const FunGifBlobLiveCache = new WeakMap(); function readGifMediaFromCache(gifMedia: GifMediaType): Blob | null { return ( FunGifBlobLiveCache.get(gifMedia) ?? FunGifBlobCache.get(gifMedia.url) ?? null ); } function saveGifMediaToCache(gifMedia: GifMediaType, blob: Blob): void { FunGifBlobCache.set(gifMedia.url, blob); FunGifBlobLiveCache.set(gifMedia, blob); } const GIF_WATERFALL_COLUMNS = 2; const GIF_WATERFALL_ITEM_WIDTH = 160; const GIF_WATERFALL_ITEM_MARGIN = 2; const GIF_WATERFALL_ITEM_TOTAL_WIDTH = GIF_WATERFALL_ITEM_WIDTH + GIF_WATERFALL_ITEM_MARGIN + GIF_WATERFALL_ITEM_MARGIN; export type GifMediaType = Readonly<{ url: string; width: number; height: number; }>; export type GifType = Readonly<{ id: string; title: string; description: string; previewMedia: GifMediaType; attachmentMedia: GifMediaType; }>; type GifsQuery = Readonly<{ selectedSection: FunGifsSection; searchQuery: string; }>; export type FunGifSelection = Readonly<{ gif: GifType; }>; export type FunPanelGifsProps = Readonly<{ onSelectGif: (gifSelection: FunGifSelection) => void; onClose: () => void; }>; export function FunPanelGifs({ onSelectGif, onClose, }: FunPanelGifsProps): JSX.Element { const fun = useFunContext(); const { i18n, searchInput, onSearchInputChange, selectedGifsSection, onChangeSelectedSelectGifsSection, recentGifs, fetchGifsFeatured, fetchGifsSearch, fetchGif, onSelectGif: onFunSelectGif, } = fun; const scrollerRef = useRef(null); const searchQuery = useMemo(() => searchInput.trim(), [searchInput]); const [selectedItemKey, setSelectedItemKey] = useState(null); const handleSearchInputChange = useCallback( (nextSearchInput: string) => { if (nextSearchInput.trim() !== '') { onChangeSelectedSelectGifsSection(FunSectionCommon.SearchResults); } else if (recentGifs.length > 0) { onChangeSelectedSelectGifsSection(FunSectionCommon.Recents); } else { onChangeSelectedSelectGifsSection(FunGifsCategory.Trending); } onSearchInputChange(nextSearchInput); }, [onSearchInputChange, recentGifs, onChangeSelectedSelectGifsSection] ); const [debouncedQuery, setDebouncedQuery] = useState({ selectedSection: selectedGifsSection, searchQuery, }); useEffect(() => { if ( debouncedQuery.searchQuery === searchQuery && debouncedQuery.selectedSection === selectedGifsSection ) { // don't update twice return; } const query: GifsQuery = { selectedSection: selectedGifsSection, searchQuery, }; // Set immediately if not a search if (searchQuery === '') { setDebouncedQuery(query); return; } // Defer searches const timeout = setTimeout(() => { setDebouncedQuery(query); }, 500); return () => { clearTimeout(timeout); }; }, [debouncedQuery, searchQuery, selectedGifsSection]); const loader = useCallback( async ( query: GifsQuery, previousPage: GifsPaginated | null, signal: AbortSignal ) => { const cursor = previousPage?.next ?? null; const limit = cursor != null ? 30 : 10; if (query.searchQuery !== '') { return fetchGifsSearch(query.searchQuery, limit, cursor, signal); } strictAssert( query.selectedSection !== FunSectionCommon.SearchResults, 'Section is search results when not searching' ); if (query.selectedSection === FunSectionCommon.Recents) { return { next: null, gifs: recentGifs }; } if (query.selectedSection === FunGifsCategory.Trending) { return fetchGifsFeatured(limit, cursor, signal); } if (query.selectedSection === FunGifsCategory.Celebrate) { return fetchGifsSearch('celebrate', limit, cursor, signal); } if (query.selectedSection === FunGifsCategory.Love) { return fetchGifsSearch('love', limit, cursor, signal); } if (query.selectedSection === FunGifsCategory.ThumbsUp) { return fetchGifsSearch('thumbs-up', limit, cursor, signal); } if (query.selectedSection === FunGifsCategory.Surprised) { return fetchGifsSearch('surprised', limit, cursor, signal); } if (query.selectedSection === FunGifsCategory.Excited) { return fetchGifsSearch('excited', limit, cursor, signal); } if (query.selectedSection === FunGifsCategory.Sad) { return fetchGifsSearch('sad', limit, cursor, signal); } if (query.selectedSection === FunGifsCategory.Angry) { return fetchGifsSearch('angry', limit, cursor, signal); } throw missingCaseError(query.selectedSection); }, [recentGifs, fetchGifsSearch, fetchGifsFeatured] ); const hasNextPage = useCallback( (_query: GifsQuery, previousPage: GifsPaginated | null) => { return previousPage?.next != null; }, [] ); const { queryState, fetchNextPage, revalidate } = useInfiniteQuery({ query: debouncedQuery, loader, hasNextPage, }); const items = useMemo(() => { return queryState.pages.flatMap(page => page.gifs); }, [queryState.pages]); const estimateSize = useCallback( (index: number) => { const gif = items[index]; const aspectRatio = gif.previewMedia.width / gif.previewMedia.height; const baseHeight = GIF_WATERFALL_ITEM_WIDTH / aspectRatio; return baseHeight + GIF_WATERFALL_ITEM_MARGIN + GIF_WATERFALL_ITEM_MARGIN; }, [items] ); const count = items.length; // Override the range extractor to always include the first and last indexes // so the keyboard delegate has something to jump to. const rangeExtractor = useCallback( (range: Range) => { const indexes = defaultRangeExtractor(range); if (!indexes.includes(0)) { indexes.unshift(0); // always include first } if (!indexes.includes(count - 1)) { indexes.push(count - 1); // always include last } return indexes; }, [count] ); const getScrollElement = useCallback(() => { return scrollerRef.current; }, []); const getItemKey = useCallback( (index: number) => { return items[index].id; }, [items] ); const virtualizer = useVirtualizer({ count, getScrollElement, estimateSize, rangeExtractor, overscan: 4 * GIF_WATERFALL_COLUMNS, lanes: GIF_WATERFALL_COLUMNS, scrollPaddingStart: 20, scrollPaddingEnd: 20, getItemKey, initialOffset: 100, }); // Scroll back to top when query changes useEffect(() => { virtualizer.scrollToOffset(0); }, [queryState.query, virtualizer]); const lastIndex = virtualizer.range?.endIndex ?? -1; useEffect(() => { if ( lastIndex === -1 || count === 0 || !queryState.hasNextPage || queryState.pending ) { return; } const overscan = 4 * GIF_WATERFALL_COLUMNS; // if we're near the end of the list, fetch more if (lastIndex + overscan >= count - 1) { fetchNextPage(); } }, [ lastIndex, count, queryState.hasNextPage, queryState.pending, fetchNextPage, ]); const keyboard = useMemo(() => { return new WaterfallKeyboardDelegate(virtualizer); }, [virtualizer]); const handleSelectSection = useCallback( (key: string) => { onChangeSelectedSelectGifsSection(key as FunGifsCategory); }, [onChangeSelectedSelectGifsSection] ); const handleKeyboardStateChange = useCallback( (state: WaterfallKeyboardState) => { setSelectedItemKey(state.key); }, [] ); const handlePressGif = useCallback( (_event: PressEvent, gifSelection: FunGifSelection) => { onFunSelectGif(gifSelection); onSelectGif(gifSelection); setSelectedItemKey(null); // Should always close, cannot select multiple onClose(); }, [onFunSelectGif, onSelectGif, onClose] ); const handleRetry = useCallback(() => { revalidate(); }, [revalidate]); // When we're searching, wait until the pending query is updated before // changing the UI const visibleSelectedSection = debouncedQuery.selectedSection === FunSectionCommon.SearchResults ? queryState.query.selectedSection : debouncedQuery.selectedSection; return ( {visibleSelectedSection !== FunSectionCommon.SearchResults && ( {recentGifs.length > 0 && ( )} )} {count === 0 && ( {queryState.pending && ( <> {i18n('icu:FunPanelGifs__SearchResults__LoadingLabel')} )} {queryState.rejected && ( <> {i18n('icu:FunPanelGifs__SearchResults__ErrorHeading')} {i18n('icu:FunPanelGifs__SearchResults__ErrorRetryButton')} )} {!queryState.pending && !queryState.rejected && ( {i18n('icu:FunPanelGifs__SearchResults__EmptyHeading')}{' '} )} )} {count !== 0 && ( {virtualizer.getVirtualItems().map(item => { const gif = items[item.index]; const key = String(item.key); const isTabbable = selectedItemKey != null ? key === selectedItemKey : item.index === 0; return ( ); })} )} ); } const Item = memo(function Item(props: { gif: GifType; itemKey: string; itemHeight: number; itemOffset: number; itemLane: number; isTabbable: boolean; onPressGif: (event: PressEvent, gifSelection: FunGifSelection) => void; fetchGif: typeof tenorDownload; }) { const { onPressGif, fetchGif } = props; const handlePress = useCallback( async (event: PressEvent) => { onPressGif(event, { gif: props.gif }); }, [props.gif, onPressGif] ); const descriptionId = `FunGifsPanelItem__GifDescription--${props.gif.id}`; const [src, setSrc] = useState(() => { const cached = readGifMediaFromCache(props.gif.previewMedia); return cached != null ? URL.createObjectURL(cached) : null; }); useEffect(() => { if (src != null) { return; } const controller = new AbortController(); const { signal } = controller; async function download() { try { const bytes = await fetchGif(props.gif.previewMedia.url, signal); const blob = new Blob([bytes]); saveGifMediaToCache(props.gif.previewMedia, blob); setSrc(URL.createObjectURL(blob)); } catch (error) { if (!isAbortError(error)) { log.error('Failed to download gif', Errors.toLogFormat(error)); } } } drop(download()); return () => { controller.abort(); }; }, [props.gif, src, fetchGif]); useEffect(() => { return () => { if (src != null) { URL.revokeObjectURL(src); } }; }, [src]); return ( {src != null && ( )} {props.gif.description} ); }); function GifsLightbox(props: { i18n: LocalizerType; items: ReadonlyArray; }) { const { i18n } = props; const key = useFunLightboxKey(); const descriptionId = useId(); const result = useMemo(() => { if (key == null) { return null; } const gif = props.items.find(item => { return item.id === key; }); strictAssert(gif, `Must have gif for "${key}"`); const blob = readGifMediaFromCache(gif.previewMedia); strictAssert(blob, 'Missing media'); const url = URL.createObjectURL(blob); return { gif, url }; }, [props.items, key]); useEffect(() => { return () => { if (result != null) { URL.revokeObjectURL(result.url); } }; }, [result]); if (result == null) { return null; } return ( {result.gif.description} ); }