This commit is contained in:
vineyardbovines
2026-04-10 11:37:32 -04:00
parent 9c65a8a78c
commit 2e54eff27d
8 changed files with 221 additions and 28 deletions
+34
View File
@@ -8,6 +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 {klipyStaticUrl} from '#/state/queries/klipy'
import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor' 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'
@@ -450,6 +451,12 @@ describe('parseEmbedPlayerFromUrl', () => {
'https://sufjanstevens.bandcamp.com', 'https://sufjanstevens.bandcamp.com',
'https://bandcamp.com/', 'https://bandcamp.com/',
'https://bandcamp.com', 'https://bandcamp.com',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200',
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
'https://static.klipy.com/other/path.gif?hh=200&ww=300',
'https://static.klipy.com',
] ]
const outputs = [ const outputs = [
@@ -845,6 +852,22 @@ describe('parseEmbedPlayerFromUrl', () => {
undefined, undefined,
undefined, undefined,
undefined, undefined,
{
type: 'klipy_gif',
source: 'klipy',
isGif: true,
hideDetails: true,
playerUri: 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
dimensions: {
width: 300,
height: 200,
},
},
undefined,
undefined,
undefined,
undefined,
] ]
it('correctly grabs the correct id from uri', () => { it('correctly grabs the correct id from uri', () => {
@@ -1049,3 +1072,14 @@ describe('tenorUrlToBskyGifUrl', () => {
}, },
) )
}) })
describe('klipyStaticUrl', () => {
it('returns the URL as-is for valid KLIPY static URLs', () => {
const input = 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif'
expect(klipyStaticUrl(input)).toEqual(input)
})
it('returns empty string for invalid URLs', () => {
expect(klipyStaticUrl('not-a-url')).toEqual('')
})
})
+1
View File
@@ -13,6 +13,7 @@ export enum Features {
ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled', ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled',
GroupChatsEnable = 'group_chats:enable', GroupChatsEnable = 'group_chats:enable',
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable', DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
KlipyGifProviderEnable = 'klipy_gif_provider:enable',
AATest = 'aa-test', AATest = 'aa-test',
} }
+2 -2
View File
@@ -3,7 +3,7 @@ import {Image} from 'expo-image'
import {type AppBskyFeedDefs} from '@atproto/api' import {type AppBskyFeedDefs} from '@atproto/api'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import {isTenorGifUri} from '#/lib/strings/embed-player' import {isGifEmbed} from '#/lib/strings/embed-player'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@@ -38,7 +38,7 @@ export function Embed({
) )
} else if (e.type === 'link') { } else if (e.type === 'link') {
if (!e.view.external.thumb) return null if (!e.view.external.thumb) return null
if (!isTenorGifUri(e.view.external.uri)) return null if (!isGifEmbed(e.view.external.uri)) return null
return ( return (
<Outer style={style}> <Outer style={style}>
<GifItem <GifItem
+36 -12
View File
@@ -15,9 +15,13 @@ import {Trans} from '@lingui/react/macro'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import { import {
type Gif, type Gif,
tenorUrlToBskyGifUrl, klipyStaticUrl,
useFeaturedGifsQuery, useFeaturedGifsQuery as useKlipyFeaturedGifsQuery,
useGifSearchQuery, useGifSearchQuery as useKlipyGifSearchQuery,
} from '#/state/queries/klipy'
import {
useTenorFeaturedGifsQuery,
useTenorGifSearchQuery,
} from '#/state/queries/tenor' } from '#/state/queries/tenor'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
@@ -85,6 +89,7 @@ function GifList({
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
onSelectGif: (gif: Gif) => void onSelectGif: (gif: Gif) => void
}) { }) {
const ax = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
@@ -95,9 +100,12 @@ function GifList({
const {height} = useWindowDimensions() const {height} = useWindowDimensions()
const isSearching = search.length > 0 const isSearching = search.length > 0
const useKlipy = ax.features.enabled(ax.features.KlipyGifProviderEnable)
const trendingQuery = useFeaturedGifsQuery() const klipyTrending = useKlipyFeaturedGifsQuery({enabled: useKlipy})
const searchQuery = useGifSearchQuery(search) const klipySearch = useKlipyGifSearchQuery(search, {enabled: useKlipy})
const tenorTrending = useTenorFeaturedGifsQuery({enabled: !useKlipy})
const tenorSearch = useTenorGifSearchQuery(search, {enabled: !useKlipy})
const { const {
data, data,
@@ -108,10 +116,16 @@ function GifList({
isPending, isPending,
isError, isError,
refetch, refetch,
} = isSearching ? searchQuery : trendingQuery } = useKlipy
? isSearching
? klipySearch
: klipyTrending
: isSearching
? tenorSearch
: tenorTrending
const flattenedData = useMemo(() => { const flattenedData = useMemo(() => {
return data?.pages.flatMap(page => page.results) || [] return data?.pages.flatMap(page => page.data) || []
}, [data]) }, [data])
const renderItem = useCallback( const renderItem = useCallback(
@@ -167,7 +181,9 @@ function GifList({
<TextField.Icon icon={Search} /> <TextField.Icon icon={Search} />
<TextField.Input <TextField.Input
label={_(msg`Search GIFs`)} label={_(msg`Search GIFs`)}
placeholder={_(msg`Search Tenor`)} placeholder={
useKlipy ? _(msg`Search KLIPY`) : _(msg`Search Tenor`)
}
onChangeText={text => { onChangeText={text => {
setSearch(text) setSearch(text)
listRef.current?.scrollToOffset({offset: 0, animated: false}) listRef.current?.scrollToOffset({offset: 0, animated: false})
@@ -185,7 +201,7 @@ function GifList({
</TextField.Root> </TextField.Root>
</View> </View>
) )
}, [gtMobile, t.atoms.bg, _, control]) }, [gtMobile, t.atoms.bg, _, control, useKlipy])
return ( return (
<> <>
@@ -213,10 +229,18 @@ function GifList({
sideBorders={false} sideBorders={false}
topBorder={false} topBorder={false}
errorTitle={_(msg`Failed to load GIFs`)} errorTitle={_(msg`Failed to load GIFs`)}
errorMessage={_(msg`There was an issue connecting to Tenor.`)} errorMessage={
useKlipy
? _(msg`There was an issue connecting to KLIPY.`)
: _(msg`There was an issue connecting to Tenor.`)
}
emptyMessage={ emptyMessage={
isSearching isSearching
? _(msg`No search results found for "${search}".`) ? _(msg`No search results found for "${search}".`)
: useKlipy
? _(
msg`No featured GIFs found. There may be an issue with KLIPY.`,
)
: _( : _(
msg`No featured GIFs found. There may be an issue with Tenor.`, msg`No featured GIFs found. There may be an issue with Tenor.`,
) )
@@ -228,7 +252,7 @@ function GifList({
stickyHeaderIndices={[0]} stickyHeaderIndices={[0]}
onEndReached={onEndReached} onEndReached={onEndReached}
onEndReachedThreshold={4} onEndReachedThreshold={4}
keyExtractor={(item: Gif) => item.id} keyExtractor={(item: Gif) => item.slug}
keyboardDismissMode="on-drag" keyboardDismissMode="on-drag"
ListFooterComponent={ ListFooterComponent={
hasData ? ( hasData ? (
@@ -308,7 +332,7 @@ export function GifPreview({
t.atoms.bg_contrast_25, t.atoms.bg_contrast_25,
]} ]}
source={{ source={{
uri: tenorUrlToBskyGifUrl(gif.media_formats.tinygif.url), uri: klipyStaticUrl(gif.file.sm.gif.url),
}} }}
contentFit="cover" contentFit="cover"
accessibilityLabel={gif.title} accessibilityLabel={gif.title}
+5
View File
@@ -178,6 +178,11 @@ export const GIF_SEARCH = (params: string) =>
export const GIF_FEATURED = (params: string) => export const GIF_FEATURED = (params: string) =>
`${GIF_SERVICE}/tenor/v2/featured?${params}` `${GIF_SERVICE}/tenor/v2/featured?${params}`
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 MAX_LABELERS = 20 export const MAX_LABELERS = 20
export const VIDEO_SERVICE = 'https://video.bsky.app' export const VIDEO_SERVICE = 'https://video.bsky.app'
+79
View File
@@ -23,6 +23,7 @@ export const embedPlayerSources = [
'vimeo', 'vimeo',
'giphy', 'giphy',
'tenor', 'tenor',
'klipy',
'flickr', 'flickr',
'bandcamp', 'bandcamp',
] as const ] as const
@@ -44,6 +45,7 @@ export type EmbedPlayerType =
| 'vimeo_video' | 'vimeo_video'
| 'giphy_gif' | 'giphy_gif'
| 'tenor_gif' | 'tenor_gif'
| 'klipy_gif'
| 'flickr_album' | 'flickr_album'
| 'bandcamp_album' | 'bandcamp_album'
| 'bandcamp_track' | 'bandcamp_track'
@@ -55,6 +57,7 @@ export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
twitch: 'Twitch', twitch: 'Twitch',
giphy: 'GIPHY', giphy: 'GIPHY',
tenor: 'Tenor', tenor: 'Tenor',
klipy: 'KLIPY',
spotify: 'Spotify', spotify: 'Spotify',
appleMusic: 'Apple Music', appleMusic: 'Apple Music',
soundcloud: 'SoundCloud', soundcloud: 'SoundCloud',
@@ -391,6 +394,20 @@ export function parseEmbedPlayerFromUrl(
} }
} }
const klipyGif = parseKlipyGif(urlp)
if (klipyGif.success) {
const {playerUri, dimensions} = klipyGif
return {
type: 'klipy_gif',
source: 'klipy',
isGif: true,
hideDetails: true,
playerUri,
dimensions,
}
}
// this is a standard flickr path! we can use the embedder for albums and groups, so validate the path // this is a standard flickr path! we can use the embedder for albums and groups, so validate the path
if (urlp.hostname === 'www.flickr.com' || urlp.hostname === 'flickr.com') { if (urlp.hostname === 'www.flickr.com' || urlp.hostname === 'flickr.com') {
let i = urlp.pathname.length - 1 let i = urlp.pathname.length - 1
@@ -628,3 +645,65 @@ export function isTenorGifUri(url: URL | string) {
return false return false
} }
} }
export function parseKlipyGif(urlp: URL):
| {success: false}
| {
success: true
playerUri: string
dimensions: {height: number; width: number}
} {
if (urlp.hostname !== 'static.klipy.com') {
return {success: false}
}
if (!urlp.pathname.startsWith('/ii/')) {
return {success: false}
}
const h = urlp.searchParams.get('hh')
const w = urlp.searchParams.get('ww')
if (!h || !w) {
return {success: false}
}
const dimensions = {
height: Number(h),
width: Number(w),
}
// Validate dimensions are valid positive numbers
if (
isNaN(dimensions.height) ||
isNaN(dimensions.width) ||
dimensions.height <= 0 ||
dimensions.width <= 0
) {
return {success: false}
}
// Use the base URL without dimension params as the player URI
const playerUrl = new URL(urlp.href)
playerUrl.searchParams.delete('hh')
playerUrl.searchParams.delete('ww')
return {
success: true,
playerUri: playerUrl.href,
dimensions,
}
}
export function isKlipyGifUri(url: URL | string) {
try {
return parseKlipyGif(typeof url === 'string' ? new URL(url) : url).success
} catch {
// Invalid URL
return false
}
}
export function isGifEmbed(url: URL | string) {
return isTenorGifUri(url) || isKlipyGifUri(url)
}
+1
View File
@@ -98,6 +98,7 @@ const schema = z.object({
.object({ .object({
giphy: z.enum(externalEmbedOptions).optional(), giphy: z.enum(externalEmbedOptions).optional(),
tenor: z.enum(externalEmbedOptions).optional(), tenor: z.enum(externalEmbedOptions).optional(),
klipy: z.enum(externalEmbedOptions).optional(),
youtube: z.enum(externalEmbedOptions).optional(), youtube: z.enum(externalEmbedOptions).optional(),
youtubeShorts: z.enum(externalEmbedOptions).optional(), youtubeShorts: z.enum(externalEmbedOptions).optional(),
twitch: z.enum(externalEmbedOptions).optional(), twitch: z.enum(externalEmbedOptions).optional(),
+60 -11
View File
@@ -2,30 +2,60 @@ import {Platform} from 'react-native'
import {getLocales} from 'expo-localization' import {getLocales} from 'expo-localization'
import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query' import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query'
import {GIF_FEATURED, GIF_SEARCH} from '#/lib/constants' import {useAnalytics} from '#/analytics'
import {
GIF_FEATURED,
GIF_KLIPY_FEATURED,
GIF_KLIPY_SEARCH,
GIF_SEARCH,
} from '#/lib/constants'
import {logger} from '#/logger' import {logger} from '#/logger'
export const RQKEY_ROOT = 'gif-service' export const RQKEY_ROOT = 'gif-service'
export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured'] export const RQKEY_FEATURED = (provider: string) => [
export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query] RQKEY_ROOT,
'featured',
provider,
]
export const RQKEY_SEARCH = (query: string, provider: string) => [
RQKEY_ROOT,
'search',
query,
provider,
]
const getTrendingGifs = createTenorApi(GIF_FEATURED) const getTenorTrendingGifs = createTenorApi(GIF_FEATURED)
const searchTenorGifs = createTenorApi<{q: string}>(GIF_SEARCH)
const searchGifs = createTenorApi<{q: string}>(GIF_SEARCH) const getKlipyTrendingGifs = createTenorApi(GIF_KLIPY_FEATURED)
const searchKlipyGifs = createTenorApi<{q: string}>(GIF_KLIPY_SEARCH)
export function useFeaturedGifsQuery() { export function useFeaturedGifsQuery() {
const ax = useAnalytics()
const useKlipy = ax.features.enabled(ax.features.KlipyGifProviderEnable)
const provider = useKlipy ? 'klipy' : 'tenor'
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: RQKEY_FEATURED, queryKey: RQKEY_FEATURED(provider),
queryFn: ({pageParam}) => getTrendingGifs({pos: pageParam}), queryFn: ({pageParam}) =>
useKlipy
? getKlipyTrendingGifs({pos: pageParam})
: getTenorTrendingGifs({pos: pageParam}),
initialPageParam: undefined as string | undefined, initialPageParam: undefined as string | undefined,
getNextPageParam: lastPage => lastPage.next, getNextPageParam: lastPage => lastPage.next,
}) })
} }
export function useGifSearchQuery(query: string) { export function useGifSearchQuery(query: string) {
const ax = useAnalytics()
const useKlipy = ax.features.enabled(ax.features.KlipyGifProviderEnable)
const provider = useKlipy ? 'klipy' : 'tenor'
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: RQKEY_SEARCH(query), queryKey: RQKEY_SEARCH(query, provider),
queryFn: ({pageParam}) => searchGifs({q: query, pos: pageParam}), queryFn: ({pageParam}) =>
useKlipy
? searchKlipyGifs({q: query, pos: pageParam})
: searchTenorGifs({q: query, pos: pageParam}),
initialPageParam: undefined as string | undefined, initialPageParam: undefined as string | undefined,
getNextPageParam: lastPage => lastPage.next, getNextPageParam: lastPage => lastPage.next,
enabled: !!query, enabled: !!query,
@@ -81,7 +111,7 @@ function createTenorApi<Input extends object>(
}, },
}) })
if (!res.ok) { if (!res.ok) {
throw new Error('Failed to fetch Tenor API') throw new Error('Failed to fetch GIF API')
} }
return res.json() return res.json()
} }
@@ -99,6 +129,25 @@ export function tenorUrlToBskyGifUrl(tenorUrl: string) {
return url.href return url.href
} }
/**
* Returns the appropriate static URL for a GIF preview image.
* For Tenor URLs, rewrites through the bsky proxy.
* For KLIPY URLs, returns as-is (no proxy yet).
*/
export function gifPreviewUrl(gifUrl: string) {
try {
const url = new URL(gifUrl)
if (url.hostname === 'media.tenor.com') {
return tenorUrlToBskyGifUrl(gifUrl)
}
// KLIPY static URLs and others pass through directly
return gifUrl
} catch (e) {
logger.debug('invalid url passed to gifPreviewUrl()')
return ''
}
}
export type Gif = { export type Gif = {
/** /**
* A Unix timestamp that represents when this post was created. * A Unix timestamp that represents when this post was created.