This commit is contained in:
vineyardbovines
2026-04-21 10:21:59 -04:00
parent 7d8912a17a
commit 6049e7b12e
24 changed files with 142 additions and 307 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(yarn typecheck *)",
"Bash(yarn lint *)",
"Bash(yarn test *)"
]
}
}
+1 -17
View File
@@ -8,8 +8,7 @@ import {
parseStarterPackUri, parseStarterPackUri,
} from '#/lib/strings/starter-pack' } from '#/lib/strings/starter-pack'
import {messages} from '#/locale/locales/en/messages' import {messages} from '#/locale/locales/en/messages'
import {klipyUrlToBskyGifUrl} from '#/state/queries/klipy' import {klipyUrlToBskyGifUrl} from '#/features/gifPicker/utils'
import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor'
import {cleanError} from '../../src/lib/strings/errors' import {cleanError} from '../../src/lib/strings/errors'
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles' import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
import {enforceLen} from '../../src/lib/strings/helpers' 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', () => { describe('klipyUrlToBskyGifUrl', () => {
const inputs = [ const inputs = [
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif', 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
@@ -100,6 +100,10 @@ function GifPickerBody({
const items = isRecentsActive ? getRecents() : networkItems const items = isRecentsActive ? getRecents() : networkItems
const hasData = items.length > 0 const hasData = items.length > 0
// Remount the grid when the data source changes so FlatList doesn't carry
// stale virtualized cells between e.g. a search and the recents list.
const viewKey = isRecentsActive ? 'recents' : `network:${effectiveSearch}`
const onEndReached = () => { const onEndReached = () => {
if (isRecentsActive) return if (isRecentsActive) return
if (isFetchingNextPage || !hasNextPage || error) return if (isFetchingNextPage || !hasNextPage || error) return
@@ -172,6 +176,7 @@ function GifPickerBody({
hasData={hasData} hasData={hasData}
isFetchingNextPage={!isRecentsActive && isFetchingNextPage} isFetchingNextPage={!isRecentsActive && isFetchingNextPage}
error={isRecentsActive ? null : error} error={isRecentsActive ? null : error}
viewKey={viewKey}
fetchNextPage={fetchNextPage} fetchNextPage={fetchNextPage}
onEndReached={onEndReached} onEndReached={onEndReached}
onSelectGif={handleSelectGif} onSelectGif={handleSelectGif}
@@ -1,10 +1,10 @@
import {View} from 'react-native' import {View} from 'react-native'
import {i18n, type MessageDescriptor} from '@lingui/core' import {type MessageDescriptor} from '@lingui/core'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react/macro'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Button, useSharedButtonTextStyles} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
import {Celebrate_Stroke2_Corner0_Rounded as Celebrate} from '#/components/icons/Celebrate' import {Celebrate_Stroke2_Corner0_Rounded as Celebrate} from '#/components/icons/Celebrate'
import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock' import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock'
import {type Props as SVGIconProps} from '#/components/icons/common' import {type Props as SVGIconProps} from '#/components/icons/common'
@@ -16,8 +16,6 @@ import {Heart2_Stroke2_Corner0_Rounded as Heart} from '#/components/icons/Heart2
import {Shaka_Stroke2_Corner0_Rounded as Shaka} from '#/components/icons/Shaka' import {Shaka_Stroke2_Corner0_Rounded as Shaka} from '#/components/icons/Shaka'
import {Trending3_Stroke2_Corner1_Rounded as Trending} from '#/components/icons/Trending' import {Trending3_Stroke2_Corner1_Rounded as Trending} from '#/components/icons/Trending'
const ICON_SIZE = 20
export type GifCategory = { export type GifCategory = {
id: string id: string
icon: React.ComponentType<SVGIconProps> icon: React.ComponentType<SVGIconProps>
@@ -49,10 +47,7 @@ export function GifCategoryPills({
onSelect: (category: GifCategory) => void onSelect: (category: GifCategory) => void
hasRecents: boolean hasRecents: boolean
}) { }) {
// Subscribe to locale changes so i18n._() returns the current translation. const {i18n} = useLingui()
// The lingui-msg-rule lint rule forbids _() with variables, so we use
// i18n._() directly to translate the MessageDescriptor from GIF_CATEGORIES.
useLingui()
return ( return (
<View <View
@@ -73,24 +68,12 @@ export function GifCategoryPills({
aria-current={isActive ? 'true' : undefined} aria-current={isActive ? 'true' : undefined}
onPress={() => onSelect(category)} onPress={() => onSelect(category)}
size="small" size="small"
variant={isActive ? 'solid' : 'ghost'} color={isActive ? 'secondary_inverted' : 'secondary'}
color="secondary"
shape="round"> shape="round">
<PillIcon icon={category.icon} /> <ButtonIcon icon={category.icon} size="md" />
</Button> </Button>
) )
})} })}
</View> </View>
) )
} }
function PillIcon({icon: Icon}: {icon: React.ComponentType<SVGIconProps>}) {
const textStyles = useSharedButtonTextStyles()
return (
<Icon
width={ICON_SIZE}
height={ICON_SIZE}
style={{color: textStyles.color}}
/>
)
}
@@ -1,6 +1,4 @@
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
@@ -8,27 +6,22 @@ import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
export function GifPickerErrorBoundary({details}: {details?: string}) { export function GifPickerErrorBoundary({details}: {details?: string}) {
const {_} = useLingui() const {t: l} = useLingui()
const control = Dialog.useDialogContext() const control = Dialog.useDialogContext()
return ( return (
<Dialog.ScrollableInner <Dialog.ScrollableInner style={a.gap_md} label={l`An error has occurred`}>
style={a.gap_md}
label={_(msg`An error has occurred`)}>
<Dialog.Close /> <Dialog.Close />
<ErrorScreen <ErrorScreen
title={_(msg`Oh no!`)} title={l`Oh no!`}
message={_( message={l`There was an unexpected issue in the application. Please let us know if this happened to you!`}
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
)}
details={details} details={details}
/> />
<Button <Button
label={_(msg`Close dialog`)} label={l`Close dialog`}
onPress={() => control.close()} onPress={() => control.close()}
color="primary" color="primary"
size="large" size="large">
variant="solid">
<ButtonText> <ButtonText>
<Trans>Close</Trans> <Trans>Close</Trans>
</ButtonText> </ButtonText>
@@ -15,6 +15,12 @@ type Props = {
hasData: boolean hasData: boolean
isFetchingNextPage: boolean isFetchingNextPage: boolean
error: unknown error: unknown
/**
* Identifies the current data source (e.g. "recents", "trending", a search
* term). Used to remount the underlying FlatList when switching sources so
* virtualized cells from the previous view can't bleed into the next one.
*/
viewKey: string
fetchNextPage: () => Promise<unknown> fetchNextPage: () => Promise<unknown>
onEndReached: () => void onEndReached: () => void
onSelectGif: (gif: Gif) => void onSelectGif: (gif: Gif) => void
@@ -28,6 +34,7 @@ export const GifPickerGrid = forwardRef<ListMethods, Props>(
hasData, hasData,
isFetchingNextPage, isFetchingNextPage,
error, error,
viewKey,
fetchNextPage, fetchNextPage,
onEndReached, onEndReached,
onSelectGif, onSelectGif,
@@ -51,7 +58,7 @@ export const GifPickerGrid = forwardRef<ListMethods, Props>(
return ( return (
<Dialog.InnerFlatList <Dialog.InnerFlatList
ref={ref} ref={ref}
key={String(numColumns)} key={`${numColumns}-${viewKey}`}
data={data} data={data}
renderItem={({item}: {item: Gif[][]}) => ( renderItem={({item}: {item: Gif[][]}) => (
<View style={[a.flex_row, a.gap_sm]}> <View style={[a.flex_row, a.gap_sm]}>
@@ -1,7 +1,6 @@
import {type Ref} from 'react' import {type Ref} from 'react'
import {type TextInput, View} from 'react-native' import {type TextInput, View} from 'react-native'
import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf' import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
@@ -21,7 +20,7 @@ export function GifPickerHeader({
onClose: () => void onClose: () => void
onEscape: () => void onEscape: () => void
}) { }) {
const {_} = useLingui() const {t: l} = useLingui()
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
@@ -39,11 +38,10 @@ export function GifPickerHeader({
{!gtMobile && IS_WEB && ( {!gtMobile && IS_WEB && (
<Button <Button
size="small" size="small"
variant="ghost"
color="secondary" color="secondary"
shape="round" shape="round"
onPress={onClose} onPress={onClose}
label={_(msg`Close GIF dialog`)}> label={l`Close GIF dialog`}>
<ButtonIcon icon={Arrow} size="md" /> <ButtonIcon icon={Arrow} size="md" />
</Button> </Button>
)} )}
@@ -51,8 +49,8 @@ export function GifPickerHeader({
<TextField.Root style={[!gtMobile && IS_WEB && a.flex_1]}> <TextField.Root style={[!gtMobile && IS_WEB && a.flex_1]}>
<TextField.Icon icon={Search} /> <TextField.Icon icon={Search} />
<TextField.Input <TextField.Input
label={_(msg`Search GIFs`)} label={l`Search GIFs`}
placeholder={_(msg`Search GIFs`)} placeholder={l`Search GIFs`}
onChangeText={onChangeText} onChangeText={onChangeText}
returnKeyType="search" returnKeyType="search"
clearButtonMode="while-editing" clearButtonMode="while-editing"
@@ -1,12 +1,11 @@
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {gifPreviewUrl} from '#/state/queries/gif'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {type Gif} from '#/features/gifPicker/types' import {type Gif} from '#/features/gifPicker/types'
import {gifPreviewUrl} from '#/features/gifPicker/utils'
export function GifPickerItem({ export function GifPickerItem({
gif, gif,
@@ -16,7 +15,7 @@ export function GifPickerItem({
onSelectGif: (gif: Gif) => void onSelectGif: (gif: Gif) => void
}) { }) {
const ax = useAnalytics() const ax = useAnalytics()
const {_} = useLingui() const {t: l} = useLingui()
const t = useTheme() const t = useTheme()
const [width, height] = gif.media_formats.tinygif.dims const [width, height] = gif.media_formats.tinygif.dims
@@ -29,7 +28,7 @@ export function GifPickerItem({
return ( return (
<Button <Button
label={_(msg`Select GIF "${gif.title}"`)} label={l`Select GIF "${gif.title}"`}
onPress={onPress} onPress={onPress}
style={a.w_full}> style={a.w_full}>
{({pressed}) => ( {({pressed}) => (
@@ -1,5 +1,4 @@
import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {ListMaybePlaceholder} from '#/components/Lists' import {ListMaybePlaceholder} from '#/components/Lists'
@@ -20,13 +19,13 @@ export function GifPickerPlaceholder({
onRetry: () => Promise<unknown> onRetry: () => Promise<unknown>
onGoBack: () => void onGoBack: () => void
}) { }) {
const {_} = useLingui() const {t: l} = useLingui()
const emptyMessage = isSearching const emptyMessage = isSearching
? _(msg`No GIFs found for "${query}".`) ? l`No GIFs found for "${query}".`
: isRecentsEmpty : isRecentsEmpty
? _(msg`No recent GIFs yet. Pick one to see it here.`) ? l`No recent GIFs yet. Pick one to see it here.`
: _(msg`No GIFs to show right now. Try again in a moment.`) : l`No GIFs to show right now. Try again in a moment.`
return ( return (
<ListMaybePlaceholder <ListMaybePlaceholder
@@ -37,10 +36,8 @@ export function GifPickerPlaceholder({
emptyType="results" emptyType="results"
sideBorders={false} sideBorders={false}
topBorder={false} topBorder={false}
errorTitle={_(msg`Couldn't load GIFs`)} errorTitle={l`Couldn't load GIFs`}
errorMessage={_( errorMessage={l`There was a problem loading GIFs. Check your connection and try again.`}
msg`There was a problem loading GIFs. Check your connection and try again.`,
)}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
/> />
) )
@@ -1,48 +1,22 @@
import { import {
useFeaturedGifsQuery as useKlipyFeaturedGifsQuery, useFeaturedGifsQuery,
useGifSearchQuery as useKlipyGifSearchQuery, useGifSearchQuery,
} from '#/state/queries/klipy' } from '#/features/gifPicker/queries'
import {
useTenorFeaturedGifsQuery,
useTenorGifSearchQuery,
} from '#/state/queries/tenor'
import {useAnalytics} from '#/analytics'
/** /**
* Single entry point for the GIF picker's data layer. Wraps the Klipy/Tenor * Single entry point for the GIF picker's data layer. Routes between the
* feature-flag split and the featured-vs-search branching so the UI only ever * featured and search endpoints so the UI only ever consumes one query result.
* consumes one query result.
*
* The Tenor path is kept alive until the Klipy rollout is complete.
*/ */
export function useGifPickerData( export function useGifPickerData(
query: string, query: string,
{enabled = true}: {enabled?: boolean} = {}, {enabled = true}: {enabled?: boolean} = {},
) { ) {
const ax = useAnalytics()
const useKlipy = ax.features.enabled(ax.features.KlipyGifProviderEnable)
const isSearching = query.length > 0 const isSearching = query.length > 0
const klipyFeatured = useKlipyFeaturedGifsQuery({ const featured = useFeaturedGifsQuery({enabled: enabled && !isSearching})
enabled: enabled && useKlipy && !isSearching, const search = useGifSearchQuery(query, {enabled: enabled && 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 const active = isSearching ? search : featured
? isSearching
? klipySearch
: klipyFeatured
: isSearching
? tenorSearch
: tenorFeatured
return { return {
...active, ...active,
@@ -3,8 +3,7 @@ import {getLocales} from 'expo-localization'
import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query' import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query'
import {GIF_KLIPY_FEATURED, GIF_KLIPY_SEARCH} from '#/lib/constants' import {GIF_KLIPY_FEATURED, GIF_KLIPY_SEARCH} from '#/lib/constants'
import {logger} from '#/logger' import {type Gif} from '#/features/gifPicker/types'
import {type Gif} from '#/state/queries/gif'
export const RQKEY_ROOT = 'klipy-gif-service' export const RQKEY_ROOT = 'klipy-gif-service'
export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured'] export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
@@ -88,21 +87,3 @@ function createKlipyApi<Input extends object>(
} }
} }
} }
/**
* 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
}
+33 -1
View File
@@ -1 +1,33 @@
export {type Gif} from '#/state/queries/gif' /**
* 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<BaseContentFormats, MediaObject> &
Partial<Record<VideoContentFormats, MediaObject>>
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
+40
View File
@@ -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
}
+1 -1
View File
@@ -26,7 +26,7 @@ import {
} from '#/lib/strings/url-helpers' } from '#/lib/strings/url-helpers'
import {type ComposerImage} from '#/state/gallery' import {type ComposerImage} from '#/state/gallery'
import {createComposerImage} from '#/state/gallery' import {createComposerImage} from '#/state/gallery'
import {type Gif} from '#/state/queries/gif' import {type Gif} from '#/features/gifPicker/types'
import {createGIFDescription} from '../gif-alt-text' import {createGIFDescription} from '../gif-alt-text'
type ResolvedExternalLink = { type ResolvedExternalLink = {
-5
View File
@@ -173,11 +173,6 @@ export const KNOWN_SHUTDOWN_FEEDS = [
export const GIF_SERVICE = 'https://gifs.bsky.app' 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) => export const GIF_KLIPY_SEARCH = (params: string) =>
`${GIF_SERVICE}/klipy/v2/search?${params}` `${GIF_SERVICE}/klipy/v2/search?${params}`
export const GIF_KLIPY_FEATURED = (params: string) => export const GIF_KLIPY_FEATURED = (params: string) =>
-57
View File
@@ -1,57 +0,0 @@
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<BaseContentFormats, MediaObject> &
Partial<Record<VideoContentFormats, MediaObject>>
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
+1 -1
View File
@@ -4,7 +4,7 @@ import {type QueryClient, useQuery} from '@tanstack/react-query'
import {type ResolvedLink, resolveGif, resolveLink} from '#/lib/api/resolve' import {type ResolvedLink, resolveGif, resolveLink} from '#/lib/api/resolve'
import {STALE} from '#/state/queries/index' import {STALE} from '#/state/queries/index'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {type Gif} from './gif' import {type Gif} from '#/features/gifPicker/types'
export const RQKEY_LINK_ROOT = 'resolve-link' export const RQKEY_LINK_ROOT = 'resolve-link'
export const RQKEY_LINK = (url: string) => [RQKEY_LINK_ROOT, url] export const RQKEY_LINK = (url: string) => [RQKEY_LINK_ROOT, url]
-105
View File
@@ -1,105 +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'
import {type ContentFormats, type Gif} from '#/state/queries/gif'
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<Input extends object>(
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
}
+1 -1
View File
@@ -1,5 +1,5 @@
import {type Gif} from '#/state/queries/gif'
import {type ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config' import {type ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config'
import {type Gif} from '#/features/gifPicker/types'
import {type Geolocation} from '#/geolocation/types' import {type Geolocation} from '#/geolocation/types'
/** /**
+1 -1
View File
@@ -93,7 +93,6 @@ import {
useLanguagePrefs, useLanguagePrefs,
useLanguagePrefsApi, useLanguagePrefsApi,
} from '#/state/preferences/languages' } from '#/state/preferences/languages'
import {type Gif} from '#/state/queries/gif'
import {usePreferencesQuery} from '#/state/queries/preferences' import {usePreferencesQuery} from '#/state/queries/preferences'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
@@ -135,6 +134,7 @@ import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' 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 {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
import { import {
draftToComposerPosts, draftToComposerPosts,
+1 -1
View File
@@ -2,7 +2,6 @@ import {useMemo} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native' import {type StyleProp, View, type ViewStyle} from 'react-native'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {type Gif} from '#/state/queries/gif'
import { import {
useResolveGifQuery, useResolveGifQuery,
useResolveLinkQuery, useResolveLinkQuery,
@@ -15,6 +14,7 @@ import {ModeratedFeedEmbed} from '#/components/Post/Embed/FeedEmbed'
import {ModeratedListEmbed} from '#/components/Post/Embed/ListEmbed' import {ModeratedListEmbed} from '#/components/Post/Embed/ListEmbed'
import {Embed as StarterPackEmbed} from '#/components/StarterPack/StarterPackCard' import {Embed as StarterPackEmbed} from '#/components/StarterPack/StarterPackCard'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {type Gif} from '#/features/gifPicker/types'
export const ExternalEmbedGif = ({ export const ExternalEmbedGif = ({
onRemove, onRemove,
+1 -1
View File
@@ -10,7 +10,6 @@ import {
type EmbedPlayerParams, type EmbedPlayerParams,
parseEmbedPlayerFromUrl, parseEmbedPlayerFromUrl,
} from '#/lib/strings/embed-player' } from '#/lib/strings/embed-player'
import {type Gif} from '#/state/queries/gif'
import {useResolveGifQuery} from '#/state/queries/resolve-link' import {useResolveGifQuery} from '#/state/queries/resolve-link'
import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper' import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
@@ -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 {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {GifEmbed} from '#/components/Post/Embed/ExternalEmbed/Gif' import {GifEmbed} from '#/components/Post/Embed/ExternalEmbed/Gif'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {type Gif} from '#/features/gifPicker/types'
export function GifAltTextDialog({ export function GifAltTextDialog({
gif, gif,
+1 -1
View File
@@ -10,7 +10,6 @@ import {getImageDim} from '#/lib/media/manip'
import {mimeToExt} from '#/lib/media/video/util' import {mimeToExt} from '#/lib/media/video/util'
import {shortenLinks} from '#/lib/strings/rich-text-manip' import {shortenLinks} from '#/lib/strings/rich-text-manip'
import {type ComposerImage} from '#/state/gallery' import {type ComposerImage} from '#/state/gallery'
import {type Gif} from '#/state/queries/gif'
import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util' import {threadgateAllowUISettingToAllowRecordValue} from '#/state/queries/threadgate/util'
import {createPublicAgent} from '#/state/session/agent' import {createPublicAgent} from '#/state/session/agent'
import { import {
@@ -21,6 +20,7 @@ import {
import {type VideoState} from '#/view/com/composer/state/video' import {type VideoState} from '#/view/com/composer/state/video'
import {type AnalyticsContextType} from '#/analytics' import {type AnalyticsContextType} from '#/analytics'
import {getDeviceId} from '#/analytics/identifiers' import {getDeviceId} from '#/analytics/identifiers'
import {type Gif} from '#/features/gifPicker/types'
import {logger} from './logger' import {logger} from './logger'
import {type DraftPostDisplay, type DraftSummary} from './schema' import {type DraftPostDisplay, type DraftSummary} from './schema'
import * as storage from './storage' import * as storage from './storage'
+1 -1
View File
@@ -17,7 +17,6 @@ import {
toBskyAppUrl, toBskyAppUrl,
} from '#/lib/strings/url-helpers' } from '#/lib/strings/url-helpers'
import {type ComposerImage, createInitialImages} from '#/state/gallery' import {type ComposerImage, createInitialImages} from '#/state/gallery'
import {type Gif} from '#/state/queries/gif'
import {createPostgateRecord} from '#/state/queries/postgate/util' import {createPostgateRecord} from '#/state/queries/postgate/util'
import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate' import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate'
import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate' import {type ThreadgateAllowUISetting} from '#/state/queries/threadgate'
@@ -26,6 +25,7 @@ import {
type LinkFacetMatch, type LinkFacetMatch,
suggestLinkCardUri, suggestLinkCardUri,
} from '#/view/com/composer/text-input/text-input-util' } from '#/view/com/composer/text-input/text-input-util'
import {type Gif} from '#/features/gifPicker/types'
import { import {
createVideoState, createVideoState,
type VideoAction, type VideoAction,