Signal-Desktop/ts/components/fun/panels/FunPanelGifs.tsx

685 lines
20 KiB
TypeScript

// 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 type { PointerEvent } from 'react';
import React, {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useId,
} from 'react';
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 { createLogger } from '../../../logging/log';
import * as Errors from '../../../types/errors';
const log = createLogger('FunPanelGifs');
const MAX_CACHE_SIZE = 50 * 1024 * 1024; // 50 MB
const FunGifBlobCache = new LRUCache<string, Blob>({
maxSize: MAX_CACHE_SIZE,
sizeCalculation: blob => blob.size,
});
const FunGifBlobLiveCache = new WeakMap<GifMediaType, Blob>();
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<HTMLDivElement>(null);
const searchQuery = useMemo(() => searchInput.trim(), [searchInput]);
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(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<GifsQuery>({
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<HTMLDivElement, HTMLDivElement>({
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 handleClickGif = useCallback(
(_event: PointerEvent, 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 (
<FunPanel>
<FunPanelHeader>
<FunSearch
i18n={i18n}
searchInput={searchInput}
onSearchInputChange={handleSearchInputChange}
placeholder={i18n('icu:FunPanelGifs__SearchPlaceholder--Tenor')}
aria-label={i18n('icu:FunPanelGifs__SearchLabel--Tenor')}
/>
</FunPanelHeader>
{visibleSelectedSection !== FunSectionCommon.SearchResults && (
<FunPanelFooter>
<FunSubNav>
<FunSubNavListBox
aria-label={i18n('icu:FunPanelGifs__SubNavLabel')}
selected={visibleSelectedSection}
onSelect={handleSelectSection}
>
{recentGifs.length > 0 && (
<FunSubNavListBoxItem
id={FunSectionCommon.Recents}
label={i18n('icu:FunPanelGifs__SubNavCategoryLabel--Recents')}
>
<FunSubNavIcon iconClassName="FunSubNav__Icon--Recents" />
</FunSubNavListBoxItem>
)}
<FunSubNavListBoxItem
id={FunGifsCategory.Trending}
label={i18n('icu:FunPanelGifs__SubNavCategoryLabel--Trending')}
>
<FunSubNavIcon iconClassName="FunSubNav__Icon--Trending" />
</FunSubNavListBoxItem>
<FunSubNavListBoxItem
id={FunGifsCategory.Celebrate}
label={i18n('icu:FunPanelGifs__SubNavCategoryLabel--Celebrate')}
>
<FunSubNavIcon iconClassName="FunSubNav__Icon--Celebrate" />
</FunSubNavListBoxItem>
<FunSubNavListBoxItem
id={FunGifsCategory.Love}
label={i18n('icu:FunPanelGifs__SubNavCategoryLabel--Love')}
>
<FunSubNavIcon iconClassName="FunSubNav__Icon--Love" />
</FunSubNavListBoxItem>
<FunSubNavListBoxItem
id={FunGifsCategory.ThumbsUp}
label={i18n('icu:FunPanelGifs__SubNavCategoryLabel--ThumbsUp')}
>
<FunSubNavIcon iconClassName="FunSubNav__Icon--ThumbsUp" />
</FunSubNavListBoxItem>
<FunSubNavListBoxItem
id={FunGifsCategory.Surprised}
label={i18n('icu:FunPanelGifs__SubNavCategoryLabel--Surprised')}
>
<FunSubNavIcon iconClassName="FunSubNav__Icon--Surprised" />
</FunSubNavListBoxItem>
<FunSubNavListBoxItem
id={FunGifsCategory.Excited}
label={i18n('icu:FunPanelGifs__SubNavCategoryLabel--Excited')}
>
<FunSubNavIcon iconClassName="FunSubNav__Icon--Excited" />
</FunSubNavListBoxItem>
<FunSubNavListBoxItem
id={FunGifsCategory.Sad}
label={i18n('icu:FunPanelGifs__SubNavCategoryLabel--Sad')}
>
<FunSubNavIcon iconClassName="FunSubNav__Icon--Sad" />
</FunSubNavListBoxItem>
<FunSubNavListBoxItem
id={FunGifsCategory.Angry}
label={i18n('icu:FunPanelGifs__SubNavCategoryLabel--Angry')}
>
<FunSubNavIcon iconClassName="FunSubNav__Icon--Angry" />
</FunSubNavListBoxItem>
</FunSubNavListBox>
</FunSubNav>
</FunPanelFooter>
)}
<FunPanelBody>
<FunScroller ref={scrollerRef} sectionGap={0}>
{count === 0 && (
<FunResults aria-busy={queryState.pending}>
{queryState.pending && (
<>
<FunResultsFigure>
<FunResultsSpinner />
</FunResultsFigure>
<VisuallyHidden>
<FunResultsHeader>
{i18n('icu:FunPanelGifs__SearchResults__LoadingLabel')}
</FunResultsHeader>
</VisuallyHidden>
</>
)}
{queryState.rejected && (
<>
<FunResultsHeader>
{i18n('icu:FunPanelGifs__SearchResults__ErrorHeading')}
</FunResultsHeader>
<FunResultsButton onPress={handleRetry}>
{i18n('icu:FunPanelGifs__SearchResults__ErrorRetryButton')}
</FunResultsButton>
</>
)}
{!queryState.pending && !queryState.rejected && (
<FunResultsHeader>
{i18n('icu:FunPanelGifs__SearchResults__EmptyHeading')}{' '}
<FunStaticEmoji
size={16}
role="presentation"
emoji={emojiVariantConstant('\u{1F641}')}
/>
</FunResultsHeader>
)}
</FunResults>
)}
{count !== 0 && (
<FunLightboxProvider containerRef={scrollerRef}>
<GifsLightbox i18n={i18n} items={items} />
<FunKeyboard
scrollerRef={scrollerRef}
keyboard={keyboard}
onStateChange={handleKeyboardStateChange}
>
<FunWaterfallContainer totalSize={virtualizer.getTotalSize()}>
{virtualizer.getVirtualItems().map(item => {
const gif = items[item.index];
const key = String(item.key);
const isTabbable =
selectedItemKey != null
? key === selectedItemKey
: item.index === 0;
return (
<Item
key={key}
gif={gif}
itemKey={key}
itemHeight={item.size}
itemOffset={item.start}
itemLane={item.lane}
isTabbable={isTabbable}
onClickGif={handleClickGif}
fetchGif={fetchGif}
/>
);
})}
</FunWaterfallContainer>
</FunKeyboard>
</FunLightboxProvider>
)}
</FunScroller>
</FunPanelBody>
</FunPanel>
);
}
const Item = memo(function Item(props: {
gif: GifType;
itemKey: string;
itemHeight: number;
itemOffset: number;
itemLane: number;
isTabbable: boolean;
onClickGif: (event: PointerEvent, gifSelection: FunGifSelection) => void;
fetchGif: typeof tenorDownload;
}) {
const { onClickGif, fetchGif } = props;
const handleClick = useCallback(
async (event: PointerEvent) => {
onClickGif(event, { gif: props.gif });
},
[props.gif, onClickGif]
);
const descriptionId = `FunGifsPanelItem__GifDescription--${props.gif.id}`;
const [src, setSrc] = useState<string | null>(() => {
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 (
<FunWaterfallItem
data-key={props.itemKey}
width={GIF_WATERFALL_ITEM_TOTAL_WIDTH}
height={props.itemHeight}
offsetY={props.itemOffset}
offsetX={GIF_WATERFALL_ITEM_TOTAL_WIDTH * props.itemLane}
>
<FunItemButton
aria-label={props.gif.title}
onClick={handleClick}
excludeFromTabOrder={!props.isTabbable}
>
{src != null && (
<FunGif
src={src}
width={props.gif.previewMedia.width}
height={props.gif.previewMedia.height}
aria-describedby={descriptionId}
/>
)}
<VisuallyHidden id={descriptionId}>
{props.gif.description}
</VisuallyHidden>
</FunItemButton>
</FunWaterfallItem>
);
});
function GifsLightbox(props: {
i18n: LocalizerType;
items: ReadonlyArray<GifType>;
}) {
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 (
<FunLightboxPortal>
<FunLightboxBackdrop>
<FunLightboxDialog
aria-label={i18n('icu:FunPanelGifs__LightboxDialog__Label')}
>
<FunGif
src={result.url}
width={result.gif.previewMedia.width}
height={result.gif.previewMedia.height}
aria-describedby={descriptionId}
ignoreReducedMotion
/>
<VisuallyHidden id={descriptionId}>
{result.gif.description}
</VisuallyHidden>
</FunLightboxDialog>
</FunLightboxBackdrop>
</FunLightboxPortal>
);
}