[APP-2066] Migrate from Tenor to KLIPY (#10240)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import {Platform} from 'react-native'
|
||||
import {getLocales} from 'expo-localization'
|
||||
import {keepPreviousData, 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'
|
||||
|
||||
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,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
+42
-10
@@ -13,22 +13,26 @@ const getTrendingGifs = createTenorApi(GIF_FEATURED)
|
||||
|
||||
const searchGifs = createTenorApi<{q: string}>(GIF_SEARCH)
|
||||
|
||||
export function useFeaturedGifsQuery() {
|
||||
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 useGifSearchQuery(query: string) {
|
||||
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,
|
||||
enabled: !!query && options?.enabled !== false,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
@@ -99,6 +103,29 @@ export function tenorUrlToBskyGifUrl(tenorUrl: string) {
|
||||
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.
|
||||
@@ -116,7 +143,8 @@ export type Gif = {
|
||||
/**
|
||||
* A dictionary with a content format as the key and a Media Object as the value.
|
||||
*/
|
||||
media_formats: Record<ContentFormats, MediaObject>
|
||||
media_formats: Record<BaseContentFormats, MediaObject> &
|
||||
Partial<Record<VideoContentFormats, MediaObject>>
|
||||
/**
|
||||
* An array of tags for the post
|
||||
*/
|
||||
@@ -171,16 +199,20 @@ type MediaObject = {
|
||||
size: number
|
||||
}
|
||||
|
||||
type ContentFormats =
|
||||
type BaseContentFormats =
|
||||
| 'preview'
|
||||
| 'gif'
|
||||
// | 'mediumgif'
|
||||
| 'tinygif'
|
||||
// | 'nanogif'
|
||||
// | 'mp4'
|
||||
// | 'loopedmp4'
|
||||
// | 'tinymp4'
|
||||
// | 'nanomp4'
|
||||
// | 'webm'
|
||||
|
||||
type VideoContentFormats =
|
||||
| 'mp4'
|
||||
// | 'loopedmp4'
|
||||
// | 'tinymp4'
|
||||
// | 'nanomp4'
|
||||
| 'webm'
|
||||
// | 'tinywebm'
|
||||
// | 'nanowebm'
|
||||
|
||||
type ContentFormats = BaseContentFormats | VideoContentFormats
|
||||
|
||||
Reference in New Issue
Block a user