From ef3ad06487b72034a0451f693e8200b35bb00259 Mon Sep 17 00:00:00 2001 From: vineyardbovines Date: Tue, 14 Apr 2026 08:21:41 -0400 Subject: [PATCH] wip --- src/components/dialogs/GifSelect.tsx | 344 ------------------ src/features/gifPicker/GifPickerDialog.tsx | 140 +++++++ src/features/gifPicker/README.md | 37 ++ .../components/GifPickerErrorBoundary.tsx | 38 ++ .../gifPicker/components/GifPickerGrid.tsx | 121 ++++++ .../gifPicker/components/GifPickerHeader.tsx | 73 ++++ .../gifPicker/components/GifPickerItem.tsx | 57 +++ .../components/GifPickerPlaceholder.tsx | 43 +++ .../gifPicker/hooks/useGifPickerData.ts | 54 +++ src/features/gifPicker/types.ts | 3 + src/view/com/composer/photos/SelectGifBtn.tsx | 6 +- 11 files changed, 569 insertions(+), 347 deletions(-) delete mode 100644 src/components/dialogs/GifSelect.tsx create mode 100644 src/features/gifPicker/GifPickerDialog.tsx create mode 100644 src/features/gifPicker/README.md create mode 100644 src/features/gifPicker/components/GifPickerErrorBoundary.tsx create mode 100644 src/features/gifPicker/components/GifPickerGrid.tsx create mode 100644 src/features/gifPicker/components/GifPickerHeader.tsx create mode 100644 src/features/gifPicker/components/GifPickerItem.tsx create mode 100644 src/features/gifPicker/components/GifPickerPlaceholder.tsx create mode 100644 src/features/gifPicker/hooks/useGifPickerData.ts create mode 100644 src/features/gifPicker/types.ts diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx deleted file mode 100644 index ab99a9525b..0000000000 --- a/src/components/dialogs/GifSelect.tsx +++ /dev/null @@ -1,344 +0,0 @@ -import { - useCallback, - useImperativeHandle, - useMemo, - useRef, - useState, -} from 'react' -import {type TextInput, View} from 'react-native' -import {useWindowDimensions} from 'react-native' -import {Image} from 'expo-image' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' - -import {cleanError} from '#/lib/strings/errors' -import { - useFeaturedGifsQuery as useKlipyFeaturedGifsQuery, - useGifSearchQuery as useKlipyGifSearchQuery, -} from '#/state/queries/klipy' -import { - type Gif, - gifPreviewUrl, - useTenorFeaturedGifsQuery, - useTenorGifSearchQuery, -} from '#/state/queries/tenor' -import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' -import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' -import {type ListMethods} from '#/view/com/util/List' -import {atoms as a, ios, native, useBreakpoints, useTheme, web} from '#/alf' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import * as Dialog from '#/components/Dialog' -import * as TextField from '#/components/forms/TextField' -import {useThrottledValue} from '#/components/hooks/useThrottledValue' -import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' -import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' -import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' -import {useAnalytics} from '#/analytics' -import {IS_WEB} from '#/env' - -export function GifSelectDialog({ - controlRef, - onClose, - onSelectGif: onSelectGifProp, -}: { - controlRef: React.RefObject<{open: () => void} | null> - onClose?: () => void - onSelectGif: (gif: Gif) => void -}) { - const control = Dialog.useDialogControl() - - useImperativeHandle(controlRef, () => ({ - open: () => control.open(), - })) - - const onSelectGif = useCallback( - (gif: Gif) => { - control.close(() => onSelectGifProp(gif)) - }, - [control, onSelectGifProp], - ) - - const renderErrorBoundary = useCallback( - (error: any) => , - [], - ) - - return ( - - - - - - - ) -} - -function GifList({ - control, - onSelectGif, -}: { - control: Dialog.DialogControlProps - onSelectGif: (gif: Gif) => void -}) { - const ax = useAnalytics() - const {_} = useLingui() - const t = useTheme() - const {gtMobile} = useBreakpoints() - const textInputRef = useRef(null) - const listRef = useRef(null) - const [undeferredSearch, setSearch] = useState('') - const search = useThrottledValue(undeferredSearch, 500) - const {height} = useWindowDimensions() - const useKlipy = ax.features.enabled(ax.features.KlipyGifProviderEnable) - - const isSearching = search.length > 0 - - const klipyTrending = useKlipyFeaturedGifsQuery({enabled: useKlipy}) - const klipySearch = useKlipyGifSearchQuery(search, {enabled: useKlipy}) - const tenorTrending = useTenorFeaturedGifsQuery({enabled: !useKlipy}) - const tenorSearch = useTenorGifSearchQuery(search, {enabled: !useKlipy}) - - const { - data, - fetchNextPage, - isFetchingNextPage, - hasNextPage, - error, - isPending, - isError, - refetch, - } = useKlipy - ? isSearching - ? klipySearch - : klipyTrending - : isSearching - ? tenorSearch - : tenorTrending - - const flattenedData = useMemo(() => { - return data?.pages.flatMap(page => page.results) || [] - }, [data]) - - const renderItem = useCallback( - ({item}: {item: Gif}) => { - return - }, - [onSelectGif], - ) - - const onEndReached = useCallback(() => { - if (isFetchingNextPage || !hasNextPage || error) return - fetchNextPage() - }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) - - const hasData = flattenedData.length > 0 - - const onGoBack = useCallback(() => { - if (isSearching) { - // clear the input and reset the state - textInputRef.current?.clear() - setSearch('') - } else { - control.close() - } - }, [control, isSearching]) - - const listHeader = useMemo(() => { - return ( - - {!gtMobile && IS_WEB && ( - - )} - - - - { - setSearch(text) - listRef.current?.scrollToOffset({offset: 0, animated: false}) - }} - returnKeyType="search" - clearButtonMode="while-editing" - inputRef={textInputRef} - maxLength={50} - onKeyPress={({nativeEvent}) => { - if (nativeEvent.key === 'Escape') { - control.close() - } - }} - /> - - - ) - }, [gtMobile, t.atoms.bg, _, control, useKlipy]) - - return ( - <> - {gtMobile && } - - {listHeader} - {!hasData && ( - - )} - - } - stickyHeaderIndices={[0]} - onEndReached={onEndReached} - onEndReachedThreshold={4} - keyExtractor={(item: Gif) => item.id} - keyboardDismissMode="on-drag" - ListFooterComponent={ - hasData ? ( - - ) : null - } - /> - - ) -} - -function DialogError({details}: {details?: string}) { - const {_} = useLingui() - const control = Dialog.useDialogContext() - - return ( - - - - - - ) -} - -export function GifPreview({ - gif, - onSelectGif, -}: { - gif: Gif - onSelectGif: (gif: Gif) => void -}) { - const ax = useAnalytics() - const {gtTablet} = useBreakpoints() - const {_} = useLingui() - const t = useTheme() - - const onPress = useCallback(() => { - ax.metric('composer:gif:select', {}) - onSelectGif(gif) - }, [ax, onSelectGif, gif]) - - return ( - - ) -} diff --git a/src/features/gifPicker/GifPickerDialog.tsx b/src/features/gifPicker/GifPickerDialog.tsx new file mode 100644 index 0000000000..3e7f18dde7 --- /dev/null +++ b/src/features/gifPicker/GifPickerDialog.tsx @@ -0,0 +1,140 @@ +import {useImperativeHandle, useRef, useState} from 'react' +import {type TextInput} from 'react-native' + +import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' +import {type ListMethods} from '#/view/com/util/List' +import {ios, useBreakpoints} from '#/alf' +import * as Dialog from '#/components/Dialog' +import {useThrottledValue} from '#/components/hooks/useThrottledValue' +import {GifPickerErrorBoundary} from '#/features/gifPicker/components/GifPickerErrorBoundary' +import {GifPickerGrid} from '#/features/gifPicker/components/GifPickerGrid' +import {GifPickerHeader} from '#/features/gifPicker/components/GifPickerHeader' +import {GifPickerPlaceholder} from '#/features/gifPicker/components/GifPickerPlaceholder' +import {useGifPickerData} from '#/features/gifPicker/hooks/useGifPickerData' +import {type Gif} from '#/features/gifPicker/types' + +export function GifPickerDialog({ + controlRef, + onClose, + onSelectGif: onSelectGifProp, +}: { + controlRef: React.RefObject<{open: () => void} | null> + onClose?: () => void + onSelectGif: (gif: Gif) => void +}) { + const control = Dialog.useDialogControl() + + useImperativeHandle(controlRef, () => ({ + open: () => control.open(), + })) + + const onSelectGif = (gif: Gif) => { + control.close(() => onSelectGifProp(gif)) + } + + return ( + + + ( + + )}> + + + + ) +} + +function GifPickerBody({ + control, + onSelectGif, +}: { + control: Dialog.DialogControlProps + onSelectGif: (gif: Gif) => void +}) { + const {gtMobile} = useBreakpoints() + const textInputRef = useRef(null) + const listRef = useRef(null) + const [rawSearch, setRawSearch] = useState('') + const search = useThrottledValue(rawSearch, 500) + + const { + data, + fetchNextPage, + isFetchingNextPage, + hasNextPage, + error, + isPending, + isError, + isSearching, + refetch, + } = useGifPickerData(search) + + const items = data?.pages.flatMap(page => page.results) ?? [] + const hasData = items.length > 0 + + const onEndReached = () => { + if (isFetchingNextPage || !hasNextPage || error) return + void fetchNextPage() + } + + const onGoBack = () => { + if (isSearching) { + textInputRef.current?.clear() + setRawSearch('') + } else { + control.close() + } + } + + const onChangeSearch = (text: string) => { + setRawSearch(text) + listRef.current?.scrollToOffset({offset: 0, animated: false}) + } + + const header = ( + <> + control.close()} + onEscape={() => control.close()} + /> + {!hasData && ( + + )} + + ) + + return ( + <> + {gtMobile && } + + + ) +} diff --git a/src/features/gifPicker/README.md b/src/features/gifPicker/README.md new file mode 100644 index 0000000000..2855cdcfd7 --- /dev/null +++ b/src/features/gifPicker/README.md @@ -0,0 +1,37 @@ +# GIF Picker + +Feature module for the GIF selection dialog used by the composer. + +## Structure + +- `GifPickerDialog.tsx` — entry component. Wraps `Dialog.Outer` with an error + boundary and exposes an imperative `open()` via `controlRef`. +- `components/GifPickerHeader.tsx` — sticky search row. Reserves a slot beneath + the search input where future tab bars (Trending / Recents / Categories) will + render. +- `components/GifPickerGrid.tsx` — column-distribution masonry grid built on + `Dialog.InnerFlatList`. Uses each GIF's intrinsic width/height instead of + forcing a square aspect ratio. +- `components/GifPickerItem.tsx` — single tile. Natural aspect ratio, press + feedback, fires the `composer:gif:select` analytics event. +- `components/GifPickerPlaceholder.tsx` — loading / empty / error states. +- `components/GifPickerErrorBoundary.tsx` — fallback rendered when the data + layer throws. +- `hooks/useGifPickerData.ts` — collapses the Klipy/Tenor provider flag and the + search-vs-featured branching into one hook so the UI never sees both paths. + +## Provider switching + +Provider selection is gated by the `KlipyGifProviderEnable` analytics feature +flag. Both provider modules in `src/state/queries/{klipy,tenor}.ts` are live +until the Klipy rollout (and the tango backend-proxy PR) is complete. Do not +remove the Tenor path without first confirming Klipy is globally enabled. + +## Out of scope (future tickets) + +- Autocomplete / autosuggest +- Recents +- Trending tags / categories browsing +- Favorites +- Alt-text-at-pick flow (alt text is still added via a separate dialog after + selection) diff --git a/src/features/gifPicker/components/GifPickerErrorBoundary.tsx b/src/features/gifPicker/components/GifPickerErrorBoundary.tsx new file mode 100644 index 0000000000..37f6f2c246 --- /dev/null +++ b/src/features/gifPicker/components/GifPickerErrorBoundary.tsx @@ -0,0 +1,38 @@ +import {msg} from '@lingui/core/macro' +import {useLingui} from '@lingui/react' +import {Trans} from '@lingui/react/macro' + +import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' +import {atoms as a} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' + +export function GifPickerErrorBoundary({details}: {details?: string}) { + const {_} = useLingui() + const control = Dialog.useDialogContext() + + return ( + + + + + + ) +} diff --git a/src/features/gifPicker/components/GifPickerGrid.tsx b/src/features/gifPicker/components/GifPickerGrid.tsx new file mode 100644 index 0000000000..60cc30ea87 --- /dev/null +++ b/src/features/gifPicker/components/GifPickerGrid.tsx @@ -0,0 +1,121 @@ +import {forwardRef, useMemo} from 'react' +import {useWindowDimensions, View} from 'react-native' + +import {cleanError} from '#/lib/strings/errors' +import {type ListMethods} from '#/view/com/util/List' +import {atoms as a, native, useBreakpoints, web} from '#/alf' +import * as Dialog from '#/components/Dialog' +import {ListFooter} from '#/components/Lists' +import {GifPickerItem} from '#/features/gifPicker/components/GifPickerItem' +import {type Gif} from '#/features/gifPicker/types' + +type Props = { + items: Gif[] + header: React.ReactNode + hasData: boolean + isFetchingNextPage: boolean + error: unknown + fetchNextPage: () => Promise + onEndReached: () => void + onSelectGif: (gif: Gif) => void +} + +export const GifPickerGrid = forwardRef( + function GifPickerGrid( + { + items, + header, + hasData, + isFetchingNextPage, + error, + fetchNextPage, + onEndReached, + onSelectGif, + }, + ref, + ) { + const {gtMobile} = useBreakpoints() + const {height} = useWindowDimensions() + const numColumns = gtMobile ? 3 : 2 + + const columns = useMemo( + () => distributeIntoColumns(items, numColumns), + [items, numColumns], + ) + + /** + * The grid is a single FlatList row because the tiles are distributed + * into columns up front for masonry. `onEndReached` still fires against + * the outer FlatList's scroll position, so pagination behaves the same + * as a conventional grid. + */ + const data = useMemo(() => (hasData ? [columns] : []), [hasData, columns]) + + return ( + ( + + {item.map((column, i) => ( + + {column.map(gif => ( + + ))} + + ))} + + )} + keyExtractor={(_item, index) => `masonry-${index}`} + contentContainerStyle={[native([a.px_xl, {minHeight: height}])]} + webInnerStyle={[web({minHeight: '80vh'})]} + webInnerContentContainerStyle={[web(a.pb_0)]} + ListHeaderComponent={<>{header}} + stickyHeaderIndices={[0]} + onEndReached={onEndReached} + onEndReachedThreshold={4} + keyboardDismissMode="on-drag" + ListFooterComponent={ + hasData ? ( + + ) : null + } + /> + ) + }, +) + +/** + * Walks `items` in order and pushes each one into the currently shortest + * column, tracking accumulated height-per-unit-width from each GIF's + * intrinsic aspect ratio. Preserves ordering top-to-bottom within each + * column, which keeps pagination behavior intuitive as new pages stream in. + */ +function distributeIntoColumns(items: Gif[], numColumns: number): Gif[][] { + const columns: Gif[][] = Array.from({length: numColumns}, () => []) + const heights = new Array(numColumns).fill(0) + + for (const item of items) { + const [w, h] = item.media_formats.tinygif.dims + const ratio = w > 0 && h > 0 ? h / w : 1 + + let shortest = 0 + for (let i = 1; i < numColumns; i++) { + if (heights[i] < heights[shortest]) shortest = i + } + columns[shortest].push(item) + heights[shortest] += ratio + } + + return columns +} diff --git a/src/features/gifPicker/components/GifPickerHeader.tsx b/src/features/gifPicker/components/GifPickerHeader.tsx new file mode 100644 index 0000000000..19d56cbad0 --- /dev/null +++ b/src/features/gifPicker/components/GifPickerHeader.tsx @@ -0,0 +1,73 @@ +import {type Ref} from 'react' +import {type TextInput, View} from 'react-native' +import {msg} from '@lingui/core/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import * as TextField from '#/components/forms/TextField' +import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' +import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' +import {IS_WEB} from '#/env' + +export function GifPickerHeader({ + inputRef, + onChangeText, + onClose, + onEscape, +}: { + inputRef: Ref + onChangeText: (text: string) => void + onClose: () => void + onEscape: () => void +}) { + const {_} = useLingui() + const t = useTheme() + const {gtMobile} = useBreakpoints() + + return ( + + {!gtMobile && IS_WEB && ( + + )} + + + + { + if (nativeEvent.key === 'Escape') { + onEscape() + } + }} + /> + + + {/* future: tabs (Trending / Recents / Categories) render here */} + + ) +} diff --git a/src/features/gifPicker/components/GifPickerItem.tsx b/src/features/gifPicker/components/GifPickerItem.tsx new file mode 100644 index 0000000000..c69d8b4723 --- /dev/null +++ b/src/features/gifPicker/components/GifPickerItem.tsx @@ -0,0 +1,57 @@ +import {Image} from 'expo-image' +import {msg} from '@lingui/core/macro' +import {useLingui} from '@lingui/react' + +import {gifPreviewUrl} from '#/state/queries/tenor' +import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import {useAnalytics} from '#/analytics' +import {type Gif} from '#/features/gifPicker/types' + +export function GifPickerItem({ + gif, + onSelectGif, +}: { + gif: Gif + onSelectGif: (gif: Gif) => void +}) { + const ax = useAnalytics() + const {_} = useLingui() + const t = useTheme() + + const [width, height] = gif.media_formats.tinygif.dims + const aspectRatio = width > 0 && height > 0 ? width / height : 1 + + const onPress = () => { + ax.metric('composer:gif:select', {}) + onSelectGif(gif) + } + + return ( + + ) +} diff --git a/src/features/gifPicker/components/GifPickerPlaceholder.tsx b/src/features/gifPicker/components/GifPickerPlaceholder.tsx new file mode 100644 index 0000000000..e478ded438 --- /dev/null +++ b/src/features/gifPicker/components/GifPickerPlaceholder.tsx @@ -0,0 +1,43 @@ +import {msg} from '@lingui/core/macro' +import {useLingui} from '@lingui/react' + +import {ListMaybePlaceholder} from '#/components/Lists' + +export function GifPickerPlaceholder({ + isLoading, + isError, + isSearching, + query, + onRetry, + onGoBack, +}: { + isLoading: boolean + isError: boolean + isSearching: boolean + query: string + onRetry: () => Promise + onGoBack: () => void +}) { + const {_} = useLingui() + + return ( + + ) +} diff --git a/src/features/gifPicker/hooks/useGifPickerData.ts b/src/features/gifPicker/hooks/useGifPickerData.ts new file mode 100644 index 0000000000..44898868f3 --- /dev/null +++ b/src/features/gifPicker/hooks/useGifPickerData.ts @@ -0,0 +1,54 @@ +import { + useFeaturedGifsQuery as useKlipyFeaturedGifsQuery, + useGifSearchQuery as useKlipyGifSearchQuery, +} from '#/state/queries/klipy' +import { + useTenorFeaturedGifsQuery, + useTenorGifSearchQuery, +} from '#/state/queries/tenor' +import {useAnalytics} from '#/analytics' +import {type GifPickerProvider} from '#/features/gifPicker/types' + +/** + * Single entry point for the GIF picker's data layer. Wraps the Klipy/Tenor + * feature-flag split and the featured-vs-search branching so the UI only ever + * consumes one query result. + * + * The Tenor path is kept alive until the Klipy rollout is complete. + */ +export function useGifPickerData( + query: string, + {enabled = true}: {enabled?: boolean} = {}, +) { + const ax = useAnalytics() + const useKlipy = ax.features.enabled(ax.features.KlipyGifProviderEnable) + const isSearching = query.length > 0 + const provider: GifPickerProvider = useKlipy ? 'klipy' : 'tenor' + + const klipyFeatured = useKlipyFeaturedGifsQuery({ + enabled: enabled && useKlipy && !isSearching, + }) + const klipySearch = useKlipyGifSearchQuery(query, { + enabled: enabled && useKlipy && isSearching, + }) + const tenorFeatured = useTenorFeaturedGifsQuery({ + enabled: enabled && !useKlipy && !isSearching, + }) + const tenorSearch = useTenorGifSearchQuery(query, { + enabled: enabled && !useKlipy && isSearching, + }) + + const active = useKlipy + ? isSearching + ? klipySearch + : klipyFeatured + : isSearching + ? tenorSearch + : tenorFeatured + + return { + ...active, + provider, + isSearching, + } +} diff --git a/src/features/gifPicker/types.ts b/src/features/gifPicker/types.ts new file mode 100644 index 0000000000..67e26bb793 --- /dev/null +++ b/src/features/gifPicker/types.ts @@ -0,0 +1,3 @@ +export {type Gif} from '#/state/queries/tenor' + +export type GifPickerProvider = 'klipy' | 'tenor' diff --git a/src/view/com/composer/photos/SelectGifBtn.tsx b/src/view/com/composer/photos/SelectGifBtn.tsx index d7dfe0c079..63636fa5d1 100644 --- a/src/view/com/composer/photos/SelectGifBtn.tsx +++ b/src/view/com/composer/photos/SelectGifBtn.tsx @@ -3,12 +3,12 @@ import {Keyboard} from 'react-native' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' -import {type Gif} from '#/state/queries/tenor' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' -import {GifSelectDialog} from '#/components/dialogs/GifSelect' import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif' import {useAnalytics} from '#/analytics' +import {GifPickerDialog} from '#/features/gifPicker/GifPickerDialog' +import {type Gif} from '#/features/gifPicker/types' type Props = { onClose?: () => void @@ -43,7 +43,7 @@ export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) { -