diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts index 7453b8d203..0c441ccdc2 100644 --- a/__tests__/lib/string.test.ts +++ b/__tests__/lib/string.test.ts @@ -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('') + }) +}) diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index b86c62e5ba..7c87c75917 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -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', } diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx index a6e30c820c..530c85ae66 100644 --- a/src/components/MediaPreview.tsx +++ b/src/components/MediaPreview.tsx @@ -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 ( 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({ { setSearch(text) listRef.current?.scrollToOffset({offset: 0, animated: false}) @@ -185,7 +201,7 @@ function GifList({ ) - }, [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} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index ba19765d47..8364cd2f6c 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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' diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts index bf396b27ac..bbd4c11305 100644 --- a/src/lib/strings/embed-player.ts +++ b/src/lib/strings/embed-player.ts @@ -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 = { 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) +} diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index d31092ad85..9b86360765 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -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(), diff --git a/src/state/queries/tenor.ts b/src/state/queries/tenor.ts index 3379d5dfb2..c6de07dae5 100644 --- a/src/state/queries/tenor.ts +++ b/src/state/queries/tenor.ts @@ -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( }, }) 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.