[APP-2067] Rebuild GIF Dialog (#10261)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Spence Pope
2026-05-05 11:06:18 -04:00
committed by GitHub
parent 3a997031e0
commit 48ea9e0b96
27 changed files with 921 additions and 616 deletions
+204
View File
@@ -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 (
<Dialog.Outer
control={control}
onClose={onClose}
nativeOptions={{
bottomInset: 0,
// use system corner radius on iOS
...ios({cornerRadius: undefined}),
fullHeight: true,
}}>
<Dialog.Handle />
<ErrorBoundary
renderError={error => (
<GifPickerErrorBoundary details={String(error)} />
)}>
<GifPickerBody control={control} onSelectGif={onSelectGif} />
</ErrorBoundary>
</Dialog.Outer>
)
}
function GifPickerBody({
control,
onSelectGif,
}: {
control: Dialog.DialogControlProps
onSelectGif: (gif: Gif) => void
}) {
const textInputRef = useRef<TextInput>(null)
const listRef = useRef<ListMethods>(null)
const [rawSearch, setRawSearch] = useState('')
const [activeCategory, setActiveCategory] = useState<string>('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 = (
<>
<GifPickerHeader
inputRef={textInputRef}
onChangeText={onChangeSearch}
onClear={onClearSearch}
canClear={rawSearch.length > 0}
onEscape={() => control.close()}
/>
{showPills && (
<GifCategoryPills
activeId={activeCategory}
onSelect={onSelectCategory}
hasRecents={hasRecents}
/>
)}
{!hasData && (
<GifPickerPlaceholder
isLoading={!isRecentsActive && isPending}
isError={!isRecentsActive && isError}
isSearching={isSearching}
isRecentsEmpty={isRecentsActive}
query={effectiveSearch}
onRetry={refetch}
onGoBack={onGoBack}
/>
)}
</>
)
return (
<>
<Dialog.Close />
<GifPickerGrid
ref={listRef}
items={items}
header={header}
hasData={hasData}
isFetchingNextPage={!isRecentsActive && isFetchingNextPage}
error={isRecentsActive ? null : error}
fetchNextPage={fetchNextPage}
onEndReached={onEndReached}
onSelectGif={handleSelectGif}
/>
</>
)
}
function dedupeById(items: Gif[]): Gif[] {
const seen = new Set<string>()
const out: Gif[] = []
for (const item of items) {
if (seen.has(item.id)) continue
seen.add(item.id)
out.push(item)
}
return out
}
@@ -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<SVGIconProps>
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 (
<View
style={[
a.flex_row,
a.justify_between,
a.align_center,
a.gap_xs,
a.pb_md,
t.atoms.bg,
]}>
{GIF_CATEGORIES.map(category => {
if (category.id === 'recents' && !hasRecents) return null
const isActive = category.id === activeId
return (
<Button
key={category.id}
label={i18n._(category.label)}
aria-current={isActive ? 'true' : undefined}
onPress={() => onSelect(category)}
size="small"
color={isActive ? 'secondary_inverted' : 'secondary'}
shape="round">
<ButtonIcon icon={category.icon} size="md" />
</Button>
)
})}
</View>
)
}
@@ -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 (
<Dialog.ScrollableInner
style={a.gap_md}
label={l({
message: 'An error has occurred',
comment:
'Accessibility label for the dialog shown when the GIF picker hits an unexpected runtime error and falls back to its error boundary.',
})}>
<Dialog.Close />
<ErrorScreen
title={l({
message: 'Oh no!',
comment:
'Title of the error screen shown when the GIF picker crashes unexpectedly.',
})}
message={l({
message:
'There was an unexpected issue in the application. Please let us know if this happened to you!',
comment:
'Body of the error screen shown when the GIF picker crashes unexpectedly. Encourages the user to report the issue.',
})}
details={details}
/>
<Button
label={l({
message: 'Close dialog',
comment:
'Accessibility label for the button that dismisses the GIF picker error dialog.',
})}
onPress={() => control.close()}
color="primary"
size="large">
<ButtonText>
<Trans comment="Visible label of the button that dismisses the GIF picker error dialog.">
Close
</Trans>
</ButtonText>
</Button>
</Dialog.ScrollableInner>
)
}
@@ -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<unknown>
onEndReached: () => void
onSelectGif: (gif: Gif) => void
}
export const GifPickerGrid = forwardRef<ListMethods, Props>(
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 (
<Dialog.InnerFlatList
ref={ref}
key={String(numColumns)}
data={data}
renderItem={({item}: {item: Gif[][]}) => (
<View style={[a.flex_row, a.gap_sm]}>
{item.map((column, i) => (
<View key={i} style={[a.flex_1, a.gap_sm, {minWidth: 0}]}>
{column.map(gif => (
<GifPickerItem
key={gif.id}
gif={gif}
onSelectGif={onSelectGif}
/>
))}
</View>
))}
</View>
)}
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 ? (
<ListFooter
isFetchingNextPage={isFetchingNextPage}
error={cleanError(error)}
onRetry={fetchNextPage}
style={{borderTopWidth: 0}}
/>
) : 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
}
@@ -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<TextInput>
onChangeText: (text: string) => void
onClear: () => void
onEscape: () => void
canClear: boolean
}) {
const {t: l} = useLingui()
const t = useTheme()
return (
<View
style={[
native(a.pt_4xl),
a.relative,
a.pb_md,
a.flex_row,
a.align_center,
t.atoms.bg,
]}>
<TextField.Root style={a.flex_1}>
<TextField.Icon icon={Search} />
<TextField.Input
label={l({
message: 'Search GIFs',
comment:
'Accessibility label for the GIF search input inside the GIF picker dialog.',
})}
placeholder={l({
message: 'Search KLIPY',
comment:
'Placeholder text inside the GIF search input. KLIPY is the third-party GIF provider; keep the brand name as-is.',
})}
onChangeText={onChangeText}
returnKeyType="search"
inputRef={inputRef}
maxLength={50}
onKeyPress={({nativeEvent}) => {
if (nativeEvent.key === 'Escape') {
onEscape()
}
}}
/>
{canClear && (
<Button
size="tiny"
color="secondary"
shape="round"
style={a.z_30}
onPress={onClear}
label={l({
message: 'Clear GIF search',
comment:
'Accessibility label for the X button inside the search input that clears the typed query and returns to the trending feed.',
})}>
<ButtonIcon icon={X} size="sm" />
</Button>
)}
</TextField.Root>
</View>
)
}
@@ -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 (
<Button
label={l({
message: `Select GIF "${gif.title}"`,
comment:
'Accessibility label for an individual GIF tile in the picker grid. The placeholder is the GIFs title from the provider.',
})}
onPress={onPress}
style={a.w_full}>
{({pressed}) => (
<Image
style={[
a.w_full,
a.rounded_sm,
t.atoms.bg_contrast_25,
{
aspectRatio,
opacity: pressed ? 0.85 : 1,
transform: [{scale: pressed ? 0.97 : 1}],
},
]}
source={{uri: gifPreviewUrl(gif.media_formats.tinygif.url)}}
contentFit="cover"
accessibilityLabel={gif.title}
accessibilityHint=""
cachePolicy="none"
accessibilityIgnoresInvertColors
/>
)}
</Button>
)
}
@@ -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<unknown>
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 users search query.',
})
: isRecentsEmpty
? l({
message: 'No recent GIFs yet. Pick one to see it here.',
comment:
'Empty-state message shown in the GIF pickers 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 (
<ListMaybePlaceholder
isLoading={isLoading}
isError={isError}
onRetry={isError ? onRetry : undefined}
onGoBack={onGoBack}
emptyType="results"
sideBorders={false}
topBorder={false}
errorTitle={l({
message: 'Couldnt load GIFs',
comment:
'Title of the error screen shown when the GIF provider request fails.',
})}
errorMessage={l({
message:
'There was a problem loading GIFs. Check your connection and try again.',
comment:
'Body message of the error screen shown when the GIF provider request fails. Encourages the user to retry.',
})}
emptyMessage={emptyMessage}
/>
)
}
@@ -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,
}
}
@@ -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,
}
}
+88
View File
@@ -0,0 +1,88 @@
import {Platform} from 'react-native'
import {getLocales} from 'expo-localization'
import {useInfiniteQuery} from '@tanstack/react-query'
import {GIF_KLIPY_FEATURED, GIF_KLIPY_SEARCH} from '#/lib/constants'
import {type Gif} from '#/features/gifPicker/types'
export const RQKEY_ROOT = 'klipy-gif-service'
export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query]
const getTrendingGifs = createKlipyApi(GIF_KLIPY_FEATURED)
const searchGifs = createKlipyApi<{q: string}>(GIF_KLIPY_SEARCH)
export function useFeaturedGifsQuery(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 useGifSearchQuery(
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,
})
}
function createKlipyApi<Input extends object>(
urlFn: (params: string) => string,
): (input: Input & {pos?: string}) => Promise<{
next: string
results: Gif[]
}> {
return async input => {
const params = new URLSearchParams()
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')
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 KLIPY API (status ${res.status})`)
}
const body: {next: string; results: Gif[]} = await res.json()
return {
next: body.next,
results: body.results,
}
}
}
+33
View File
@@ -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<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
}