design tweaks and categories

This commit is contained in:
vineyardbovines
2026-04-20 12:10:59 -04:00
parent 2dc369a8fc
commit 128911f451
23 changed files with 130 additions and 431 deletions
+57
View File
@@ -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<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
+3 -69
View File
@@ -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<string[]> {
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<Input extends object>(
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[]
}
+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 {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]
+1 -114
View File
@@ -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<BaseContentFormats, MediaObject> &
Partial<Record<VideoContentFormats, MediaObject>>
/**
* 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