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) {
-