v2
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
parseStarterPackUri,
|
||||
} from '#/lib/strings/starter-pack'
|
||||
import {messages} from '#/locale/locales/en/messages'
|
||||
import {klipyStaticUrl} from '#/state/queries/klipy'
|
||||
import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor'
|
||||
import {cleanError} from '../../src/lib/strings/errors'
|
||||
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
|
||||
@@ -450,6 +451,12 @@ describe('parseEmbedPlayerFromUrl', () => {
|
||||
'https://sufjanstevens.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 = [
|
||||
@@ -845,6 +852,22 @@ describe('parseEmbedPlayerFromUrl', () => {
|
||||
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', () => {
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum Features {
|
||||
ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled',
|
||||
GroupChatsEnable = 'group_chats:enable',
|
||||
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
||||
KlipyGifProviderEnable = 'klipy_gif_provider:enable',
|
||||
|
||||
AATest = 'aa-test',
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {Image} from 'expo-image'
|
||||
import {type AppBskyFeedDefs} from '@atproto/api'
|
||||
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 {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -38,7 +38,7 @@ export function Embed({
|
||||
)
|
||||
} else if (e.type === 'link') {
|
||||
if (!e.view.external.thumb) return null
|
||||
if (!isTenorGifUri(e.view.external.uri)) return null
|
||||
if (!isGifEmbed(e.view.external.uri)) return null
|
||||
return (
|
||||
<Outer style={style}>
|
||||
<GifItem
|
||||
|
||||
@@ -15,9 +15,13 @@ import {Trans} from '@lingui/react/macro'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {
|
||||
type Gif,
|
||||
tenorUrlToBskyGifUrl,
|
||||
useFeaturedGifsQuery,
|
||||
useGifSearchQuery,
|
||||
klipyStaticUrl,
|
||||
useFeaturedGifsQuery as useKlipyFeaturedGifsQuery,
|
||||
useGifSearchQuery as useKlipyGifSearchQuery,
|
||||
} from '#/state/queries/klipy'
|
||||
import {
|
||||
useTenorFeaturedGifsQuery,
|
||||
useTenorGifSearchQuery,
|
||||
} from '#/state/queries/tenor'
|
||||
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
@@ -85,6 +89,7 @@ function GifList({
|
||||
control: Dialog.DialogControlProps
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const ax = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
@@ -95,9 +100,12 @@ function GifList({
|
||||
const {height} = useWindowDimensions()
|
||||
|
||||
const isSearching = search.length > 0
|
||||
const useKlipy = ax.features.enabled(ax.features.KlipyGifProviderEnable)
|
||||
|
||||
const trendingQuery = useFeaturedGifsQuery()
|
||||
const searchQuery = useGifSearchQuery(search)
|
||||
const klipyTrending = useKlipyFeaturedGifsQuery({enabled: useKlipy})
|
||||
const klipySearch = useKlipyGifSearchQuery(search, {enabled: useKlipy})
|
||||
const tenorTrending = useTenorFeaturedGifsQuery({enabled: !useKlipy})
|
||||
const tenorSearch = useTenorGifSearchQuery(search, {enabled: !useKlipy})
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -108,10 +116,16 @@ function GifList({
|
||||
isPending,
|
||||
isError,
|
||||
refetch,
|
||||
} = isSearching ? searchQuery : trendingQuery
|
||||
} = useKlipy
|
||||
? isSearching
|
||||
? klipySearch
|
||||
: klipyTrending
|
||||
: isSearching
|
||||
? tenorSearch
|
||||
: tenorTrending
|
||||
|
||||
const flattenedData = useMemo(() => {
|
||||
return data?.pages.flatMap(page => page.results) || []
|
||||
return data?.pages.flatMap(page => page.data) || []
|
||||
}, [data])
|
||||
|
||||
const renderItem = useCallback(
|
||||
@@ -167,7 +181,9 @@ function GifList({
|
||||
<TextField.Icon icon={Search} />
|
||||
<TextField.Input
|
||||
label={_(msg`Search GIFs`)}
|
||||
placeholder={_(msg`Search Tenor`)}
|
||||
placeholder={
|
||||
useKlipy ? _(msg`Search KLIPY`) : _(msg`Search Tenor`)
|
||||
}
|
||||
onChangeText={text => {
|
||||
setSearch(text)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
@@ -185,7 +201,7 @@ function GifList({
|
||||
</TextField.Root>
|
||||
</View>
|
||||
)
|
||||
}, [gtMobile, t.atoms.bg, _, control])
|
||||
}, [gtMobile, t.atoms.bg, _, control, useKlipy])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -213,13 +229,21 @@ function GifList({
|
||||
sideBorders={false}
|
||||
topBorder={false}
|
||||
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={
|
||||
isSearching
|
||||
? _(msg`No search results found for "${search}".`)
|
||||
: _(
|
||||
msg`No featured GIFs found. There may be an issue with Tenor.`,
|
||||
)
|
||||
: 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.`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -228,7 +252,7 @@ function GifList({
|
||||
stickyHeaderIndices={[0]}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={4}
|
||||
keyExtractor={(item: Gif) => item.id}
|
||||
keyExtractor={(item: Gif) => item.slug}
|
||||
keyboardDismissMode="on-drag"
|
||||
ListFooterComponent={
|
||||
hasData ? (
|
||||
@@ -308,7 +332,7 @@ export function GifPreview({
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
source={{
|
||||
uri: tenorUrlToBskyGifUrl(gif.media_formats.tinygif.url),
|
||||
uri: klipyStaticUrl(gif.file.sm.gif.url),
|
||||
}}
|
||||
contentFit="cover"
|
||||
accessibilityLabel={gif.title}
|
||||
|
||||
@@ -178,6 +178,11 @@ export const GIF_SEARCH = (params: string) =>
|
||||
export const GIF_FEATURED = (params: string) =>
|
||||
`${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 VIDEO_SERVICE = 'https://video.bsky.app'
|
||||
|
||||
@@ -23,6 +23,7 @@ export const embedPlayerSources = [
|
||||
'vimeo',
|
||||
'giphy',
|
||||
'tenor',
|
||||
'klipy',
|
||||
'flickr',
|
||||
'bandcamp',
|
||||
] as const
|
||||
@@ -44,6 +45,7 @@ export type EmbedPlayerType =
|
||||
| 'vimeo_video'
|
||||
| 'giphy_gif'
|
||||
| 'tenor_gif'
|
||||
| 'klipy_gif'
|
||||
| 'flickr_album'
|
||||
| 'bandcamp_album'
|
||||
| 'bandcamp_track'
|
||||
@@ -55,6 +57,7 @@ export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
|
||||
twitch: 'Twitch',
|
||||
giphy: 'GIPHY',
|
||||
tenor: 'Tenor',
|
||||
klipy: 'KLIPY',
|
||||
spotify: 'Spotify',
|
||||
appleMusic: 'Apple Music',
|
||||
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
|
||||
if (urlp.hostname === 'www.flickr.com' || urlp.hostname === 'flickr.com') {
|
||||
let i = urlp.pathname.length - 1
|
||||
@@ -628,3 +645,65 @@ export function isTenorGifUri(url: URL | string) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ const schema = z.object({
|
||||
.object({
|
||||
giphy: z.enum(externalEmbedOptions).optional(),
|
||||
tenor: z.enum(externalEmbedOptions).optional(),
|
||||
klipy: z.enum(externalEmbedOptions).optional(),
|
||||
youtube: z.enum(externalEmbedOptions).optional(),
|
||||
youtubeShorts: z.enum(externalEmbedOptions).optional(),
|
||||
twitch: z.enum(externalEmbedOptions).optional(),
|
||||
|
||||
+60
-11
@@ -2,30 +2,60 @@ 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 {useAnalytics} from '#/analytics'
|
||||
import {
|
||||
GIF_FEATURED,
|
||||
GIF_KLIPY_FEATURED,
|
||||
GIF_KLIPY_SEARCH,
|
||||
GIF_SEARCH,
|
||||
} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export const RQKEY_ROOT = 'gif-service'
|
||||
export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
|
||||
export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query]
|
||||
export const RQKEY_FEATURED = (provider: string) => [
|
||||
RQKEY_ROOT,
|
||||
'featured',
|
||||
provider,
|
||||
]
|
||||
export const RQKEY_SEARCH = (query: string, provider: string) => [
|
||||
RQKEY_ROOT,
|
||||
'search',
|
||||
query,
|
||||
provider,
|
||||
]
|
||||
|
||||
const getTrendingGifs = createTenorApi(GIF_FEATURED)
|
||||
|
||||
const searchGifs = createTenorApi<{q: string}>(GIF_SEARCH)
|
||||
const getTenorTrendingGifs = createTenorApi(GIF_FEATURED)
|
||||
const searchTenorGifs = createTenorApi<{q: string}>(GIF_SEARCH)
|
||||
const getKlipyTrendingGifs = createTenorApi(GIF_KLIPY_FEATURED)
|
||||
const searchKlipyGifs = createTenorApi<{q: string}>(GIF_KLIPY_SEARCH)
|
||||
|
||||
export function useFeaturedGifsQuery() {
|
||||
const ax = useAnalytics()
|
||||
const useKlipy = ax.features.enabled(ax.features.KlipyGifProviderEnable)
|
||||
const provider = useKlipy ? 'klipy' : 'tenor'
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: RQKEY_FEATURED,
|
||||
queryFn: ({pageParam}) => getTrendingGifs({pos: pageParam}),
|
||||
queryKey: RQKEY_FEATURED(provider),
|
||||
queryFn: ({pageParam}) =>
|
||||
useKlipy
|
||||
? getKlipyTrendingGifs({pos: pageParam})
|
||||
: getTenorTrendingGifs({pos: pageParam}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: lastPage => lastPage.next,
|
||||
})
|
||||
}
|
||||
|
||||
export function useGifSearchQuery(query: string) {
|
||||
const ax = useAnalytics()
|
||||
const useKlipy = ax.features.enabled(ax.features.KlipyGifProviderEnable)
|
||||
const provider = useKlipy ? 'klipy' : 'tenor'
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: RQKEY_SEARCH(query),
|
||||
queryFn: ({pageParam}) => searchGifs({q: query, pos: pageParam}),
|
||||
queryKey: RQKEY_SEARCH(query, provider),
|
||||
queryFn: ({pageParam}) =>
|
||||
useKlipy
|
||||
? searchKlipyGifs({q: query, pos: pageParam})
|
||||
: searchTenorGifs({q: query, pos: pageParam}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: lastPage => lastPage.next,
|
||||
enabled: !!query,
|
||||
@@ -81,7 +111,7 @@ function createTenorApi<Input extends object>(
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch Tenor API')
|
||||
throw new Error('Failed to fetch GIF API')
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
@@ -99,6 +129,25 @@ export function tenorUrlToBskyGifUrl(tenorUrl: string) {
|
||||
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 = {
|
||||
/**
|
||||
* A Unix timestamp that represents when this post was created.
|
||||
|
||||
Reference in New Issue
Block a user