Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
117
fork

Configure Feed

Select the types of activity you want to include in your feed.

[APP-2066] Migrate from Tenor to KLIPY (#10240)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

authored by

Spence Pope
Claude Opus 4.6 (1M context)
and committed by
GitHub
a77b6e35 a97b15b2

+411 -50
+65
__tests__/lib/string.test.ts
··· 8 8 parseStarterPackUri, 9 9 } from '#/lib/strings/starter-pack' 10 10 import {messages} from '#/locale/locales/en/messages' 11 + import {klipyUrlToBskyGifUrl} from '#/state/queries/klipy' 11 12 import {tenorUrlToBskyGifUrl} from '#/state/queries/tenor' 12 13 import {cleanError} from '../../src/lib/strings/errors' 13 14 import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles' ··· 450 451 'https://sufjanstevens.bandcamp.com', 451 452 'https://bandcamp.com/', 452 453 'https://bandcamp.com', 454 + 455 + 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300', 456 + 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300&mp4=videoSlugMp4&webm=videoSlugWebm', 457 + 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200', 458 + 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif', 459 + 'https://static.klipy.com/other/path.gif?hh=200&ww=300', 460 + 'https://static.klipy.com', 453 461 ] 454 462 455 463 const outputs = [ ··· 845 853 undefined, 846 854 undefined, 847 855 undefined, 856 + 857 + { 858 + type: 'klipy_gif', 859 + source: 'klipy', 860 + isGif: true, 861 + hideDetails: true, 862 + playerUri: 'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif', 863 + dimensions: { 864 + width: 300, 865 + height: 200, 866 + }, 867 + }, 868 + // With video slug params — on native (test env), keeps gif filename, 869 + // strips mp4/webm params. On web, would swap to video filename. 870 + { 871 + type: 'klipy_gif', 872 + source: 'klipy', 873 + isGif: true, 874 + hideDetails: true, 875 + playerUri: 'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif', 876 + dimensions: { 877 + width: 300, 878 + height: 200, 879 + }, 880 + }, 881 + undefined, 882 + undefined, 883 + undefined, 884 + undefined, 848 885 ] 849 886 850 887 it('correctly grabs the correct id from uri', () => { ··· 1049 1086 }, 1050 1087 ) 1051 1088 }) 1089 + 1090 + describe('klipyUrlToBskyGifUrl', () => { 1091 + const inputs = [ 1092 + 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif', 1093 + 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300', 1094 + ] 1095 + 1096 + it.each(inputs)( 1097 + 'returns url with k.gifs.bsky.app as hostname for input url', 1098 + input => { 1099 + const out = klipyUrlToBskyGifUrl(input) 1100 + expect(out.startsWith('https://k.gifs.bsky.app/')).toEqual(true) 1101 + }, 1102 + ) 1103 + 1104 + it('preserves the path and query params when rewriting', () => { 1105 + const out = klipyUrlToBskyGifUrl( 1106 + 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300', 1107 + ) 1108 + expect(out).toEqual( 1109 + 'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif?hh=200&ww=300', 1110 + ) 1111 + }) 1112 + 1113 + it('returns empty string for invalid URLs', () => { 1114 + expect(klipyUrlToBskyGifUrl('not-a-url')).toEqual('') 1115 + }) 1116 + })
+1 -1
src/analytics/features/types.ts
··· 13 13 ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled', 14 14 GroupChatsEnable = 'group_chats:enable', 15 15 DmsNewMessageComposerEnable = 'dms:new_message_composer:enable', 16 + KlipyGifProviderEnable = 'klipy_gif_provider:enable', 16 17 PostGalleryEmbedEnable = 'post_gallery_embed:enable', 17 - 18 18 AATest = 'aa-test', 19 19 }
+2 -2
src/components/MediaPreview.tsx
··· 3 3 import {type AppBskyFeedDefs} from '@atproto/api' 4 4 import {Trans} from '@lingui/react/macro' 5 5 6 - import {isTenorGifUri} from '#/lib/strings/embed-player' 6 + import {isGifEmbed} from '#/lib/strings/embed-player' 7 7 import {atoms as a, useTheme} from '#/alf' 8 8 import {MediaInsetBorder} from '#/components/MediaInsetBorder' 9 9 import {Text} from '#/components/Typography' ··· 38 38 ) 39 39 } else if (e.type === 'link') { 40 40 if (!e.view.external.thumb) return null 41 - if (!isTenorGifUri(e.view.external.uri)) return null 41 + if (!isGifEmbed(e.view.external.uri)) return null 42 42 return ( 43 43 <Outer style={style}> 44 44 <GifItem
+4 -1
src/components/Post/Embed/ExternalEmbed/index.tsx
··· 59 59 } 60 60 }, [link.uri, playHaptic]) 61 61 62 - if (embedPlayerParams?.source === 'tenor') { 62 + if ( 63 + embedPlayerParams?.source === 'tenor' || 64 + embedPlayerParams?.source === 'klipy' 65 + ) { 63 66 const parsedAlt = parseAltFromGIFDescription(link.description) 64 67 return ( 65 68 <View style={style}>
+44 -32
src/components/dialogs/GifSelect.tsx
··· 8 8 import {type TextInput, View} from 'react-native' 9 9 import {useWindowDimensions} from 'react-native' 10 10 import {Image} from 'expo-image' 11 - import {msg} from '@lingui/core/macro' 12 - import {useLingui} from '@lingui/react' 13 - import {Trans} from '@lingui/react/macro' 11 + import {Trans, useLingui} from '@lingui/react/macro' 14 12 15 13 import {cleanError} from '#/lib/strings/errors' 16 14 import { 15 + useFeaturedGifsQuery as useKlipyFeaturedGifsQuery, 16 + useGifSearchQuery as useKlipyGifSearchQuery, 17 + } from '#/state/queries/klipy' 18 + import { 17 19 type Gif, 18 - tenorUrlToBskyGifUrl, 19 - useFeaturedGifsQuery, 20 - useGifSearchQuery, 20 + gifPreviewUrl, 21 + useTenorFeaturedGifsQuery, 22 + useTenorGifSearchQuery, 21 23 } from '#/state/queries/tenor' 22 24 import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' 23 25 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' ··· 85 87 control: Dialog.DialogControlProps 86 88 onSelectGif: (gif: Gif) => void 87 89 }) { 88 - const {_} = useLingui() 90 + const ax = useAnalytics() 91 + const {t: l} = useLingui() 89 92 const t = useTheme() 90 93 const {gtMobile} = useBreakpoints() 91 94 const textInputRef = useRef<TextInput>(null) ··· 93 96 const [undeferredSearch, setSearch] = useState('') 94 97 const search = useThrottledValue(undeferredSearch, 500) 95 98 const {height} = useWindowDimensions() 99 + const klipyEnabled = ax.features.enabled(ax.features.KlipyGifProviderEnable) 96 100 97 101 const isSearching = search.length > 0 98 102 99 - const trendingQuery = useFeaturedGifsQuery() 100 - const searchQuery = useGifSearchQuery(search) 103 + const klipyTrending = useKlipyFeaturedGifsQuery({enabled: klipyEnabled}) 104 + const klipySearch = useKlipyGifSearchQuery(search, {enabled: klipyEnabled}) 105 + const tenorTrending = useTenorFeaturedGifsQuery({enabled: !klipyEnabled}) 106 + const tenorSearch = useTenorGifSearchQuery(search, {enabled: !klipyEnabled}) 101 107 102 108 const { 103 109 data, ··· 108 114 isPending, 109 115 isError, 110 116 refetch, 111 - } = isSearching ? searchQuery : trendingQuery 117 + } = klipyEnabled 118 + ? isSearching 119 + ? klipySearch 120 + : klipyTrending 121 + : isSearching 122 + ? tenorSearch 123 + : tenorTrending 112 124 113 125 const flattenedData = useMemo(() => { 114 126 return data?.pages.flatMap(page => page.results) || [] ··· 158 170 color="secondary" 159 171 shape="round" 160 172 onPress={() => control.close()} 161 - label={_(msg`Close GIF dialog`)}> 173 + label={l`Close GIF dialog`}> 162 174 <ButtonIcon icon={Arrow} size="md" /> 163 175 </Button> 164 176 )} ··· 166 178 <TextField.Root style={[!gtMobile && IS_WEB && a.flex_1]}> 167 179 <TextField.Icon icon={Search} /> 168 180 <TextField.Input 169 - label={_(msg`Search GIFs`)} 170 - placeholder={_(msg`Search Tenor`)} 181 + label={l`Search GIFs`} 182 + placeholder={klipyEnabled ? l`Search KLIPY` : l`Search Tenor`} 171 183 onChangeText={text => { 172 184 setSearch(text) 173 185 listRef.current?.scrollToOffset({offset: 0, animated: false}) ··· 185 197 </TextField.Root> 186 198 </View> 187 199 ) 188 - }, [gtMobile, t.atoms.bg, _, control]) 200 + }, [gtMobile, t.atoms.bg, l, control, klipyEnabled]) 189 201 190 202 return ( 191 203 <> ··· 212 224 emptyType="results" 213 225 sideBorders={false} 214 226 topBorder={false} 215 - errorTitle={_(msg`Failed to load GIFs`)} 216 - errorMessage={_(msg`There was an issue connecting to Tenor.`)} 227 + errorTitle={l`Failed to load GIFs`} 228 + errorMessage={ 229 + klipyEnabled 230 + ? l`There was an issue connecting to KLIPY.` 231 + : l`There was an issue connecting to Tenor.` 232 + } 217 233 emptyMessage={ 218 234 isSearching 219 - ? _(msg`No search results found for "${search}".`) 220 - : _( 221 - msg`No featured GIFs found. There may be an issue with Tenor.`, 222 - ) 235 + ? l`No search results found for "${search}".` 236 + : klipyEnabled 237 + ? l`No featured GIFs found. There may be an issue with KLIPY.` 238 + : l`No featured GIFs found. There may be an issue with Tenor.` 223 239 } 224 240 /> 225 241 )} ··· 246 262 } 247 263 248 264 function DialogError({details}: {details?: string}) { 249 - const {_} = useLingui() 265 + const {t: l} = useLingui() 250 266 const control = Dialog.useDialogContext() 251 267 252 268 return ( 253 - <Dialog.ScrollableInner 254 - style={a.gap_md} 255 - label={_(msg`An error has occurred`)}> 269 + <Dialog.ScrollableInner style={a.gap_md} label={l`An error has occurred`}> 256 270 <Dialog.Close /> 257 271 <ErrorScreen 258 - title={_(msg`Oh no!`)} 259 - message={_( 260 - msg`There was an unexpected issue in the application. Please let us know if this happened to you!`, 261 - )} 272 + title={l`Oh no!`} 273 + message={l`There was an unexpected issue in the application. Please let us know if this happened to you!`} 262 274 details={details} 263 275 /> 264 276 <Button 265 - label={_(msg`Close dialog`)} 277 + label={l`Close dialog`} 266 278 onPress={() => control.close()} 267 279 color="primary" 268 280 size="large" ··· 284 296 }) { 285 297 const ax = useAnalytics() 286 298 const {gtTablet} = useBreakpoints() 287 - const {_} = useLingui() 299 + const {t: l} = useLingui() 288 300 const t = useTheme() 289 301 290 302 const onPress = useCallback(() => { ··· 294 306 295 307 return ( 296 308 <Button 297 - label={_(msg`Select GIF "${gif.title}"`)} 309 + label={l`Select GIF "${gif.title}"`} 298 310 style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]} 299 311 onPress={onPress}> 300 312 {({pressed}) => ( ··· 308 320 t.atoms.bg_contrast_25, 309 321 ]} 310 322 source={{ 311 - uri: tenorUrlToBskyGifUrl(gif.media_formats.tinygif.url), 323 + uri: gifPreviewUrl(gif.media_formats.tinygif.url), 312 324 }} 313 325 contentFit="cover" 314 326 accessibilityLabel={gif.title}
+31 -3
src/lib/api/resolve.ts
··· 190 190 agent: BskyAgent, 191 191 gif: Gif, 192 192 ): Promise<ResolvedExternalLink> { 193 - const uri = `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[1]}&ww=${gif.media_formats.gif.dims[0]}` 193 + const gifUrl = gif.media_formats.gif.url 194 + const params = new URLSearchParams() 195 + params.set('hh', String(gif.media_formats.gif.dims[1])) 196 + params.set('ww', String(gif.media_formats.gif.dims[0])) 197 + 198 + // For Klipy GIFs, embed video format slugs so parseKlipyGif can 199 + // swap to the right format per platform at render time. Klipy uses 200 + // different filename slugs per format (unlike Tenor where format is 201 + // encoded in the URL ID), so this info must travel with the URL. 202 + try { 203 + const url = new URL(gifUrl) 204 + if (url.hostname === 'static.klipy.com') { 205 + const mp4Slug = getFileSlug(gif.media_formats.mp4?.url) 206 + const webmSlug = getFileSlug(gif.media_formats.webm?.url) 207 + if (mp4Slug) params.set('mp4', mp4Slug) 208 + if (webmSlug) params.set('webm', webmSlug) 209 + } 210 + } catch {} 211 + 212 + const uri = `${gifUrl}?${params.toString()}` 213 + const altText = gif.content_description || gif.title 194 214 return { 195 215 type: 'external', 196 216 uri, 197 - title: gif.content_description, 198 - description: createGIFDescription(gif.content_description), 217 + title: altText, 218 + description: createGIFDescription(altText), 199 219 thumb: await imageToThumb(gif.media_formats.preview.url), 200 220 } 221 + } 222 + 223 + function getFileSlug(url: string | undefined): string | undefined { 224 + if (!url) return undefined 225 + const filename = url.split('/').pop() 226 + if (!filename) return undefined 227 + const dotIndex = filename.lastIndexOf('.') 228 + return dotIndex > 0 ? filename.slice(0, dotIndex) : undefined 201 229 } 202 230 203 231 async function resolveExternal(
+5
src/lib/constants.ts
··· 178 178 export const GIF_FEATURED = (params: string) => 179 179 `${GIF_SERVICE}/tenor/v2/featured?${params}` 180 180 181 + export const GIF_KLIPY_SEARCH = (params: string) => 182 + `${GIF_SERVICE}/klipy/v2/search?${params}` 183 + export const GIF_KLIPY_FEATURED = (params: string) => 184 + `${GIF_SERVICE}/klipy/v2/featured?${params}` 185 + 181 186 export const MAX_LABELERS = 20 182 187 183 188 export const VIDEO_SERVICE = 'https://video.bsky.app'
+104
src/lib/strings/embed-player.ts
··· 23 23 'vimeo', 24 24 'giphy', 25 25 'tenor', 26 + 'klipy', 26 27 'flickr', 27 28 'bandcamp', 28 29 ] as const ··· 44 45 | 'vimeo_video' 45 46 | 'giphy_gif' 46 47 | 'tenor_gif' 48 + | 'klipy_gif' 47 49 | 'flickr_album' 48 50 | 'bandcamp_album' 49 51 | 'bandcamp_track' ··· 55 57 twitch: 'Twitch', 56 58 giphy: 'GIPHY', 57 59 tenor: 'Tenor', 60 + klipy: 'KLIPY', 58 61 spotify: 'Spotify', 59 62 appleMusic: 'Apple Music', 60 63 soundcloud: 'SoundCloud', ··· 391 394 } 392 395 } 393 396 397 + const klipyGif = parseKlipyGif(urlp) 398 + if (klipyGif.success) { 399 + const {playerUri, dimensions} = klipyGif 400 + 401 + return { 402 + type: 'klipy_gif', 403 + source: 'klipy', 404 + isGif: true, 405 + hideDetails: true, 406 + playerUri, 407 + dimensions, 408 + } 409 + } 410 + 394 411 // this is a standard flickr path! we can use the embedder for albums and groups, so validate the path 395 412 if (urlp.hostname === 'www.flickr.com' || urlp.hostname === 'flickr.com') { 396 413 let i = urlp.pathname.length - 1 ··· 628 645 return false 629 646 } 630 647 } 648 + 649 + export function parseKlipyGif(urlp: URL): 650 + | {success: false} 651 + | { 652 + success: true 653 + playerUri: string 654 + dimensions: {height: number; width: number} 655 + } { 656 + if (urlp.hostname !== 'static.klipy.com') { 657 + return {success: false} 658 + } 659 + 660 + if (!urlp.pathname.startsWith('/ii/')) { 661 + return {success: false} 662 + } 663 + 664 + const h = urlp.searchParams.get('hh') 665 + const w = urlp.searchParams.get('ww') 666 + 667 + if (!h || !w) { 668 + return {success: false} 669 + } 670 + 671 + const dimensions = { 672 + height: Number(h), 673 + width: Number(w), 674 + } 675 + 676 + // Validate dimensions are valid positive numbers 677 + if ( 678 + isNaN(dimensions.height) || 679 + isNaN(dimensions.width) || 680 + dimensions.height <= 0 || 681 + dimensions.width <= 0 682 + ) { 683 + return {success: false} 684 + } 685 + 686 + const playerUrl = new URL(urlp.href) 687 + playerUrl.hostname = 'k.gifs.bsky.app' 688 + 689 + // On web, swap the gif filename for a video format so the <video> 690 + // element can play it. Klipy uses different filename slugs per 691 + // format (unlike Tenor's ID-based scheme), so the slugs are 692 + // embedded as query params at composition time by resolveGif(). 693 + if (IS_WEB) { 694 + const webmSlug = playerUrl.searchParams.get('webm') 695 + const mp4Slug = playerUrl.searchParams.get('mp4') 696 + const slug = IS_WEB_SAFARI ? mp4Slug : webmSlug 697 + const ext = IS_WEB_SAFARI ? 'mp4' : 'webm' 698 + 699 + // Without a slug we can't produce a playable video URL on web, 700 + // so fall back to the link card instead of returning a broken player. 701 + if (!slug) { 702 + return {success: false} 703 + } 704 + 705 + const parts = playerUrl.pathname.split('/') 706 + parts[parts.length - 1] = `${slug}.${ext}` 707 + playerUrl.pathname = parts.join('/') 708 + } 709 + 710 + // Strip all metadata params — only the path matters for the CDN 711 + playerUrl.searchParams.delete('hh') 712 + playerUrl.searchParams.delete('ww') 713 + playerUrl.searchParams.delete('mp4') 714 + playerUrl.searchParams.delete('webm') 715 + 716 + return { 717 + success: true, 718 + playerUri: playerUrl.href, 719 + dimensions, 720 + } 721 + } 722 + 723 + export function isKlipyGifUri(url: URL | string) { 724 + try { 725 + return parseKlipyGif(typeof url === 'string' ? new URL(url) : url).success 726 + } catch { 727 + // Invalid URL 728 + return false 729 + } 730 + } 731 + 732 + export function isGifEmbed(url: URL | string) { 733 + return isTenorGifUri(url) || isKlipyGifUri(url) 734 + }
+1
src/state/persisted/schema.ts
··· 98 98 .object({ 99 99 giphy: z.enum(externalEmbedOptions).optional(), 100 100 tenor: z.enum(externalEmbedOptions).optional(), 101 + klipy: z.enum(externalEmbedOptions).optional(), 101 102 youtube: z.enum(externalEmbedOptions).optional(), 102 103 youtubeShorts: z.enum(externalEmbedOptions).optional(), 103 104 twitch: z.enum(externalEmbedOptions).optional(),
+108
src/state/queries/klipy.ts
··· 1 + import {Platform} from 'react-native' 2 + import {getLocales} from 'expo-localization' 3 + import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query' 4 + 5 + import {GIF_KLIPY_FEATURED, GIF_KLIPY_SEARCH} from '#/lib/constants' 6 + import {logger} from '#/logger' 7 + import {type Gif} from '#/state/queries/tenor' 8 + 9 + export const RQKEY_ROOT = 'klipy-gif-service' 10 + export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured'] 11 + export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query] 12 + 13 + const getTrendingGifs = createKlipyApi(GIF_KLIPY_FEATURED) 14 + const searchGifs = createKlipyApi<{q: string}>(GIF_KLIPY_SEARCH) 15 + 16 + export function useFeaturedGifsQuery(options?: {enabled?: boolean}) { 17 + return useInfiniteQuery({ 18 + queryKey: RQKEY_FEATURED, 19 + queryFn: ({pageParam}) => getTrendingGifs({pos: pageParam}), 20 + initialPageParam: undefined as string | undefined, 21 + getNextPageParam: lastPage => lastPage.next, 22 + enabled: options?.enabled, 23 + }) 24 + } 25 + 26 + export function useGifSearchQuery( 27 + query: string, 28 + options?: {enabled?: boolean}, 29 + ) { 30 + return useInfiniteQuery({ 31 + queryKey: RQKEY_SEARCH(query), 32 + queryFn: ({pageParam}) => searchGifs({q: query, pos: pageParam}), 33 + initialPageParam: undefined as string | undefined, 34 + getNextPageParam: lastPage => lastPage.next, 35 + enabled: !!query && options?.enabled !== false, 36 + placeholderData: keepPreviousData, 37 + }) 38 + } 39 + 40 + function createKlipyApi<Input extends object>( 41 + urlFn: (params: string) => string, 42 + ): (input: Input & {pos?: string}) => Promise<{ 43 + next: string 44 + results: Gif[] 45 + }> { 46 + return async input => { 47 + const params = new URLSearchParams() 48 + 49 + params.set( 50 + 'client_key', 51 + Platform.select({ 52 + ios: 'bluesky-ios', 53 + android: 'bluesky-android', 54 + default: 'bluesky-web', 55 + }), 56 + ) 57 + 58 + // 30 is divisible by 2 and 3, so both 2 and 3 column layouts can be used 59 + params.set('limit', '30') 60 + 61 + params.set('contentfilter', 'high') 62 + 63 + const locale = getLocales?.()?.[0] 64 + 65 + if (locale) { 66 + params.set('locale', locale.languageTag.replace('-', '_')) 67 + } 68 + 69 + for (const [key, value] of Object.entries(input)) { 70 + if (value !== undefined) { 71 + params.set(key, String(value)) 72 + } 73 + } 74 + 75 + const res = await fetch(urlFn(params.toString()), { 76 + method: 'GET', 77 + headers: { 78 + 'Content-Type': 'application/json', 79 + }, 80 + }) 81 + if (!res.ok) { 82 + throw new Error(`Failed to fetch KLIPY API (status ${res.status})`) 83 + } 84 + const body: {next: string; results: Gif[]} = await res.json() 85 + return { 86 + next: body.next, 87 + results: body.results, 88 + } 89 + } 90 + } 91 + 92 + /** 93 + * Rewrites a KLIPY static CDN URL through the bsky proxy 94 + * (k.gifs.bsky.app). Mirrors `tenorUrlToBskyGifUrl`, but uses a 95 + * separate hostname from Tenor's t.gifs.bsky.app so the two 96 + * upstreams can be routed independently. 97 + */ 98 + export function klipyUrlToBskyGifUrl(klipyUrl: string) { 99 + let url 100 + try { 101 + url = new URL(klipyUrl) 102 + } catch (e) { 103 + logger.debug('invalid url passed to klipyUrlToBskyGifUrl()') 104 + return '' 105 + } 106 + url.hostname = 'k.gifs.bsky.app' 107 + return url.href 108 + }
+42 -10
src/state/queries/tenor.ts
··· 13 13 14 14 const searchGifs = createTenorApi<{q: string}>(GIF_SEARCH) 15 15 16 - export function useFeaturedGifsQuery() { 16 + export function useTenorFeaturedGifsQuery(options?: {enabled?: boolean}) { 17 17 return useInfiniteQuery({ 18 18 queryKey: RQKEY_FEATURED, 19 19 queryFn: ({pageParam}) => getTrendingGifs({pos: pageParam}), 20 20 initialPageParam: undefined as string | undefined, 21 21 getNextPageParam: lastPage => lastPage.next, 22 + enabled: options?.enabled, 22 23 }) 23 24 } 24 25 25 - export function useGifSearchQuery(query: string) { 26 + export function useTenorGifSearchQuery( 27 + query: string, 28 + options?: {enabled?: boolean}, 29 + ) { 26 30 return useInfiniteQuery({ 27 31 queryKey: RQKEY_SEARCH(query), 28 32 queryFn: ({pageParam}) => searchGifs({q: query, pos: pageParam}), 29 33 initialPageParam: undefined as string | undefined, 30 34 getNextPageParam: lastPage => lastPage.next, 31 - enabled: !!query, 35 + enabled: !!query && options?.enabled !== false, 32 36 placeholderData: keepPreviousData, 33 37 }) 34 38 } ··· 99 103 return url.href 100 104 } 101 105 106 + /** 107 + * Returns the appropriate URL for a GIF preview image. 108 + * Tenor URLs (media.tenor.com) are routed through t.gifs.bsky.app; 109 + * KLIPY URLs (static.klipy.com) are routed through k.gifs.bsky.app. 110 + */ 111 + export function gifPreviewUrl(gifUrl: string) { 112 + try { 113 + const url = new URL(gifUrl) 114 + if (url.hostname === 'media.tenor.com') { 115 + url.hostname = 't.gifs.bsky.app' 116 + return url.href 117 + } 118 + if (url.hostname === 'static.klipy.com') { 119 + url.hostname = 'k.gifs.bsky.app' 120 + return url.href 121 + } 122 + return gifUrl 123 + } catch (e) { 124 + logger.debug('invalid url passed to gifPreviewUrl()') 125 + return '' 126 + } 127 + } 128 + 102 129 export type Gif = { 103 130 /** 104 131 * A Unix timestamp that represents when this post was created. ··· 116 143 /** 117 144 * A dictionary with a content format as the key and a Media Object as the value. 118 145 */ 119 - media_formats: Record<ContentFormats, MediaObject> 146 + media_formats: Record<BaseContentFormats, MediaObject> & 147 + Partial<Record<VideoContentFormats, MediaObject>> 120 148 /** 121 149 * An array of tags for the post 122 150 */ ··· 171 199 size: number 172 200 } 173 201 174 - type ContentFormats = 202 + type BaseContentFormats = 175 203 | 'preview' 176 204 | 'gif' 177 205 // | 'mediumgif' 178 206 | 'tinygif' 179 207 // | 'nanogif' 180 - // | 'mp4' 181 - // | 'loopedmp4' 182 - // | 'tinymp4' 183 - // | 'nanomp4' 184 - // | 'webm' 208 + 209 + type VideoContentFormats = 210 + | 'mp4' 211 + // | 'loopedmp4' 212 + // | 'tinymp4' 213 + // | 'nanomp4' 214 + | 'webm' 185 215 // | 'tinywebm' 186 216 // | 'nanowebm' 217 + 218 + type ContentFormats = BaseContentFormats | VideoContentFormats
+4 -1
src/view/com/composer/drafts/state/api.ts
··· 26 26 import * as storage from './storage' 27 27 28 28 const TENOR_HOSTNAME = 'media.tenor.com' 29 + const KLIPY_HOSTNAME = 'static.klipy.com' 29 30 30 31 /** 31 32 * Video data from a draft that needs to be restored by re-processing. ··· 379 380 ): {url: string; width: number; height: number; alt: string} | undefined { 380 381 try { 381 382 const url = new URL(uri) 382 - if (url.hostname !== TENOR_HOSTNAME) { 383 + if (url.hostname !== TENOR_HOSTNAME && url.hostname !== KLIPY_HOSTNAME) { 383 384 return undefined 384 385 } 385 386 ··· 396 397 url.searchParams.delete('ww') 397 398 url.searchParams.delete('hh') 398 399 url.searchParams.delete('alt') 400 + url.searchParams.delete('mp4') 401 + url.searchParams.delete('webm') 399 402 400 403 return {url: url.toString(), width, height, alt} 401 404 } catch {