diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000000..53998ac58c
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,9 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(yarn typecheck *)",
+ "Bash(yarn lint *)",
+ "Bash(yarn test *)"
+ ]
+ }
+}
diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts
index 38966c35cb..8a56423796 100644
--- a/__tests__/lib/string.test.ts
+++ b/__tests__/lib/string.test.ts
@@ -8,8 +8,7 @@ import {
parseStarterPackUri,
} from '#/lib/strings/starter-pack'
import {messages} from '#/locale/locales/en/messages'
-import {klipyUrlToBskyGifUrl} from '#/state/queries/klipy'
-import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor'
+import {klipyUrlToBskyGifUrl} from '#/features/gifPicker/utils'
import {cleanError} from '../../src/lib/strings/errors'
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
import {enforceLen} from '../../src/lib/strings/helpers'
@@ -1072,21 +1071,6 @@ describe('createStarterPackGooglePlayUri', () => {
})
})
-describe('tenorUrlToBskyGifUrl', () => {
- const inputs = [
- 'https://media.tenor.com/someID_AAAAC/someName.gif',
- 'https://media.tenor.com/someID/someName.gif',
- ]
-
- it.each(inputs)(
- 'returns url with t.gifs.bsky.app as hostname for input url',
- input => {
- const out = tenorUrlToBskyGifUrl(input)
- expect(out.startsWith('https://t.gifs.bsky.app/')).toEqual(true)
- },
- )
-})
-
describe('klipyUrlToBskyGifUrl', () => {
const inputs = [
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx
deleted file mode 100644
index bc58cbf137..0000000000
--- a/src/components/dialogs/GifSelect.tsx
+++ /dev/null
@@ -1,334 +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 {Trans, useLingui} 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 {t: l} = 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 klipyEnabled = ax.features.enabled(ax.features.KlipyGifProviderEnable)
-
- const isSearching = search.length > 0
-
- const klipyTrending = useKlipyFeaturedGifsQuery({enabled: klipyEnabled})
- const klipySearch = useKlipyGifSearchQuery(search, {enabled: klipyEnabled})
- const tenorTrending = useTenorFeaturedGifsQuery({enabled: !klipyEnabled})
- const tenorSearch = useTenorGifSearchQuery(search, {enabled: !klipyEnabled})
-
- const {
- data,
- fetchNextPage,
- isFetchingNextPage,
- hasNextPage,
- error,
- isPending,
- isError,
- refetch,
- } = klipyEnabled
- ? 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, l, control, klipyEnabled])
-
- 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 {t: l} = useLingui()
- const control = Dialog.useDialogContext()
-
- return (
-
-
-
-
-
- )
-}
-
-export function GifPreview({
- gif,
- onSelectGif,
-}: {
- gif: Gif
- onSelectGif: (gif: Gif) => void
-}) {
- const ax = useAnalytics()
- const {gtTablet} = useBreakpoints()
- const {t: l} = useLingui()
- const t = useTheme()
-
- const onPress = useCallback(() => {
- ax.metric('composer:gif:select', {})
- onSelectGif(gif)
- }, [ax, onSelectGif, gif])
-
- return (
-
- )
-}
diff --git a/src/components/icons/Thumb.tsx b/src/components/icons/Thumb.tsx
new file mode 100644
index 0000000000..b8247a4d24
--- /dev/null
+++ b/src/components/icons/Thumb.tsx
@@ -0,0 +1,6 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const ThumbUp_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ viewBox: '0 0 21 19',
+ path: 'M9.61523 0C11.4562 0 12.8635 1.6429 12.5801 3.46191L12.1836 6H16.0635C18.4871 6.0002 20.3536 8.13859 20.0264 10.54L19.3447 15.54C19.0745 17.522 17.3811 19 15.3809 19H2C0.895431 19 0 18.1046 0 17V10C0 8.89543 0.895431 8 2 8H4.38184L8.10547 0.552734L8.17676 0.431641C8.36169 0.163675 8.66854 0 9 0H9.61523ZM6 9.23633V17H15.3809C16.3809 17 17.228 16.2614 17.3633 15.2705L18.0449 10.2705C18.2087 9.06976 17.2753 8.0002 16.0635 8H11.0166C10.7246 8 10.4468 7.87218 10.2568 7.65039C10.0669 7.42849 9.98332 7.13434 10.0283 6.8457L10.6035 3.1543C10.698 2.54844 10.2301 2.00093 9.61719 2L6 9.23633ZM2 17H4V10H2V17Z',
+})
diff --git a/src/features/gifPicker/GifPickerDialog.tsx b/src/features/gifPicker/GifPickerDialog.tsx
new file mode 100644
index 0000000000..6f51364408
--- /dev/null
+++ b/src/features/gifPicker/GifPickerDialog.tsx
@@ -0,0 +1,204 @@
+import {useEffect, 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} from '#/alf'
+import * as Dialog from '#/components/Dialog'
+import {useThrottledValue} from '#/components/hooks/useThrottledValue'
+import {
+ GIF_CATEGORIES,
+ type GifCategory,
+ GifCategoryPills,
+} from '#/features/gifPicker/components/GifCategoryPills'
+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 {useRecentGifs} from '#/features/gifPicker/hooks/useRecentGifs'
+import {type Gif} from '#/features/gifPicker/types'
+
+export function GifPickerDialog({
+ control,
+ onClose,
+ onSelectGif: onSelectGifProp,
+}: {
+ control: Dialog.DialogControlProps
+ onClose?: () => void
+ onSelectGif: (gif: Gif) => void
+}) {
+ const onSelectGif = (gif: Gif) => {
+ control.close(() => onSelectGifProp(gif))
+ }
+
+ return (
+
+
+ (
+
+ )}>
+
+
+
+ )
+}
+
+function GifPickerBody({
+ control,
+ onSelectGif,
+}: {
+ control: Dialog.DialogControlProps
+ onSelectGif: (gif: Gif) => void
+}) {
+ const textInputRef = useRef(null)
+ const listRef = useRef(null)
+ const [rawSearch, setRawSearch] = useState('')
+ const [activeCategory, setActiveCategory] = useState('trending')
+ const search = useThrottledValue(rawSearch, 750)
+ const {getRecents, addRecent, hasRecents} = useRecentGifs()
+
+ // Determine the effective search query:
+ // - If user is typing, use the throttled text
+ // - If user clears the input, immediately drop the search (don't wait for
+ // the throttle to catch up — otherwise the previous query keeps driving
+ // the visible results until the next interval tick)
+ // - If a non-trending category is active, use its searchterm
+ // - Otherwise (trending/recents), empty string triggers the featured endpoint
+ const activeCategorySearchterm =
+ GIF_CATEGORIES.find(c => c.id === activeCategory)?.searchterm ?? ''
+ const effectiveSearch =
+ rawSearch.length > 0 && search.length > 0
+ ? search
+ : activeCategorySearchterm
+
+ const isRecentsActive = activeCategory === 'recents' && rawSearch.length === 0
+
+ const {
+ data,
+ fetchNextPage,
+ isFetchingNextPage,
+ hasNextPage,
+ error,
+ isPending,
+ isError,
+ isSearching,
+ refetch,
+ } = useGifPickerData(effectiveSearch, {enabled: !isRecentsActive})
+
+ const networkItems = dedupeById(
+ data?.pages.flatMap(page => page.results) ?? [],
+ )
+ const items = isRecentsActive ? getRecents() : networkItems
+ const hasData = items.length > 0
+
+ const onEndReached = () => {
+ if (isRecentsActive) return
+ if (isFetchingNextPage || !hasNextPage || error) return
+ void fetchNextPage()
+ }
+
+ // Scroll to top when the effective query/category changes, NOT on every
+ // keystroke. Calling scrollToOffset on the FlatList while its sticky header
+ // holds the focused input blurs that input on web.
+ useEffect(() => {
+ listRef.current?.scrollToOffset({offset: 0, animated: false})
+ }, [effectiveSearch, isRecentsActive])
+
+ const onClearSearch = () => {
+ textInputRef.current?.clear()
+ setRawSearch('')
+ setActiveCategory('trending')
+ textInputRef.current?.focus()
+ }
+
+ const onGoBack = () => {
+ if (isSearching || activeCategory !== 'trending') {
+ onClearSearch()
+ } else {
+ control.close()
+ }
+ }
+
+ const onChangeSearch = (text: string) => {
+ setRawSearch(text)
+ }
+
+ const onSelectCategory = (category: GifCategory) => {
+ setActiveCategory(category.id)
+ }
+
+ const handleSelectGif = (gif: Gif) => {
+ addRecent(gif)
+ onSelectGif(gif)
+ }
+
+ const showPills = rawSearch.length === 0
+
+ const header = (
+ <>
+ 0}
+ onEscape={() => control.close()}
+ />
+ {showPills && (
+
+ )}
+ {!hasData && (
+
+ )}
+ >
+ )
+
+ return (
+ <>
+
+
+ >
+ )
+}
+
+function dedupeById(items: Gif[]): Gif[] {
+ const seen = new Set()
+ const out: Gif[] = []
+ for (const item of items) {
+ if (seen.has(item.id)) continue
+ seen.add(item.id)
+ out.push(item)
+ }
+ return out
+}
diff --git a/src/features/gifPicker/components/GifCategoryPills.tsx b/src/features/gifPicker/components/GifCategoryPills.tsx
new file mode 100644
index 0000000000..44edd69323
--- /dev/null
+++ b/src/features/gifPicker/components/GifCategoryPills.tsx
@@ -0,0 +1,144 @@
+import {View} from 'react-native'
+import {type MessageDescriptor} from '@lingui/core'
+import {msg} from '@lingui/core/macro'
+import {useLingui} from '@lingui/react/macro'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import {Celebrate_Stroke2_Corner0_Rounded as Celebrate} from '#/components/icons/Celebrate'
+import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock'
+import {type Props as SVGIconProps} from '#/components/icons/common'
+import {
+ EmojiSad_Stroke2_Corner0_Rounded as EmojiSad,
+ EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile,
+} from '#/components/icons/Emoji'
+import {Heart2_Stroke2_Corner0_Rounded as Heart} from '#/components/icons/Heart2'
+import {Shaka_Stroke2_Corner0_Rounded as Shaka} from '#/components/icons/Shaka'
+import {Trending3_Stroke2_Corner1_Rounded as Trending} from '#/components/icons/Trending'
+
+export type GifCategory = {
+ id: string
+ icon: React.ComponentType
+ label: MessageDescriptor
+ searchterm: string | null // null = trending/recents (handled by consumer)
+}
+
+/*
+ * Category pill labels are icon-only buttons in the UI; the `label` field is
+ * what screen readers announce. Each is phrased "[topic] GIFs" so the
+ * announcement makes sense in isolation rather than just "Love" or "Happy".
+ */
+export const GIF_CATEGORIES: readonly GifCategory[] = [
+ {
+ id: 'recents',
+ icon: Clock,
+ label: msg({
+ message: 'Recent GIFs',
+ comment:
+ 'Accessibility label for the icon-only pill that shows previously selected GIFs in the GIF picker.',
+ }),
+ searchterm: null,
+ },
+ {
+ id: 'trending',
+ icon: Trending,
+ label: msg({
+ message: 'Trending GIFs',
+ comment:
+ 'Accessibility label for the icon-only pill that shows currently trending/featured GIFs in the GIF picker.',
+ }),
+ searchterm: null,
+ },
+ {
+ id: 'love',
+ icon: Heart,
+ label: msg({
+ message: 'Love GIFs',
+ comment:
+ 'Accessibility label for the icon-only pill that filters the GIF picker to GIFs about love/affection.',
+ }),
+ searchterm: 'love',
+ },
+ {
+ id: 'happy',
+ icon: EmojiSmile,
+ label: msg({
+ message: 'Happy GIFs',
+ comment:
+ 'Accessibility label for the icon-only pill that filters the GIF picker to happy/joyful GIFs.',
+ }),
+ searchterm: 'happy',
+ },
+ {
+ id: 'sad',
+ icon: EmojiSad,
+ label: msg({
+ message: 'Sad GIFs',
+ comment:
+ 'Accessibility label for the icon-only pill that filters the GIF picker to sad/crying GIFs.',
+ }),
+ searchterm: 'cry',
+ },
+ {
+ id: 'party',
+ icon: Celebrate,
+ label: msg({
+ message: 'Party GIFs',
+ comment:
+ 'Accessibility label for the icon-only pill that filters the GIF picker to celebration/party GIFs.',
+ }),
+ searchterm: 'congratulations',
+ },
+ {
+ id: 'yes',
+ icon: Shaka,
+ label: msg({
+ message: 'Yes GIFs',
+ comment:
+ 'Accessibility label for the icon-only pill that filters the GIF picker to affirmation/agreement GIFs.',
+ }),
+ searchterm: 'yes',
+ },
+] as const
+
+export function GifCategoryPills({
+ activeId,
+ onSelect,
+ hasRecents,
+}: {
+ activeId: string
+ onSelect: (category: GifCategory) => void
+ hasRecents: boolean
+}) {
+ const {i18n} = useLingui()
+ const t = useTheme()
+
+ return (
+
+ {GIF_CATEGORIES.map(category => {
+ if (category.id === 'recents' && !hasRecents) return null
+ const isActive = category.id === activeId
+ return (
+
+ )
+ })}
+
+ )
+}
diff --git a/src/features/gifPicker/components/GifPickerErrorBoundary.tsx b/src/features/gifPicker/components/GifPickerErrorBoundary.tsx
new file mode 100644
index 0000000000..a57ad46551
--- /dev/null
+++ b/src/features/gifPicker/components/GifPickerErrorBoundary.tsx
@@ -0,0 +1,52 @@
+import {Trans, useLingui} 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 {t: l} = 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..a34aeacab0
--- /dev/null
+++ b/src/features/gifPicker/components/GifPickerGrid.tsx
@@ -0,0 +1,122 @@
+import {forwardRef} from 'react'
+import {Platform, 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 = distributeIntoColumns(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 = 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={1}
+ // On web, "on-drag" blurs the focused input on ANY scroll event,
+ // including programmatic scrolls (e.g., content shrinking when search
+ // results swap in). That breaks search-while-scrolled — the blur fires
+ // mid-typing and subsequent keystrokes go nowhere.
+ keyboardDismissMode={Platform.OS === 'web' ? 'none' : '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..ee50cdb8e4
--- /dev/null
+++ b/src/features/gifPicker/components/GifPickerHeader.tsx
@@ -0,0 +1,78 @@
+import {type Ref} from 'react'
+import {type TextInput, View} from 'react-native'
+import {useLingui} from '@lingui/react/macro'
+
+import {atoms as a, native, useTheme} from '#/alf'
+import {Button, ButtonIcon} from '#/components/Button'
+import * as TextField from '#/components/forms/TextField'
+import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
+import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
+
+export function GifPickerHeader({
+ inputRef,
+ onChangeText,
+ onClear,
+ onEscape,
+ canClear,
+}: {
+ inputRef: Ref
+ onChangeText: (text: string) => void
+ onClear: () => void
+ onEscape: () => void
+ canClear: boolean
+}) {
+ const {t: l} = useLingui()
+ const t = useTheme()
+
+ return (
+
+
+
+ {
+ if (nativeEvent.key === 'Escape') {
+ onEscape()
+ }
+ }}
+ />
+ {canClear && (
+
+ )}
+
+
+ )
+}
diff --git a/src/features/gifPicker/components/GifPickerItem.tsx b/src/features/gifPicker/components/GifPickerItem.tsx
new file mode 100644
index 0000000000..e307e064a3
--- /dev/null
+++ b/src/features/gifPicker/components/GifPickerItem.tsx
@@ -0,0 +1,60 @@
+import {Image} from 'expo-image'
+import {useLingui} from '@lingui/react/macro'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Button} from '#/components/Button'
+import {useAnalytics} from '#/analytics'
+import {type Gif} from '#/features/gifPicker/types'
+import {gifPreviewUrl} from '#/features/gifPicker/utils'
+
+export function GifPickerItem({
+ gif,
+ onSelectGif,
+}: {
+ gif: Gif
+ onSelectGif: (gif: Gif) => void
+}) {
+ const ax = useAnalytics()
+ const {t: l} = 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..d498a8285e
--- /dev/null
+++ b/src/features/gifPicker/components/GifPickerPlaceholder.tsx
@@ -0,0 +1,65 @@
+import {useLingui} from '@lingui/react/macro'
+
+import {ListMaybePlaceholder} from '#/components/Lists'
+
+export function GifPickerPlaceholder({
+ isLoading,
+ isError,
+ isSearching,
+ isRecentsEmpty,
+ query,
+ onRetry,
+ onGoBack,
+}: {
+ isLoading: boolean
+ isError: boolean
+ isSearching: boolean
+ isRecentsEmpty: boolean
+ query: string
+ onRetry: () => Promise
+ onGoBack: () => void
+}) {
+ const {t: l} = useLingui()
+
+ const emptyMessage = isSearching
+ ? l({
+ message: `No GIFs found for "${query}".`,
+ comment:
+ 'Empty-state message shown in the GIF picker when a search returns zero results. Placeholder is the user’s search query.',
+ })
+ : isRecentsEmpty
+ ? l({
+ message: 'No recent GIFs yet. Pick one to see it here.',
+ comment:
+ 'Empty-state message shown in the GIF picker’s Recents tab before the user has selected any GIFs.',
+ })
+ : l({
+ message: 'No GIFs to show right now. Try again in a moment.',
+ comment:
+ 'Empty-state message shown when the trending/featured GIF feed returns no results (rare, usually a transient provider issue).',
+ })
+
+ return (
+
+ )
+}
diff --git a/src/features/gifPicker/hooks/useGifPickerData.ts b/src/features/gifPicker/hooks/useGifPickerData.ts
new file mode 100644
index 0000000000..f598b5b719
--- /dev/null
+++ b/src/features/gifPicker/hooks/useGifPickerData.ts
@@ -0,0 +1,25 @@
+import {
+ useFeaturedGifsQuery,
+ useGifSearchQuery,
+} from '#/features/gifPicker/queries'
+
+/**
+ * Single entry point for the GIF picker's data layer. Routes between the
+ * featured and search endpoints so the UI only ever consumes one query result.
+ */
+export function useGifPickerData(
+ query: string,
+ {enabled = true}: {enabled?: boolean} = {},
+) {
+ const isSearching = query.length > 0
+
+ const featured = useFeaturedGifsQuery({enabled: enabled && !isSearching})
+ const search = useGifSearchQuery(query, {enabled: enabled && isSearching})
+
+ const active = isSearching ? search : featured
+
+ return {
+ ...active,
+ isSearching,
+ }
+}
diff --git a/src/features/gifPicker/hooks/useRecentGifs.ts b/src/features/gifPicker/hooks/useRecentGifs.ts
new file mode 100644
index 0000000000..a2230005bd
--- /dev/null
+++ b/src/features/gifPicker/hooks/useRecentGifs.ts
@@ -0,0 +1,40 @@
+import {useSession} from '#/state/session'
+import {type Gif} from '#/features/gifPicker/types'
+import {account} from '#/storage'
+
+const MAX_RECENT_GIFS = 20
+
+function readValid(did: string): Gif[] {
+ const stored = account.get([did, 'recentGifs']) ?? []
+ // Earlier builds of this branch stored recents as JSON-serialized strings.
+ // Drop any malformed entries so a dev with stale local data doesn't crash.
+ if (stored.some(item => typeof item !== 'object' || item === null)) {
+ account.remove([did, 'recentGifs'])
+ return []
+ }
+ return stored
+}
+
+export function useRecentGifs() {
+ const {currentAccount} = useSession()
+ const did = currentAccount?.did
+
+ const getRecents = (): Gif[] => {
+ if (!did) return []
+ return readValid(did)
+ }
+
+ const addRecent = (gif: Gif) => {
+ if (!did) return
+ const existing = readValid(did)
+ const deduped = existing.filter(g => g.id !== gif.id)
+ const updated = [gif, ...deduped].slice(0, MAX_RECENT_GIFS)
+ account.set([did, 'recentGifs'], updated)
+ }
+
+ return {
+ getRecents,
+ addRecent,
+ hasRecents: did ? readValid(did).length > 0 : false,
+ }
+}
diff --git a/src/state/queries/klipy.ts b/src/features/gifPicker/queries.ts
similarity index 77%
rename from src/state/queries/klipy.ts
rename to src/features/gifPicker/queries.ts
index f87e031e31..b8055fc8eb 100644
--- a/src/state/queries/klipy.ts
+++ b/src/features/gifPicker/queries.ts
@@ -1,10 +1,9 @@
import {Platform} from 'react-native'
import {getLocales} from 'expo-localization'
-import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query'
+import {useInfiniteQuery} from '@tanstack/react-query'
import {GIF_KLIPY_FEATURED, GIF_KLIPY_SEARCH} from '#/lib/constants'
-import {logger} from '#/logger'
-import {type Gif} from '#/state/queries/tenor'
+import {type Gif} from '#/features/gifPicker/types'
export const RQKEY_ROOT = 'klipy-gif-service'
export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
@@ -33,7 +32,6 @@ export function useGifSearchQuery(
initialPageParam: undefined as string | undefined,
getNextPageParam: lastPage => lastPage.next,
enabled: !!query && options?.enabled !== false,
- placeholderData: keepPreviousData,
})
}
@@ -88,21 +86,3 @@ function createKlipyApi(
}
}
}
-
-/**
- * Rewrites a KLIPY static CDN URL through the bsky proxy
- * (k.gifs.bsky.app). Mirrors `tenorUrlToBskyGifUrl`, but uses a
- * separate hostname from Tenor's t.gifs.bsky.app so the two
- * upstreams can be routed independently.
- */
-export function klipyUrlToBskyGifUrl(klipyUrl: string) {
- let url
- try {
- url = new URL(klipyUrl)
- } catch (e) {
- logger.debug('invalid url passed to klipyUrlToBskyGifUrl()')
- return ''
- }
- url.hostname = 'k.gifs.bsky.app'
- return url.href
-}
diff --git a/src/features/gifPicker/types.ts b/src/features/gifPicker/types.ts
new file mode 100644
index 0000000000..02c607dc5b
--- /dev/null
+++ b/src/features/gifPicker/types.ts
@@ -0,0 +1,33 @@
+/**
+ * GIF shape returned by the Bluesky GIF proxy. The field names follow the
+ * Tenor schema; Klipy responses are normalized to the same shape by the
+ * proxy so downstream code can be provider-agnostic.
+ */
+export type Gif = {
+ created: number
+ hasaudio: boolean
+ id: string
+ media_formats: Record &
+ Partial>
+ tags: string[]
+ title: string
+ content_description: string
+ itemurl: string
+ hascaption: boolean
+ flags: string
+ bg_color?: string
+ url: string
+}
+
+type MediaObject = {
+ url: string
+ dims: [number, number]
+ duration: number
+ size: number
+}
+
+type BaseContentFormats = 'preview' | 'gif' | 'tinygif'
+
+type VideoContentFormats = 'mp4' | 'webm'
+
+export type ContentFormats = BaseContentFormats | VideoContentFormats
diff --git a/src/features/gifPicker/utils.ts b/src/features/gifPicker/utils.ts
new file mode 100644
index 0000000000..233a0e42e6
--- /dev/null
+++ b/src/features/gifPicker/utils.ts
@@ -0,0 +1,40 @@
+import {logger} from '#/logger'
+
+/**
+ * Rewrites a provider's CDN URL (Tenor or Klipy) to the corresponding
+ * bsky proxy hostname. Leaves unrecognized hosts untouched.
+ */
+export function gifPreviewUrl(gifUrl: string) {
+ try {
+ const url = new URL(gifUrl)
+ if (url.hostname === 'media.tenor.com') {
+ url.hostname = 't.gifs.bsky.app'
+ return url.href
+ }
+ if (url.hostname === 'static.klipy.com') {
+ url.hostname = 'k.gifs.bsky.app'
+ return url.href
+ }
+ return gifUrl
+ } catch (e) {
+ logger.debug('invalid url passed to gifPreviewUrl()')
+ return ''
+ }
+}
+
+/**
+ * Rewrites a KLIPY static CDN URL through the bsky proxy
+ * (k.gifs.bsky.app) so downstream consumers can route requests
+ * through Bluesky-owned infrastructure.
+ */
+export function klipyUrlToBskyGifUrl(klipyUrl: string) {
+ let url
+ try {
+ url = new URL(klipyUrl)
+ } catch (e) {
+ logger.debug('invalid url passed to klipyUrlToBskyGifUrl()')
+ return ''
+ }
+ url.hostname = 'k.gifs.bsky.app'
+ return url.href
+}
diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts
index e6d21845fd..1b798bd85b 100644
--- a/src/lib/api/resolve.ts
+++ b/src/lib/api/resolve.ts
@@ -1,10 +1,10 @@
import {
type AppBskyFeedDefs,
type AppBskyGraphDefs,
+ type BskyAgent,
type ComAtprotoRepoStrongRef,
} from '@atproto/api'
import {AtUri} from '@atproto/api'
-import {type BskyAgent} from '@atproto/api'
import {POST_IMG_MAX} from '#/lib/constants'
import {getLinkMeta} from '#/lib/link-meta/link-meta'
@@ -15,18 +15,19 @@ import {
parseStarterPackUri,
} from '#/lib/strings/starter-pack'
import {
+ convertBskyAppUrlIfNeeded,
isBskyCustomFeedUrl,
isBskyListUrl,
isBskyPostUrl,
isBskyStarterPackUrl,
isBskyStartUrl,
isShortLink,
+ makeRecordUri,
} from '#/lib/strings/url-helpers'
import {type ComposerImage} from '#/state/gallery'
import {createComposerImage} from '#/state/gallery'
-import {type Gif} from '#/state/queries/tenor'
+import {type Gif} from '#/features/gifPicker/types'
import {createGIFDescription} from '../gif-alt-text'
-import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
type ResolvedExternalLink = {
type: 'external'
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 8364cd2f6c..7a5b74843b 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -173,11 +173,6 @@ export const KNOWN_SHUTDOWN_FEEDS = [
export const GIF_SERVICE = 'https://gifs.bsky.app'
-export const GIF_SEARCH = (params: string) =>
- `${GIF_SERVICE}/tenor/v2/search?${params}`
-export const GIF_FEATURED = (params: string) =>
- `${GIF_SERVICE}/tenor/v2/featured?${params}`
-
export const GIF_KLIPY_SEARCH = (params: string) =>
`${GIF_SERVICE}/klipy/v2/search?${params}`
export const GIF_KLIPY_FEATURED = (params: string) =>
diff --git a/src/state/queries/resolve-link.ts b/src/state/queries/resolve-link.ts
index b0f694b464..a6b21e0bcc 100644
--- a/src/state/queries/resolve-link.ts
+++ b/src/state/queries/resolve-link.ts
@@ -4,7 +4,7 @@ import {type QueryClient, useQuery} from '@tanstack/react-query'
import {type ResolvedLink, resolveGif, resolveLink} from '#/lib/api/resolve'
import {STALE} from '#/state/queries/index'
import {useAgent} from '#/state/session'
-import {type Gif} from './tenor'
+import {type Gif} from '#/features/gifPicker/types'
export const RQKEY_LINK_ROOT = 'resolve-link'
export const RQKEY_LINK = (url: string) => [RQKEY_LINK_ROOT, url]
diff --git a/src/state/queries/tenor.ts b/src/state/queries/tenor.ts
deleted file mode 100644
index 835f21f066..0000000000
--- a/src/state/queries/tenor.ts
+++ /dev/null
@@ -1,218 +0,0 @@
-import {Platform} from 'react-native'
-import {getLocales} from 'expo-localization'
-import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query'
-
-import {GIF_FEATURED, GIF_SEARCH} from '#/lib/constants'
-import {logger} from '#/logger'
-
-export const RQKEY_ROOT = 'gif-service'
-export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
-export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query]
-
-const getTrendingGifs = createTenorApi(GIF_FEATURED)
-
-const searchGifs = createTenorApi<{q: string}>(GIF_SEARCH)
-
-export function useTenorFeaturedGifsQuery(options?: {enabled?: boolean}) {
- return useInfiniteQuery({
- queryKey: RQKEY_FEATURED,
- queryFn: ({pageParam}) => getTrendingGifs({pos: pageParam}),
- initialPageParam: undefined as string | undefined,
- getNextPageParam: lastPage => lastPage.next,
- enabled: options?.enabled,
- })
-}
-
-export function useTenorGifSearchQuery(
- query: string,
- options?: {enabled?: boolean},
-) {
- return useInfiniteQuery({
- queryKey: RQKEY_SEARCH(query),
- queryFn: ({pageParam}) => searchGifs({q: query, pos: pageParam}),
- initialPageParam: undefined as string | undefined,
- getNextPageParam: lastPage => lastPage.next,
- enabled: !!query && options?.enabled !== false,
- placeholderData: keepPreviousData,
- })
-}
-
-function createTenorApi(
- urlFn: (params: string) => string,
-): (input: Input & {pos?: string}) => Promise<{
- next: string
- results: Gif[]
-}> {
- return async input => {
- const params = new URLSearchParams()
-
- // set client key based on platform
- params.set(
- 'client_key',
- Platform.select({
- ios: 'bluesky-ios',
- android: 'bluesky-android',
- default: 'bluesky-web',
- }),
- )
-
- // 30 is divisible by 2 and 3, so both 2 and 3 column layouts can be used
- params.set('limit', '30')
-
- params.set('contentfilter', 'high')
-
- params.set(
- 'media_filter',
- (['preview', 'gif', 'tinygif'] satisfies ContentFormats[]).join(','),
- )
-
- const locale = getLocales?.()?.[0]
-
- if (locale) {
- params.set('locale', locale.languageTag.replace('-', '_'))
- }
-
- for (const [key, value] of Object.entries(input)) {
- if (value !== undefined) {
- params.set(key, String(value))
- }
- }
-
- const res = await fetch(urlFn(params.toString()), {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
- })
- if (!res.ok) {
- throw new Error('Failed to fetch Tenor API')
- }
- return res.json()
- }
-}
-
-export function tenorUrlToBskyGifUrl(tenorUrl: string) {
- let url
- try {
- url = new URL(tenorUrl)
- } catch (e) {
- logger.debug('invalid url passed to tenorUrlToBskyGifUrl()')
- return ''
- }
- url.hostname = 't.gifs.bsky.app'
- return url.href
-}
-
-/**
- * Returns the appropriate URL for a GIF preview image.
- * Tenor URLs (media.tenor.com) are routed through t.gifs.bsky.app;
- * KLIPY URLs (static.klipy.com) are routed through k.gifs.bsky.app.
- */
-export function gifPreviewUrl(gifUrl: string) {
- try {
- const url = new URL(gifUrl)
- if (url.hostname === 'media.tenor.com') {
- url.hostname = 't.gifs.bsky.app'
- return url.href
- }
- if (url.hostname === 'static.klipy.com') {
- url.hostname = 'k.gifs.bsky.app'
- return url.href
- }
- return gifUrl
- } catch (e) {
- logger.debug('invalid url passed to gifPreviewUrl()')
- return ''
- }
-}
-
-export type Gif = {
- /**
- * A Unix timestamp that represents when this post was created.
- */
- created: number
- /**
- * Returns true if this post contains audio.
- * Note: Only video formats support audio. The GIF image file format can't contain audio information.
- */
- hasaudio: boolean
- /**
- * Tenor result identifier
- */
- id: string
- /**
- * A dictionary with a content format as the key and a Media Object as the value.
- */
- media_formats: Record &
- Partial>
- /**
- * An array of tags for the post
- */
- tags: string[]
- /**
- * The title of the post
- */
- title: string
- /**
- * A textual description of the content.
- * We recommend that you use content_description for user accessibility features.
- */
- content_description: string
- /**
- * The full URL to view the post on tenor.com.
- */
- itemurl: string
- /**
- * Returns true if this post contains captions.
- */
- hascaption: boolean
- /**
- * Comma-separated list to signify whether the content is a sticker or static image, has audio, or is any combination of these. If sticker and static aren't present, then the content is a GIF. A blank flags field signifies a GIF without audio.
- */
- flags: string
- /**
- * The most common background pixel color of the content
- */
- bg_color?: string
- /**
- * A short URL to view the post on tenor.com.
- */
- url: string
-}
-
-type MediaObject = {
- /**
- * A URL to the media source
- */
- url: string
- /**
- * Width and height of the media in pixels
- */
- dims: [number, number]
- /**
- * Represents the time in seconds for one loop of the content. If the content is static, the duration is set to 0.
- */
- duration: number
- /**
- * Size of the file in bytes
- */
- size: number
-}
-
-type BaseContentFormats =
- | 'preview'
- | 'gif'
- // | 'mediumgif'
- | 'tinygif'
-// | 'nanogif'
-
-type VideoContentFormats =
- | 'mp4'
- // | 'loopedmp4'
- // | 'tinymp4'
- // | 'nanomp4'
- | 'webm'
-// | 'tinywebm'
-// | 'nanowebm'
-
-type ContentFormats = BaseContentFormats | VideoContentFormats
diff --git a/src/storage/schema.ts b/src/storage/schema.ts
index 2dd99ace2d..e6dc865450 100644
--- a/src/storage/schema.ts
+++ b/src/storage/schema.ts
@@ -1,4 +1,5 @@
import {type ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config'
+import {type Gif} from '#/features/gifPicker/types'
import {type Geolocation} from '#/geolocation/types'
/**
@@ -79,4 +80,9 @@ export type Account = {
birthdateLastUpdatedAt?: string
lastSelectedHomeFeed?: string
+
+ /**
+ * Recently selected GIFs in the GIF picker. Most recent first, capped at 20.
+ */
+ recentGifs?: Gif[]
}
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index eb3f5f25cd..1d588c9b8b 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -95,7 +95,6 @@ import {
} from '#/state/preferences/languages'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useProfileQuery} from '#/state/queries/profile'
-import {type Gif} from '#/state/queries/tenor'
import {useAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer'
@@ -135,6 +134,7 @@ import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
+import {type Gif} from '#/features/gifPicker/types'
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
import {
draftToComposerPosts,
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index 0db82a03f8..19ac9b9dcc 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -6,7 +6,6 @@ import {
useResolveGifQuery,
useResolveLinkQuery,
} from '#/state/queries/resolve-link'
-import {type Gif} from '#/state/queries/tenor'
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
import {atoms as a, useTheme} from '#/alf'
import {Loader} from '#/components/Loader'
@@ -15,6 +14,7 @@ import {ModeratedFeedEmbed} from '#/components/Post/Embed/FeedEmbed'
import {ModeratedListEmbed} from '#/components/Post/Embed/ListEmbed'
import {Embed as StarterPackEmbed} from '#/components/StarterPack/StarterPackCard'
import {Text} from '#/components/Typography'
+import {type Gif} from '#/features/gifPicker/types'
export const ExternalEmbedGif = ({
onRemove,
diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx
index e32df485c3..275b4be89a 100644
--- a/src/view/com/composer/GifAltText.tsx
+++ b/src/view/com/composer/GifAltText.tsx
@@ -11,7 +11,6 @@ import {
parseEmbedPlayerFromUrl,
} from '#/lib/strings/embed-player'
import {useResolveGifQuery} from '#/state/queries/resolve-link'
-import {type Gif} from '#/state/queries/tenor'
import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper'
import {atoms as a, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
@@ -24,6 +23,7 @@ import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/ico
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {GifEmbed} from '#/components/Post/Embed/ExternalEmbed/Gif'
import {Text} from '#/components/Typography'
+import {type Gif} from '#/features/gifPicker/types'
export function GifAltTextDialog({
gif,
diff --git a/src/view/com/composer/drafts/state/api.ts b/src/view/com/composer/drafts/state/api.ts
index b07c290733..1099c1a0fd 100644
--- a/src/view/com/composer/drafts/state/api.ts
+++ b/src/view/com/composer/drafts/state/api.ts
@@ -10,7 +10,6 @@ import {getImageDim} from '#/lib/media/manip'
import {mimeToExt} from '#/lib/media/video/util'
import {shortenLinks} from '#/lib/strings/rich-text-manip'
import {type ComposerImage} from '#/state/gallery'
-import {type Gif} from '#/state/queries/tenor'
import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util'
import {createPublicAgent} from '#/state/session/agent'
import {
@@ -21,6 +20,7 @@ import {
import {type VideoState} from '#/view/com/composer/state/video'
import {type AnalyticsContextType} from '#/analytics'
import {getDeviceId} from '#/analytics/identifiers'
+import {type Gif} from '#/features/gifPicker/types'
import {logger} from './logger'
import {type DraftPostDisplay, type DraftSummary} from './schema'
import * as storage from './storage'
diff --git a/src/view/com/composer/photos/SelectGifBtn.tsx b/src/view/com/composer/photos/SelectGifBtn.tsx
index d7dfe0c079..151578bbed 100644
--- a/src/view/com/composer/photos/SelectGifBtn.tsx
+++ b/src/view/com/composer/photos/SelectGifBtn.tsx
@@ -1,14 +1,15 @@
-import {useCallback, useRef} from 'react'
+import {useCallback} from 'react'
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 * as Dialog from '#/components/Dialog'
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
@@ -19,22 +20,34 @@ type Props = {
export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) {
const ax = useAnalytics()
const {_} = useLingui()
- const ref = useRef<{open: () => void}>(null)
+ const control = Dialog.useDialogControl()
const t = useTheme()
- const onPressSelectGif = useCallback(async () => {
+ const onPressSelectGif = useCallback(() => {
ax.metric('composer:gif:open', {})
Keyboard.dismiss()
- ref.current?.open()
- }, [ax])
+ control.open()
+ }, [ax, control])
return (
<>
-
diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts
index 75ea8153cf..d2b9af7b8e 100644
--- a/src/view/com/composer/state/composer.ts
+++ b/src/view/com/composer/state/composer.ts
@@ -18,7 +18,6 @@ import {
} from '#/lib/strings/url-helpers'
import {type ComposerImage, createInitialImages} from '#/state/gallery'
import {createPostgateRecord} from '#/state/queries/postgate/util'
-import {type Gif} from '#/state/queries/tenor'
import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate'
import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate'
import {type ComposerOpts} from '#/state/shell/composer'
@@ -26,6 +25,7 @@ import {
type LinkFacetMatch,
suggestLinkCardUri,
} from '#/view/com/composer/text-input/text-input-util'
+import {type Gif} from '#/features/gifPicker/types'
import {
createVideoState,
type VideoAction,