diff --git a/src/features/gifPicker/GifPickerDialog.tsx b/src/features/gifPicker/GifPickerDialog.tsx
index af92a891b6..efd89edbf2 100644
--- a/src/features/gifPicker/GifPickerDialog.tsx
+++ b/src/features/gifPicker/GifPickerDialog.tsx
@@ -153,6 +153,7 @@ function GifPickerBody({
isLoading={!isRecentsActive && isPending}
isError={!isRecentsActive && isError}
isSearching={isSearching}
+ isRecentsEmpty={isRecentsActive}
query={effectiveSearch}
onRetry={refetch}
onGoBack={onGoBack}
diff --git a/src/features/gifPicker/components/GifAutocompleteSuggestions.tsx b/src/features/gifPicker/components/GifAutocompleteSuggestions.tsx
deleted file mode 100644
index 5d5af6f115..0000000000
--- a/src/features/gifPicker/components/GifAutocompleteSuggestions.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import {Pressable, View} from 'react-native'
-import {msg} from '@lingui/core/macro'
-import {useLingui} from '@lingui/react'
-
-import {atoms as a, useTheme} from '#/alf'
-import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
-import {Text} from '#/components/Typography'
-
-const LISTBOX_ID = 'gif-autocomplete-listbox'
-
-export function suggestionItemId(index: number) {
- return `gif-autocomplete-option-${index}`
-}
-
-export {LISTBOX_ID as GIF_AUTOCOMPLETE_LISTBOX_ID}
-
-export function GifAutocompleteSuggestions({
- suggestions,
- activeIndex,
- onSelect,
-}: {
- suggestions: string[]
- activeIndex: number
- onSelect: (suggestion: string) => void
-}) {
- const {_} = useLingui()
- const t = useTheme()
-
- if (suggestions.length === 0) return null
-
- return (
-
- {suggestions.map((suggestion, index) => {
- const isActive = index === activeIndex
- return (
- onSelect(suggestion)}
- style={state => [
- a.flex_row,
- a.align_center,
- a.gap_sm,
- a.px_md,
- a.py_sm,
- (isActive || ('hovered' in state && state.hovered)) &&
- t.atoms.bg_contrast_25,
- ]}>
-
-
- {suggestion}
-
-
- )
- })}
-
- )
-}
diff --git a/src/features/gifPicker/components/GifCategoryPills.tsx b/src/features/gifPicker/components/GifCategoryPills.tsx
index b3bec77f2e..d5aec3518c 100644
--- a/src/features/gifPicker/components/GifCategoryPills.tsx
+++ b/src/features/gifPicker/components/GifCategoryPills.tsx
@@ -4,19 +4,19 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {atoms as a} from '#/alf'
-import {Button, ButtonIcon} from '#/components/Button'
+import {Button, useSharedButtonTextStyles} from '#/components/Button'
import {Celebrate_Stroke2_Corner0_Rounded as Celebrate} from '#/components/icons/Celebrate'
-import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {
- EmojiArc_Stroke2_Corner0_Rounded as EmojiArc,
- EmojiHeartEyes_Stroke2_Corner0_Rounded as EmojiHeartEyes,
EmojiSad_Stroke2_Corner0_Rounded as EmojiSad,
EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile,
} from '#/components/icons/Emoji'
-import {Flame_Stroke2_Corner1_Rounded as Flame} from '#/components/icons/Flame'
-import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled} from '#/components/icons/Heart2'
+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'
+
+const ICON_SIZE = 20
export type GifCategory = {
id: string
@@ -27,14 +27,12 @@ export type GifCategory = {
export const GIF_CATEGORIES: readonly GifCategory[] = [
{id: 'recents', icon: Clock, label: msg`Recents`, searchterm: null},
- {id: 'trending', icon: Flame, label: msg`Trending`, searchterm: null},
- {id: 'love', icon: HeartFilled, label: msg`Love`, searchterm: 'love'},
+ {id: 'trending', icon: Trending, label: msg`Trending`, searchterm: null},
+ {id: 'love', icon: Heart, label: msg`Love`, searchterm: 'love'},
{id: 'happy', icon: EmojiSmile, label: msg`Happy`, searchterm: 'happy'},
{id: 'sad', icon: EmojiSad, label: msg`Sad`, searchterm: 'cry'},
{id: 'party', icon: Celebrate, label: msg`Party`, searchterm: 'congratulations'},
- {id: 'yes', icon: Check, label: msg`Yes`, searchterm: 'yes'},
- {id: 'lol', icon: EmojiArc, label: msg`LOL`, searchterm: 'lol'},
- {id: 'excited', icon: EmojiHeartEyes, label: msg`Excited`, searchterm: 'excited'},
+ {id: 'yes', icon: Shaka, label: msg`Yes`, searchterm: 'yes'},
] as const
export function GifCategoryPills({
@@ -46,8 +44,9 @@ export function GifCategoryPills({
onSelect: (category: GifCategory) => void
hasRecents: boolean
}) {
- // useLingui() is called to re-render when the locale changes, even though we
- // translate via i18n._() below to satisfy the lingui-msg-rule lint constraint.
+ // Subscribe to locale changes so i18n._() returns the current translation.
+ // The lingui-msg-rule lint rule forbids _() with variables, so we use
+ // i18n._() directly to translate the MessageDescriptor from GIF_CATEGORIES.
useLingui()
return (
@@ -57,26 +56,36 @@ export function GifCategoryPills({
a.justify_between,
a.align_center,
a.gap_xs,
- a.px_xl,
- a.mb_sm,
+ a.mb_md,
]}>
{GIF_CATEGORIES.map(category => {
if (category.id === 'recents' && !hasRecents) return null
const isActive = category.id === activeId
- const label = i18n._(category.label)
return (
)
})}
)
}
+
+function PillIcon({icon: Icon}: {icon: React.ComponentType}) {
+ const textStyles = useSharedButtonTextStyles()
+ return (
+
+ )
+}
diff --git a/src/features/gifPicker/components/GifPickerGrid.tsx b/src/features/gifPicker/components/GifPickerGrid.tsx
index 60cc30ea87..a2ac8ad0fa 100644
--- a/src/features/gifPicker/components/GifPickerGrid.tsx
+++ b/src/features/gifPicker/components/GifPickerGrid.tsx
@@ -1,4 +1,4 @@
-import {forwardRef, useMemo} from 'react'
+import {forwardRef} from 'react'
import {useWindowDimensions, View} from 'react-native'
import {cleanError} from '#/lib/strings/errors'
@@ -38,10 +38,7 @@ export const GifPickerGrid = forwardRef(
const {height} = useWindowDimensions()
const numColumns = gtMobile ? 3 : 2
- const columns = useMemo(
- () => distributeIntoColumns(items, numColumns),
- [items, numColumns],
- )
+ const columns = distributeIntoColumns(items, numColumns)
/**
* The grid is a single FlatList row because the tiles are distributed
@@ -49,7 +46,7 @@ export const GifPickerGrid = forwardRef(
* the outer FlatList's scroll position, so pagination behaves the same
* as a conventional grid.
*/
- const data = useMemo(() => (hasData ? [columns] : []), [hasData, columns])
+ const data = hasData ? [columns] : []
return (
{!gtMobile && IS_WEB && (
@@ -66,8 +65,6 @@ export function GifPickerHeader({
}}
/>
-
- {/* future: tabs (Trending / Recents / Categories) render here */}
)
}
diff --git a/src/features/gifPicker/components/GifPickerItem.tsx b/src/features/gifPicker/components/GifPickerItem.tsx
index c69d8b4723..f7babef9f5 100644
--- a/src/features/gifPicker/components/GifPickerItem.tsx
+++ b/src/features/gifPicker/components/GifPickerItem.tsx
@@ -2,7 +2,7 @@ import {Image} from 'expo-image'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
-import {gifPreviewUrl} from '#/state/queries/tenor'
+import {gifPreviewUrl} from '#/state/queries/gif'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useAnalytics} from '#/analytics'
diff --git a/src/features/gifPicker/components/GifPickerPlaceholder.tsx b/src/features/gifPicker/components/GifPickerPlaceholder.tsx
index e478ded438..478d2aa386 100644
--- a/src/features/gifPicker/components/GifPickerPlaceholder.tsx
+++ b/src/features/gifPicker/components/GifPickerPlaceholder.tsx
@@ -7,6 +7,7 @@ export function GifPickerPlaceholder({
isLoading,
isError,
isSearching,
+ isRecentsEmpty,
query,
onRetry,
onGoBack,
@@ -14,12 +15,19 @@ export function GifPickerPlaceholder({
isLoading: boolean
isError: boolean
isSearching: boolean
+ isRecentsEmpty: boolean
query: string
onRetry: () => Promise
onGoBack: () => void
}) {
const {_} = useLingui()
+ const emptyMessage = isSearching
+ ? _(msg`No GIFs found for "${query}".`)
+ : isRecentsEmpty
+ ? _(msg`No recent GIFs yet. Pick one to see it here.`)
+ : _(msg`No GIFs to show right now. Try again in a moment.`)
+
return (
)
}
diff --git a/src/features/gifPicker/hooks/useGifAutocomplete.ts b/src/features/gifPicker/hooks/useGifAutocomplete.ts
deleted file mode 100644
index d6af1f59cf..0000000000
--- a/src/features/gifPicker/hooks/useGifAutocomplete.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import {useRef, useState} from 'react'
-
-import {useKlipyAutocompleteQuery} from '#/state/queries/klipy'
-import {useThrottledValue} from '#/components/hooks/useThrottledValue'
-import {useAnalytics} from '#/analytics'
-import {IS_WEB} from '#/env'
-
-export type GifAutocompleteState = {
- /** The suggestion strings to display */
- suggestions: string[]
- /** Whether the suggestion list should be visible */
- isVisible: boolean
- /** Index of the keyboard-highlighted suggestion (web only), -1 = none */
- activeIndex: number
- /** Call when the user selects a suggestion */
- selectSuggestion: (suggestion: string) => void
- /** Call when the raw search text changes (from the input's onChangeText) */
- handleTextChange: (text: string) => void
- /** Call with the key event from the search input (web only) */
- handleKeyDown: (key: string) => boolean
- /** Call to dismiss suggestions (e.g. escape key) */
- dismiss: () => void
-}
-
-export function useGifAutocomplete({
- onSelectSuggestion,
-}: {
- onSelectSuggestion: (text: string) => void
-}): GifAutocompleteState {
- const ax = useAnalytics()
- // TODO: revert — hardcoded for local Klipy testing
- const useKlipy = true // ax.features.enabled(ax.features.KlipyGifProviderEnable)
-
- const [rawText, setRawText] = useState('')
- const [dismissed, setDismissed] = useState(false)
- const justSelectedRef = useRef(false)
-
- const autocompleteQuery = useThrottledValue(rawText, 200)
- const {data: suggestions} = useKlipyAutocompleteQuery(autocompleteQuery, {
- enabled: useKlipy && !justSelectedRef.current,
- })
-
- const [activeIndex, setActiveIndex] = useState(-1)
-
- const isVisible =
- rawText.length > 0 &&
- !dismissed &&
- !justSelectedRef.current &&
- (suggestions?.length ?? 0) > 0
-
- const handleTextChange = (text: string) => {
- setRawText(text)
- if (justSelectedRef.current) {
- justSelectedRef.current = false
- }
- setDismissed(false)
- setActiveIndex(-1)
- }
-
- const selectSuggestion = (suggestion: string) => {
- justSelectedRef.current = true
- setRawText(suggestion)
- setActiveIndex(-1)
- onSelectSuggestion(suggestion)
- }
-
- const dismiss = () => {
- setDismissed(true)
- setActiveIndex(-1)
- }
-
- const handleKeyDown = (key: string): boolean => {
- if (!IS_WEB || !isVisible || !suggestions?.length) return false
-
- switch (key) {
- case 'ArrowDown': {
- setActiveIndex(i => (i + 1) % suggestions.length)
- return true
- }
- case 'ArrowUp': {
- setActiveIndex(i => (i <= 0 ? suggestions.length - 1 : i - 1))
- return true
- }
- case 'Enter': {
- if (activeIndex >= 0 && activeIndex < suggestions.length) {
- selectSuggestion(suggestions[activeIndex])
- return true
- }
- return false
- }
- case 'Escape': {
- dismiss()
- return true
- }
- default:
- return false
- }
- }
-
- return {
- suggestions: suggestions ?? [],
- isVisible,
- activeIndex,
- selectSuggestion,
- handleTextChange,
- handleKeyDown,
- dismiss,
- }
-}
diff --git a/src/features/gifPicker/hooks/useGifPickerData.ts b/src/features/gifPicker/hooks/useGifPickerData.ts
index fa0d58da52..0b55243c25 100644
--- a/src/features/gifPicker/hooks/useGifPickerData.ts
+++ b/src/features/gifPicker/hooks/useGifPickerData.ts
@@ -7,7 +7,6 @@ import {
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
@@ -24,7 +23,6 @@ export function useGifPickerData(
// TODO: revert — hardcoded for local Klipy testing
const useKlipy = true // ax.features.enabled(ax.features.KlipyGifProviderEnable)
const isSearching = query.length > 0
- const provider: GifPickerProvider = useKlipy ? 'klipy' : 'tenor'
const klipyFeatured = useKlipyFeaturedGifsQuery({
enabled: enabled && useKlipy && !isSearching,
@@ -49,7 +47,6 @@ export function useGifPickerData(
return {
...active,
- provider,
isSearching,
}
}
diff --git a/src/features/gifPicker/hooks/useRecentGifs.ts b/src/features/gifPicker/hooks/useRecentGifs.ts
index cd572deacb..a2230005bd 100644
--- a/src/features/gifPicker/hooks/useRecentGifs.ts
+++ b/src/features/gifPicker/hooks/useRecentGifs.ts
@@ -4,42 +4,37 @@ 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 []
- const stored = account.get([did, 'recentGifs'])
- if (!stored) return []
- try {
- return stored.map(s => JSON.parse(s) as Gif)
- } catch {
- return []
- }
+ return readValid(did)
}
const addRecent = (gif: Gif) => {
if (!did) return
- const stored = account.get([did, 'recentGifs']) ?? []
- // Remove duplicate if already in recents
- const filtered = stored.filter(s => {
- try {
- return (JSON.parse(s) as Gif).id !== gif.id
- } catch {
- return true
- }
- })
- // Prepend and cap
- const updated = [JSON.stringify(gif), ...filtered].slice(0, MAX_RECENT_GIFS)
+ 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
- ? (account.get([did, 'recentGifs'])?.length ?? 0) > 0
- : false,
+ hasRecents: did ? readValid(did).length > 0 : false,
}
}
diff --git a/src/features/gifPicker/types.ts b/src/features/gifPicker/types.ts
index 67e26bb793..f320f26b6d 100644
--- a/src/features/gifPicker/types.ts
+++ b/src/features/gifPicker/types.ts
@@ -1,3 +1 @@
-export {type Gif} from '#/state/queries/tenor'
-
-export type GifPickerProvider = 'klipy' | 'tenor'
+export {type Gif} from '#/state/queries/gif'
diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts
index 49b00fdfc0..4eb3259cae 100644
--- a/src/lib/api/resolve.ts
+++ b/src/lib/api/resolve.ts
@@ -26,7 +26,7 @@ import {
} 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 '#/state/queries/gif'
import {createGIFDescription} from '../gif-alt-text'
type ResolvedExternalLink = {
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 1c66dfd14a..8364cd2f6c 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -182,8 +182,6 @@ export const GIF_KLIPY_SEARCH = (params: string) =>
`${GIF_SERVICE}/klipy/v2/search?${params}`
export const GIF_KLIPY_FEATURED = (params: string) =>
`${GIF_SERVICE}/klipy/v2/featured?${params}`
-export const GIF_KLIPY_AUTOCOMPLETE = (params: string) =>
- `${GIF_SERVICE}/klipy/v2/autocomplete?${params}`
export const MAX_LABELERS = 20
diff --git a/src/state/queries/gif.ts b/src/state/queries/gif.ts
new file mode 100644
index 0000000000..54fea78a03
--- /dev/null
+++ b/src/state/queries/gif.ts
@@ -0,0 +1,57 @@
+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 ''
+ }
+}
+
+/**
+ * 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/state/queries/klipy.ts b/src/state/queries/klipy.ts
index 588cc593c4..a99d35195e 100644
--- a/src/state/queries/klipy.ts
+++ b/src/state/queries/klipy.ts
@@ -1,66 +1,18 @@
import {Platform} from 'react-native'
import {getLocales} from 'expo-localization'
-import {
- keepPreviousData,
- useInfiniteQuery,
- useQuery,
-} from '@tanstack/react-query'
+import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query'
-import {
- GIF_KLIPY_AUTOCOMPLETE,
- GIF_KLIPY_FEATURED,
- GIF_KLIPY_SEARCH,
-} from '#/lib/constants'
+import {GIF_KLIPY_FEATURED, GIF_KLIPY_SEARCH} from '#/lib/constants'
import {logger} from '#/logger'
-import {STALE} from '#/state/queries'
-import {type Gif} from '#/state/queries/tenor'
+import {type Gif} from '#/state/queries/gif'
export const RQKEY_ROOT = 'klipy-gif-service'
export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query]
-export const RQKEY_AUTOCOMPLETE = (query: string) => [
- RQKEY_ROOT,
- 'autocomplete',
- query,
-]
const getTrendingGifs = createKlipyApi(GIF_KLIPY_FEATURED)
const searchGifs = createKlipyApi<{q: string}>(GIF_KLIPY_SEARCH)
-async function fetchKlipyAutocomplete(query: string): Promise {
- const params = new URLSearchParams()
-
- params.set(
- 'client_key',
- Platform.select({
- ios: 'bluesky-ios',
- android: 'bluesky-android',
- default: 'bluesky-web',
- }),
- )
-
- params.set('limit', '8')
-
- const locale = getLocales?.()?.[0]
- if (locale) {
- params.set('locale', locale.languageTag.replace('-', '_'))
- }
-
- params.set('q', query)
-
- const res = await fetch(GIF_KLIPY_AUTOCOMPLETE(params.toString()), {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
- })
- if (!res.ok) {
- throw new Error('Failed to fetch KLIPY autocomplete API')
- }
- const body: KlipyAutocompleteResponse = await res.json()
- return body.results
-}
-
export function useFeaturedGifsQuery(options?: {enabled?: boolean}) {
return useInfiniteQuery({
queryKey: RQKEY_FEATURED,
@@ -85,19 +37,6 @@ export function useGifSearchQuery(
})
}
-export function useKlipyAutocompleteQuery(
- query: string,
- options?: {enabled?: boolean},
-) {
- return useQuery({
- queryKey: RQKEY_AUTOCOMPLETE(query),
- queryFn: () => fetchKlipyAutocomplete(query),
- enabled: query.length > 0 && options?.enabled !== false,
- staleTime: STALE.HOURS.ONE,
- placeholderData: keepPreviousData,
- })
-}
-
function createKlipyApi(
urlFn: (params: string) => string,
): (input: Input & {pos?: string}) => Promise<{
@@ -167,8 +106,3 @@ export function klipyUrlToBskyGifUrl(klipyUrl: string) {
url.hostname = 'k.gifs.bsky.app'
return url.href
}
-
-type KlipyAutocompleteResponse = {
- locale: string
- results: string[]
-}
diff --git a/src/state/queries/resolve-link.ts b/src/state/queries/resolve-link.ts
index b0f694b464..723d51c911 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 './gif'
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
index 835f21f066..391fe0f44f 100644
--- a/src/state/queries/tenor.ts
+++ b/src/state/queries/tenor.ts
@@ -4,6 +4,7 @@ import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query'
import {GIF_FEATURED, GIF_SEARCH} from '#/lib/constants'
import {logger} from '#/logger'
+import {type ContentFormats, type Gif} from '#/state/queries/gif'
export const RQKEY_ROOT = 'gif-service'
export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
@@ -102,117 +103,3 @@ export function tenorUrlToBskyGifUrl(tenorUrl: string) {
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 e5cb225f9f..46032b7804 100644
--- a/src/storage/schema.ts
+++ b/src/storage/schema.ts
@@ -1,3 +1,4 @@
+import {type Gif} from '#/state/queries/gif'
import {type ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config'
import {type Geolocation} from '#/geolocation/types'
@@ -81,8 +82,7 @@ export type Account = {
lastSelectedHomeFeed?: string
/**
- * Recently selected GIFs in the GIF picker, stored as serialized Gif objects.
- * Most recent first, capped at 20.
+ * Recently selected GIFs in the GIF picker. Most recent first, capped at 20.
*/
- recentGifs?: string[]
+ recentGifs?: Gif[]
}
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index d12b3a0bea..34c7066a1a 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -93,9 +93,9 @@ import {
useLanguagePrefs,
useLanguagePrefsApi,
} from '#/state/preferences/languages'
+import {type Gif} from '#/state/queries/gif'
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'
diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx
index 0db82a03f8..51a34b489f 100644
--- a/src/view/com/composer/ExternalEmbed.tsx
+++ b/src/view/com/composer/ExternalEmbed.tsx
@@ -2,11 +2,11 @@ import {useMemo} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {cleanError} from '#/lib/strings/errors'
+import {type Gif} from '#/state/queries/gif'
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'
diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx
index e32df485c3..da215bb584 100644
--- a/src/view/com/composer/GifAltText.tsx
+++ b/src/view/com/composer/GifAltText.tsx
@@ -10,8 +10,8 @@ import {
type EmbedPlayerParams,
parseEmbedPlayerFromUrl,
} from '#/lib/strings/embed-player'
+import {type Gif} from '#/state/queries/gif'
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'
diff --git a/src/view/com/composer/drafts/state/api.ts b/src/view/com/composer/drafts/state/api.ts
index b07c290733..8109659689 100644
--- a/src/view/com/composer/drafts/state/api.ts
+++ b/src/view/com/composer/drafts/state/api.ts
@@ -10,7 +10,7 @@ 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 {type Gif} from '#/state/queries/gif'
import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util'
import {createPublicAgent} from '#/state/session/agent'
import {
diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts
index 75ea8153cf..17c2307d17 100644
--- a/src/view/com/composer/state/composer.ts
+++ b/src/view/com/composer/state/composer.ts
@@ -17,8 +17,8 @@ import {
toBskyAppUrl,
} from '#/lib/strings/url-helpers'
import {type ComposerImage, createInitialImages} from '#/state/gallery'
+import {type Gif} from '#/state/queries/gif'
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'