[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:
@@ -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 {klipyUrlToBskyGifUrl} 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,13 @@ 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&ww=300&mp4=videoSlugMp4&webm=videoSlugWebm',
|
||||||
|
'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 +853,35 @@ describe('parseEmbedPlayerFromUrl', () => {
|
|||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
|
|
||||||
|
{
|
||||||
|
type: 'klipy_gif',
|
||||||
|
source: 'klipy',
|
||||||
|
isGif: true,
|
||||||
|
hideDetails: true,
|
||||||
|
playerUri: 'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif',
|
||||||
|
dimensions: {
|
||||||
|
width: 300,
|
||||||
|
height: 200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// With video slug params — on native (test env), keeps gif filename,
|
||||||
|
// strips mp4/webm params. On web, would swap to video filename.
|
||||||
|
{
|
||||||
|
type: 'klipy_gif',
|
||||||
|
source: 'klipy',
|
||||||
|
isGif: true,
|
||||||
|
hideDetails: true,
|
||||||
|
playerUri: 'https://k.gifs.bsky.app/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 +1086,31 @@ describe('tenorUrlToBskyGifUrl', () => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('klipyUrlToBskyGifUrl', () => {
|
||||||
|
const inputs = [
|
||||||
|
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif',
|
||||||
|
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
|
||||||
|
]
|
||||||
|
|
||||||
|
it.each(inputs)(
|
||||||
|
'returns url with k.gifs.bsky.app as hostname for input url',
|
||||||
|
input => {
|
||||||
|
const out = klipyUrlToBskyGifUrl(input)
|
||||||
|
expect(out.startsWith('https://k.gifs.bsky.app/')).toEqual(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('preserves the path and query params when rewriting', () => {
|
||||||
|
const out = klipyUrlToBskyGifUrl(
|
||||||
|
'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
|
||||||
|
)
|
||||||
|
expect(out).toEqual(
|
||||||
|
'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif?hh=200&ww=300',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty string for invalid URLs', () => {
|
||||||
|
expect(klipyUrlToBskyGifUrl('not-a-url')).toEqual('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -13,7 +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',
|
||||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||||
|
|
||||||
AATest = 'aa-test',
|
AATest = 'aa-test',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ export const ExternalEmbed = ({
|
|||||||
}
|
}
|
||||||
}, [link.uri, playHaptic])
|
}, [link.uri, playHaptic])
|
||||||
|
|
||||||
if (embedPlayerParams?.source === 'tenor') {
|
if (
|
||||||
|
embedPlayerParams?.source === 'tenor' ||
|
||||||
|
embedPlayerParams?.source === 'klipy'
|
||||||
|
) {
|
||||||
const parsedAlt = parseAltFromGIFDescription(link.description)
|
const parsedAlt = parseAltFromGIFDescription(link.description)
|
||||||
return (
|
return (
|
||||||
<View style={style}>
|
<View style={style}>
|
||||||
|
|||||||
@@ -8,16 +8,18 @@ import {
|
|||||||
import {type TextInput, View} from 'react-native'
|
import {type TextInput, View} from 'react-native'
|
||||||
import {useWindowDimensions} from 'react-native'
|
import {useWindowDimensions} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {msg} from '@lingui/core/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {Trans} from '@lingui/react/macro'
|
|
||||||
|
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
|
import {
|
||||||
|
useFeaturedGifsQuery as useKlipyFeaturedGifsQuery,
|
||||||
|
useGifSearchQuery as useKlipyGifSearchQuery,
|
||||||
|
} from '#/state/queries/klipy'
|
||||||
import {
|
import {
|
||||||
type Gif,
|
type Gif,
|
||||||
tenorUrlToBskyGifUrl,
|
gifPreviewUrl,
|
||||||
useFeaturedGifsQuery,
|
useTenorFeaturedGifsQuery,
|
||||||
useGifSearchQuery,
|
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,7 +87,8 @@ function GifList({
|
|||||||
control: Dialog.DialogControlProps
|
control: Dialog.DialogControlProps
|
||||||
onSelectGif: (gif: Gif) => void
|
onSelectGif: (gif: Gif) => void
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const ax = useAnalytics()
|
||||||
|
const {t: l} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const textInputRef = useRef<TextInput>(null)
|
const textInputRef = useRef<TextInput>(null)
|
||||||
@@ -93,11 +96,14 @@ function GifList({
|
|||||||
const [undeferredSearch, setSearch] = useState('')
|
const [undeferredSearch, setSearch] = useState('')
|
||||||
const search = useThrottledValue(undeferredSearch, 500)
|
const search = useThrottledValue(undeferredSearch, 500)
|
||||||
const {height} = useWindowDimensions()
|
const {height} = useWindowDimensions()
|
||||||
|
const klipyEnabled = ax.features.enabled(ax.features.KlipyGifProviderEnable)
|
||||||
|
|
||||||
const isSearching = search.length > 0
|
const isSearching = search.length > 0
|
||||||
|
|
||||||
const trendingQuery = useFeaturedGifsQuery()
|
const klipyTrending = useKlipyFeaturedGifsQuery({enabled: klipyEnabled})
|
||||||
const searchQuery = useGifSearchQuery(search)
|
const klipySearch = useKlipyGifSearchQuery(search, {enabled: klipyEnabled})
|
||||||
|
const tenorTrending = useTenorFeaturedGifsQuery({enabled: !klipyEnabled})
|
||||||
|
const tenorSearch = useTenorGifSearchQuery(search, {enabled: !klipyEnabled})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -108,7 +114,13 @@ function GifList({
|
|||||||
isPending,
|
isPending,
|
||||||
isError,
|
isError,
|
||||||
refetch,
|
refetch,
|
||||||
} = isSearching ? searchQuery : trendingQuery
|
} = klipyEnabled
|
||||||
|
? 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.results) || []
|
||||||
@@ -158,7 +170,7 @@ function GifList({
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
shape="round"
|
shape="round"
|
||||||
onPress={() => control.close()}
|
onPress={() => control.close()}
|
||||||
label={_(msg`Close GIF dialog`)}>
|
label={l`Close GIF dialog`}>
|
||||||
<ButtonIcon icon={Arrow} size="md" />
|
<ButtonIcon icon={Arrow} size="md" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -166,8 +178,8 @@ function GifList({
|
|||||||
<TextField.Root style={[!gtMobile && IS_WEB && a.flex_1]}>
|
<TextField.Root style={[!gtMobile && IS_WEB && a.flex_1]}>
|
||||||
<TextField.Icon icon={Search} />
|
<TextField.Icon icon={Search} />
|
||||||
<TextField.Input
|
<TextField.Input
|
||||||
label={_(msg`Search GIFs`)}
|
label={l`Search GIFs`}
|
||||||
placeholder={_(msg`Search Tenor`)}
|
placeholder={klipyEnabled ? l`Search KLIPY` : l`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 +197,7 @@ function GifList({
|
|||||||
</TextField.Root>
|
</TextField.Root>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}, [gtMobile, t.atoms.bg, _, control])
|
}, [gtMobile, t.atoms.bg, l, control, klipyEnabled])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -212,14 +224,18 @@ function GifList({
|
|||||||
emptyType="results"
|
emptyType="results"
|
||||||
sideBorders={false}
|
sideBorders={false}
|
||||||
topBorder={false}
|
topBorder={false}
|
||||||
errorTitle={_(msg`Failed to load GIFs`)}
|
errorTitle={l`Failed to load GIFs`}
|
||||||
errorMessage={_(msg`There was an issue connecting to Tenor.`)}
|
errorMessage={
|
||||||
|
klipyEnabled
|
||||||
|
? l`There was an issue connecting to KLIPY.`
|
||||||
|
: l`There was an issue connecting to Tenor.`
|
||||||
|
}
|
||||||
emptyMessage={
|
emptyMessage={
|
||||||
isSearching
|
isSearching
|
||||||
? _(msg`No search results found for "${search}".`)
|
? l`No search results found for "${search}".`
|
||||||
: _(
|
: klipyEnabled
|
||||||
msg`No featured GIFs found. There may be an issue with Tenor.`,
|
? l`No featured GIFs found. There may be an issue with KLIPY.`
|
||||||
)
|
: l`No featured GIFs found. There may be an issue with Tenor.`
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -246,23 +262,19 @@ function GifList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DialogError({details}: {details?: string}) {
|
function DialogError({details}: {details?: string}) {
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const control = Dialog.useDialogContext()
|
const control = Dialog.useDialogContext()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.ScrollableInner
|
<Dialog.ScrollableInner style={a.gap_md} label={l`An error has occurred`}>
|
||||||
style={a.gap_md}
|
|
||||||
label={_(msg`An error has occurred`)}>
|
|
||||||
<Dialog.Close />
|
<Dialog.Close />
|
||||||
<ErrorScreen
|
<ErrorScreen
|
||||||
title={_(msg`Oh no!`)}
|
title={l`Oh no!`}
|
||||||
message={_(
|
message={l`There was an unexpected issue in the application. Please let us know if this happened to you!`}
|
||||||
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
|
|
||||||
)}
|
|
||||||
details={details}
|
details={details}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Close dialog`)}
|
label={l`Close dialog`}
|
||||||
onPress={() => control.close()}
|
onPress={() => control.close()}
|
||||||
color="primary"
|
color="primary"
|
||||||
size="large"
|
size="large"
|
||||||
@@ -284,7 +296,7 @@ export function GifPreview({
|
|||||||
}) {
|
}) {
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const {gtTablet} = useBreakpoints()
|
const {gtTablet} = useBreakpoints()
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
const onPress = useCallback(() => {
|
const onPress = useCallback(() => {
|
||||||
@@ -294,7 +306,7 @@ export function GifPreview({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Select GIF "${gif.title}"`)}
|
label={l`Select GIF "${gif.title}"`}
|
||||||
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
|
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
|
||||||
onPress={onPress}>
|
onPress={onPress}>
|
||||||
{({pressed}) => (
|
{({pressed}) => (
|
||||||
@@ -308,7 +320,7 @@ export function GifPreview({
|
|||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
]}
|
]}
|
||||||
source={{
|
source={{
|
||||||
uri: tenorUrlToBskyGifUrl(gif.media_formats.tinygif.url),
|
uri: gifPreviewUrl(gif.media_formats.tinygif.url),
|
||||||
}}
|
}}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
accessibilityLabel={gif.title}
|
accessibilityLabel={gif.title}
|
||||||
|
|||||||
+31
-3
@@ -190,16 +190,44 @@ export async function resolveGif(
|
|||||||
agent: BskyAgent,
|
agent: BskyAgent,
|
||||||
gif: Gif,
|
gif: Gif,
|
||||||
): Promise<ResolvedExternalLink> {
|
): Promise<ResolvedExternalLink> {
|
||||||
const uri = `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[1]}&ww=${gif.media_formats.gif.dims[0]}`
|
const gifUrl = gif.media_formats.gif.url
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('hh', String(gif.media_formats.gif.dims[1]))
|
||||||
|
params.set('ww', String(gif.media_formats.gif.dims[0]))
|
||||||
|
|
||||||
|
// For Klipy GIFs, embed video format slugs so parseKlipyGif can
|
||||||
|
// swap to the right format per platform at render time. Klipy uses
|
||||||
|
// different filename slugs per format (unlike Tenor where format is
|
||||||
|
// encoded in the URL ID), so this info must travel with the URL.
|
||||||
|
try {
|
||||||
|
const url = new URL(gifUrl)
|
||||||
|
if (url.hostname === 'static.klipy.com') {
|
||||||
|
const mp4Slug = getFileSlug(gif.media_formats.mp4?.url)
|
||||||
|
const webmSlug = getFileSlug(gif.media_formats.webm?.url)
|
||||||
|
if (mp4Slug) params.set('mp4', mp4Slug)
|
||||||
|
if (webmSlug) params.set('webm', webmSlug)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const uri = `${gifUrl}?${params.toString()}`
|
||||||
|
const altText = gif.content_description || gif.title
|
||||||
return {
|
return {
|
||||||
type: 'external',
|
type: 'external',
|
||||||
uri,
|
uri,
|
||||||
title: gif.content_description,
|
title: altText,
|
||||||
description: createGIFDescription(gif.content_description),
|
description: createGIFDescription(altText),
|
||||||
thumb: await imageToThumb(gif.media_formats.preview.url),
|
thumb: await imageToThumb(gif.media_formats.preview.url),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFileSlug(url: string | undefined): string | undefined {
|
||||||
|
if (!url) return undefined
|
||||||
|
const filename = url.split('/').pop()
|
||||||
|
if (!filename) return undefined
|
||||||
|
const dotIndex = filename.lastIndexOf('.')
|
||||||
|
return dotIndex > 0 ? filename.slice(0, dotIndex) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveExternal(
|
async function resolveExternal(
|
||||||
agent: BskyAgent,
|
agent: BskyAgent,
|
||||||
uri: string,
|
uri: string,
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,90 @@ 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}
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerUrl = new URL(urlp.href)
|
||||||
|
playerUrl.hostname = 'k.gifs.bsky.app'
|
||||||
|
|
||||||
|
// On web, swap the gif filename for a video format so the <video>
|
||||||
|
// element can play it. Klipy uses different filename slugs per
|
||||||
|
// format (unlike Tenor's ID-based scheme), so the slugs are
|
||||||
|
// embedded as query params at composition time by resolveGif().
|
||||||
|
if (IS_WEB) {
|
||||||
|
const webmSlug = playerUrl.searchParams.get('webm')
|
||||||
|
const mp4Slug = playerUrl.searchParams.get('mp4')
|
||||||
|
const slug = IS_WEB_SAFARI ? mp4Slug : webmSlug
|
||||||
|
const ext = IS_WEB_SAFARI ? 'mp4' : 'webm'
|
||||||
|
|
||||||
|
// Without a slug we can't produce a playable video URL on web,
|
||||||
|
// so fall back to the link card instead of returning a broken player.
|
||||||
|
if (!slug) {
|
||||||
|
return {success: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = playerUrl.pathname.split('/')
|
||||||
|
parts[parts.length - 1] = `${slug}.${ext}`
|
||||||
|
playerUrl.pathname = parts.join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip all metadata params — only the path matters for the CDN
|
||||||
|
playerUrl.searchParams.delete('hh')
|
||||||
|
playerUrl.searchParams.delete('ww')
|
||||||
|
playerUrl.searchParams.delete('mp4')
|
||||||
|
playerUrl.searchParams.delete('webm')
|
||||||
|
|
||||||
|
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({
|
.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(),
|
||||||
|
|||||||
@@ -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)
|
const searchGifs = createTenorApi<{q: string}>(GIF_SEARCH)
|
||||||
|
|
||||||
export function useFeaturedGifsQuery() {
|
export function useTenorFeaturedGifsQuery(options?: {enabled?: boolean}) {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: RQKEY_FEATURED,
|
queryKey: RQKEY_FEATURED,
|
||||||
queryFn: ({pageParam}) => getTrendingGifs({pos: pageParam}),
|
queryFn: ({pageParam}) => getTrendingGifs({pos: pageParam}),
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
getNextPageParam: lastPage => lastPage.next,
|
getNextPageParam: lastPage => lastPage.next,
|
||||||
|
enabled: options?.enabled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGifSearchQuery(query: string) {
|
export function useTenorGifSearchQuery(
|
||||||
|
query: string,
|
||||||
|
options?: {enabled?: boolean},
|
||||||
|
) {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: RQKEY_SEARCH(query),
|
queryKey: RQKEY_SEARCH(query),
|
||||||
queryFn: ({pageParam}) => searchGifs({q: query, pos: pageParam}),
|
queryFn: ({pageParam}) => searchGifs({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 && options?.enabled !== false,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -99,6 +103,29 @@ export function tenorUrlToBskyGifUrl(tenorUrl: string) {
|
|||||||
return url.href
|
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 = {
|
export type Gif = {
|
||||||
/**
|
/**
|
||||||
* A Unix timestamp that represents when this post was created.
|
* 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.
|
* 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
|
* An array of tags for the post
|
||||||
*/
|
*/
|
||||||
@@ -171,16 +199,20 @@ type MediaObject = {
|
|||||||
size: number
|
size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContentFormats =
|
type BaseContentFormats =
|
||||||
| 'preview'
|
| 'preview'
|
||||||
| 'gif'
|
| 'gif'
|
||||||
// | 'mediumgif'
|
// | 'mediumgif'
|
||||||
| 'tinygif'
|
| 'tinygif'
|
||||||
// | 'nanogif'
|
// | 'nanogif'
|
||||||
// | 'mp4'
|
|
||||||
// | 'loopedmp4'
|
type VideoContentFormats =
|
||||||
// | 'tinymp4'
|
| 'mp4'
|
||||||
// | 'nanomp4'
|
// | 'loopedmp4'
|
||||||
// | 'webm'
|
// | 'tinymp4'
|
||||||
|
// | 'nanomp4'
|
||||||
|
| 'webm'
|
||||||
// | 'tinywebm'
|
// | 'tinywebm'
|
||||||
// | 'nanowebm'
|
// | 'nanowebm'
|
||||||
|
|
||||||
|
type ContentFormats = BaseContentFormats | VideoContentFormats
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {type DraftPostDisplay, type DraftSummary} from './schema'
|
|||||||
import * as storage from './storage'
|
import * as storage from './storage'
|
||||||
|
|
||||||
const TENOR_HOSTNAME = 'media.tenor.com'
|
const TENOR_HOSTNAME = 'media.tenor.com'
|
||||||
|
const KLIPY_HOSTNAME = 'static.klipy.com'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Video data from a draft that needs to be restored by re-processing.
|
* Video data from a draft that needs to be restored by re-processing.
|
||||||
@@ -379,7 +380,7 @@ function parseGifFromUrl(
|
|||||||
): {url: string; width: number; height: number; alt: string} | undefined {
|
): {url: string; width: number; height: number; alt: string} | undefined {
|
||||||
try {
|
try {
|
||||||
const url = new URL(uri)
|
const url = new URL(uri)
|
||||||
if (url.hostname !== TENOR_HOSTNAME) {
|
if (url.hostname !== TENOR_HOSTNAME && url.hostname !== KLIPY_HOSTNAME) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,6 +397,8 @@ function parseGifFromUrl(
|
|||||||
url.searchParams.delete('ww')
|
url.searchParams.delete('ww')
|
||||||
url.searchParams.delete('hh')
|
url.searchParams.delete('hh')
|
||||||
url.searchParams.delete('alt')
|
url.searchParams.delete('alt')
|
||||||
|
url.searchParams.delete('mp4')
|
||||||
|
url.searchParams.delete('webm')
|
||||||
|
|
||||||
return {url: url.toString(), width, height, alt}
|
return {url: url.toString(), width, height, alt}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user