Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

[GIFs] Replace GIPHY with Tenor (#3651)

* replace GIPHY with Tenor

* remove "directly" wording

* replace GIPHY wording

* remove log

authored by

Samuel Newman and committed by
GitHub
76449fb6 1a4e05e9

+220 -335
+27 -38
src/components/dialogs/GifSelect.tsx
··· 5 5 import {msg, Trans} from '@lingui/macro' 6 6 import {useLingui} from '@lingui/react' 7 7 8 - import {GIPHY_PRIVACY_POLICY} from '#/lib/constants' 9 8 import {logEvent} from '#/lib/statsig/statsig' 10 9 import {cleanError} from '#/lib/strings/errors' 11 10 import {isWeb} from '#/platform/detection' ··· 13 12 useExternalEmbedsPrefs, 14 13 useSetExternalEmbedPref, 15 14 } from '#/state/preferences' 16 - import {Gif, useGifphySearch, useGiphyTrending} from '#/state/queries/giphy' 15 + import { 16 + Gif, 17 + useFeaturedGifsQuery, 18 + useGifSearchQuery, 19 + } from '#/state/queries/tenor' 17 20 import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' 18 21 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 19 22 import {atoms as a, useBreakpoints, useTheme} from '#/alf' ··· 22 25 import {useThrottledValue} from '#/components/hooks/useThrottledValue' 23 26 import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' 24 27 import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' 25 - import {InlineLinkText} from '#/components/Link' 26 28 import {Button, ButtonIcon, ButtonText} from '../Button' 27 29 import {ListFooter, ListMaybePlaceholder} from '../Lists' 28 30 import {Text} from '../Typography' ··· 46 48 47 49 let content = null 48 50 let snapPoints 49 - switch (externalEmbedsPrefs?.giphy) { 51 + switch (externalEmbedsPrefs?.tenor) { 50 52 case 'show': 51 53 content = <GifList control={control} onSelectGif={onSelectGif} /> 52 54 snapPoints = ['100%'] 53 55 break 54 56 case 'hide': 55 57 default: 56 - content = <GiphyConsentPrompt control={control} /> 58 + content = <TenorConsentPrompt control={control} /> 57 59 break 58 60 } 59 61 ··· 90 92 91 93 const isSearching = search.length > 0 92 94 93 - const trendingQuery = useGiphyTrending() 94 - const searchQuery = useGifphySearch(search) 95 + const trendingQuery = useFeaturedGifsQuery() 96 + const searchQuery = useGifSearchQuery(search) 95 97 96 98 const { 97 99 data, ··· 105 107 } = isSearching ? searchQuery : trendingQuery 106 108 107 109 const flattenedData = useMemo(() => { 108 - const uniquenessSet = new Set<string>() 109 - 110 - function filter(gif: Gif) { 111 - if (!gif) return false 112 - if (uniquenessSet.has(gif.id)) { 113 - return false 114 - } 115 - uniquenessSet.add(gif.id) 116 - return true 117 - } 118 - return data?.pages.flatMap(page => page.data.filter(filter)) || [] 110 + return data?.pages.flatMap(page => page.results) || [] 119 111 }, [data]) 120 112 121 113 const renderItem = useCallback( ··· 181 173 <TextField.Icon icon={Search} /> 182 174 <TextField.Input 183 175 label={_(msg`Search GIFs`)} 184 - placeholder={_(msg`Powered by GIPHY`)} 176 + placeholder={_(msg`Search Tenor`)} 185 177 onChangeText={text => { 186 178 setSearch(text) 187 179 listRef.current?.scrollToOffset({offset: 0, animated: false}) ··· 223 215 emptyType="results" 224 216 sideBorders={false} 225 217 errorTitle={_(msg`Failed to load GIFs`)} 226 - errorMessage={_(msg`There was an issue connecting to GIPHY.`)} 218 + errorMessage={_(msg`There was an issue connecting to Tenor.`)} 227 219 emptyMessage={ 228 220 isSearching 229 221 ? _(msg`No search results found for "${search}".`) 230 222 : _( 231 - msg`No trending GIFs found. There may be an issue with GIPHY.`, 223 + msg`No featured GIFs found. There may be an issue with Tenor.`, 232 224 ) 233 225 } 234 226 /> ··· 287 279 {aspectRatio: 1, opacity: pressed ? 0.8 : 1}, 288 280 t.atoms.bg_contrast_25, 289 281 ]} 290 - source={{uri: gif.images.preview_gif.url}} 282 + source={{ 283 + uri: gif.media_formats.tinygif.url, 284 + }} 291 285 contentFit="cover" 292 286 accessibilityLabel={gif.title} 293 287 accessibilityHint="" ··· 299 293 ) 300 294 } 301 295 302 - function GiphyConsentPrompt({control}: {control: Dialog.DialogControlProps}) { 296 + function TenorConsentPrompt({control}: {control: Dialog.DialogControlProps}) { 303 297 const {_} = useLingui() 304 298 const t = useTheme() 305 299 const {gtMobile} = useBreakpoints() 306 300 const setExternalEmbedPref = useSetExternalEmbedPref() 307 301 308 302 const onShowPress = useCallback(() => { 309 - setExternalEmbedPref('giphy', 'show') 303 + setExternalEmbedPref('tenor', 'show') 310 304 }, [setExternalEmbedPref]) 311 305 312 306 const onHidePress = useCallback(() => { 313 - setExternalEmbedPref('giphy', 'hide') 307 + setExternalEmbedPref('tenor', 'hide') 314 308 control.close() 315 309 }, [control, setExternalEmbedPref]) 316 310 317 311 const gtMobileWeb = gtMobile && isWeb 318 312 319 313 return ( 320 - <Dialog.ScrollableInner label={_(msg`Permission to use GIPHY`)}> 314 + <Dialog.ScrollableInner label={_(msg`Permission to use Tenor`)}> 321 315 <View style={a.gap_sm}> 322 316 <Text style={[a.text_2xl, a.font_bold]}> 323 - <Trans>Permission to use GIPHY</Trans> 317 + <Trans>Permission to use Tenor</Trans> 324 318 </Text> 325 319 326 320 <View style={[a.mt_sm, a.mb_2xl, a.gap_lg]}> 327 321 <Text> 328 322 <Trans> 329 - Bluesky uses GIPHY to provide the GIF selector feature. 323 + Bluesky uses Tenor to provide the GIF selector feature. 330 324 </Trans> 331 325 </Text> 332 326 333 327 <Text style={t.atoms.text_contrast_medium}> 334 328 <Trans> 335 - GIPHY may collect information about you and your device. You can 336 - find out more in their{' '} 337 - <InlineLinkText 338 - to={GIPHY_PRIVACY_POLICY} 339 - onPress={() => control.close()}> 340 - privacy policy 341 - </InlineLinkText> 342 - . 329 + Tenor is a third-party service that provides GIFs for use in 330 + Bluesky. By enabling Tenor, requests will be made to Tenor's 331 + servers to retrieve the GIFs. 343 332 </Trans> 344 333 </Text> 345 334 </View> 346 335 </View> 347 336 <View style={[a.gap_md, gtMobileWeb && a.flex_row_reverse]}> 348 337 <Button 349 - label={_(msg`Enable GIPHY`)} 338 + label={_(msg`Enable Tenor`)} 350 339 onPress={onShowPress} 351 340 onAccessibilityEscape={control.close} 352 341 color="primary" 353 342 size={gtMobileWeb ? 'small' : 'medium'} 354 343 variant="solid"> 355 344 <ButtonText> 356 - <Trans>Enable GIPHY</Trans> 345 + <Trans>Enable Tenor</Trans> 357 346 </ButtonText> 358 347 </Button> 359 348 <Button
+6 -8
src/lib/constants.ts
··· 90 90 'did:plc:q6gjnaw2blty4crticxkmujt', 91 91 ] 92 92 93 - export const GIPHY_API_URL = 'https://api.giphy.com' 94 - export const GIPHY_API_KEY = Platform.select({ 95 - ios: 'ydVxhrQkwlcUjkVKx15mF6vyaNJbMeez', 96 - android: 'Vwj3Ib7857dj3EcIg24Hiz1LbRVdGeYF', 97 - default: 'vyL3hQQ8AipwcmIB8kFvg0NDs9faWg7G', 98 - }) 99 - export const GIPHY_PRIVACY_POLICY = 100 - 'https://support.giphy.com/hc/en-us/articles/360032872931-GIPHY-Privacy-Policy' 93 + export const GIF_SERVICE = 'https://gifs.bsky.app' 94 + 95 + export const GIF_SEARCH = (params: string) => 96 + `${GIF_SERVICE}/tenor/v2/search?${params}` 97 + export const GIF_FEATURED = (params: string) => 98 + `${GIF_SERVICE}/tenor/v2/featured?${params}`
-280
src/state/queries/giphy.ts
··· 1 - import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query' 2 - 3 - import {GIPHY_API_KEY, GIPHY_API_URL} from '#/lib/constants' 4 - 5 - export const RQKEY_ROOT = 'giphy' 6 - export const RQKEY_TRENDING = [RQKEY_ROOT, 'trending'] 7 - export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query] 8 - 9 - const getTrendingGifs = createGiphyApi< 10 - { 11 - limit?: number 12 - offset?: number 13 - rating?: string 14 - random_id?: string 15 - bundle?: string 16 - }, 17 - {data: Gif[]; pagination: Pagination} 18 - >('/v1/gifs/trending') 19 - 20 - const searchGifs = createGiphyApi< 21 - { 22 - q: string 23 - limit?: number 24 - offset?: number 25 - rating?: string 26 - lang?: string 27 - random_id?: string 28 - bundle?: string 29 - }, 30 - {data: Gif[]; pagination: Pagination} 31 - >('/v1/gifs/search') 32 - 33 - export function useGiphyTrending() { 34 - return useInfiniteQuery({ 35 - queryKey: RQKEY_TRENDING, 36 - queryFn: ({pageParam}) => getTrendingGifs({offset: pageParam}), 37 - initialPageParam: 0, 38 - getNextPageParam: lastPage => 39 - lastPage.pagination.offset + lastPage.pagination.count, 40 - }) 41 - } 42 - 43 - export function useGifphySearch(query: string) { 44 - return useInfiniteQuery({ 45 - queryKey: RQKEY_SEARCH(query), 46 - queryFn: ({pageParam}) => searchGifs({q: query, offset: pageParam}), 47 - initialPageParam: 0, 48 - getNextPageParam: lastPage => 49 - lastPage.pagination.offset + lastPage.pagination.count, 50 - enabled: !!query, 51 - placeholderData: keepPreviousData, 52 - }) 53 - } 54 - 55 - function createGiphyApi<Input extends object, Ouput>( 56 - path: string, 57 - ): (input: Input) => Promise< 58 - Ouput & { 59 - meta: Meta 60 - } 61 - > { 62 - return async input => { 63 - const url = new URL(path, GIPHY_API_URL) 64 - url.searchParams.set('api_key', GIPHY_API_KEY) 65 - 66 - for (const [key, value] of Object.entries(input)) { 67 - url.searchParams.set(key, String(value)) 68 - } 69 - 70 - const res = await fetch(url.toString(), { 71 - method: 'GET', 72 - headers: { 73 - 'Content-Type': 'application/json', 74 - }, 75 - }) 76 - if (!res.ok) { 77 - throw new Error('Failed to fetch Giphy API') 78 - } 79 - return res.json() 80 - } 81 - } 82 - 83 - export type Gif = { 84 - type: string 85 - id: string 86 - slug: string 87 - url: string 88 - bitly_url: string 89 - embed_url: string 90 - username: string 91 - source: string 92 - rating: string 93 - content_url: string 94 - user: User 95 - source_tld: string 96 - source_post_url: string 97 - update_datetime: string 98 - create_datetime: string 99 - import_datetime: string 100 - trending_datetime: string 101 - images: Images 102 - title: string 103 - alt_text: string 104 - } 105 - 106 - type Images = { 107 - fixed_height: { 108 - url: string 109 - width: string 110 - height: string 111 - size: string 112 - mp4: string 113 - mp4_size: string 114 - webp: string 115 - webp_size: string 116 - } 117 - 118 - fixed_height_still: { 119 - url: string 120 - width: string 121 - height: string 122 - } 123 - 124 - fixed_height_downsampled: { 125 - url: string 126 - width: string 127 - height: string 128 - size: string 129 - webp: string 130 - webp_size: string 131 - } 132 - 133 - fixed_width: { 134 - url: string 135 - width: string 136 - height: string 137 - size: string 138 - mp4: string 139 - mp4_size: string 140 - webp: string 141 - webp_size: string 142 - } 143 - 144 - fixed_width_still: { 145 - url: string 146 - width: string 147 - height: string 148 - } 149 - 150 - fixed_width_downsampled: { 151 - url: string 152 - width: string 153 - height: string 154 - size: string 155 - webp: string 156 - webp_size: string 157 - } 158 - 159 - fixed_height_small: { 160 - url: string 161 - width: string 162 - height: string 163 - size: string 164 - mp4: string 165 - mp4_size: string 166 - webp: string 167 - webp_size: string 168 - } 169 - 170 - fixed_height_small_still: { 171 - url: string 172 - width: string 173 - height: string 174 - } 175 - 176 - fixed_width_small: { 177 - url: string 178 - width: string 179 - height: string 180 - size: string 181 - mp4: string 182 - mp4_size: string 183 - webp: string 184 - webp_size: string 185 - } 186 - 187 - fixed_width_small_still: { 188 - url: string 189 - width: string 190 - height: string 191 - } 192 - 193 - downsized: { 194 - url: string 195 - width: string 196 - height: string 197 - size: string 198 - } 199 - 200 - downsized_still: { 201 - url: string 202 - width: string 203 - height: string 204 - } 205 - 206 - downsized_large: { 207 - url: string 208 - width: string 209 - height: string 210 - size: string 211 - } 212 - 213 - downsized_medium: { 214 - url: string 215 - width: string 216 - height: string 217 - size: string 218 - } 219 - 220 - downsized_small: { 221 - mp4: string 222 - width: string 223 - height: string 224 - mp4_size: string 225 - } 226 - 227 - original: { 228 - width: string 229 - height: string 230 - size: string 231 - frames: string 232 - mp4: string 233 - mp4_size: string 234 - webp: string 235 - webp_size: string 236 - } 237 - 238 - original_still: { 239 - url: string 240 - width: string 241 - height: string 242 - } 243 - 244 - looping: { 245 - mp4: string 246 - } 247 - 248 - preview: { 249 - mp4: string 250 - mp4_size: string 251 - width: string 252 - height: string 253 - } 254 - 255 - preview_gif: { 256 - url: string 257 - width: string 258 - height: string 259 - } 260 - } 261 - 262 - type User = { 263 - avatar_url: string 264 - banner_url: string 265 - profile_url: string 266 - username: string 267 - display_name: string 268 - } 269 - 270 - type Meta = { 271 - msg: string 272 - status: number 273 - response_id: string 274 - } 275 - 276 - type Pagination = { 277 - offset: number 278 - total_count: number 279 - count: number 280 - }
+177
src/state/queries/tenor.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_FEATURED, GIF_SEARCH} from '#/lib/constants' 6 + 7 + export const RQKEY_ROOT = 'gif-service' 8 + export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured'] 9 + export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query] 10 + 11 + const getTrendingGifs = createTenorApi(GIF_FEATURED) 12 + 13 + const searchGifs = createTenorApi<{q: string}>(GIF_SEARCH) 14 + 15 + export function useFeaturedGifsQuery() { 16 + return useInfiniteQuery({ 17 + queryKey: RQKEY_FEATURED, 18 + queryFn: ({pageParam}) => getTrendingGifs({pos: pageParam}), 19 + initialPageParam: undefined as string | undefined, 20 + getNextPageParam: lastPage => lastPage.next, 21 + }) 22 + } 23 + 24 + export function useGifSearchQuery(query: string) { 25 + return useInfiniteQuery({ 26 + queryKey: RQKEY_SEARCH(query), 27 + queryFn: ({pageParam}) => searchGifs({q: query, pos: pageParam}), 28 + initialPageParam: undefined as string | undefined, 29 + getNextPageParam: lastPage => lastPage.next, 30 + enabled: !!query, 31 + placeholderData: keepPreviousData, 32 + }) 33 + } 34 + 35 + function createTenorApi<Input extends object>( 36 + urlFn: (params: string) => string, 37 + ): (input: Input & {pos?: string}) => Promise<{ 38 + next: string 39 + results: Gif[] 40 + }> { 41 + return async input => { 42 + const params = new URLSearchParams() 43 + 44 + // set client key based on platform 45 + params.set( 46 + 'client_key', 47 + Platform.select({ 48 + ios: 'bluesky-ios', 49 + android: 'bluesky-android', 50 + default: 'bluesky-web', 51 + }), 52 + ) 53 + 54 + // 30 is divisible by 2 and 3, so both 2 and 3 column layouts can be used 55 + params.set('limit', '30') 56 + 57 + params.set('contentfilter', 'high') 58 + 59 + params.set( 60 + 'media_filter', 61 + (['preview', 'gif', 'tinygif'] satisfies ContentFormats[]).join(','), 62 + ) 63 + 64 + const locale = getLocales?.()?.[0] 65 + 66 + if (locale) { 67 + params.set('locale', locale.languageTag.replace('-', '_')) 68 + 69 + if (locale.regionCode) { 70 + params.set('country', locale.regionCode) 71 + } 72 + } 73 + 74 + for (const [key, value] of Object.entries(input)) { 75 + if (value !== undefined) { 76 + params.set(key, String(value)) 77 + } 78 + } 79 + 80 + const res = await fetch(urlFn(params.toString()), { 81 + method: 'GET', 82 + headers: { 83 + 'Content-Type': 'application/json', 84 + }, 85 + }) 86 + if (!res.ok) { 87 + throw new Error('Failed to fetch Tenor API') 88 + } 89 + return res.json() 90 + } 91 + } 92 + 93 + export type Gif = { 94 + /** 95 + * A Unix timestamp that represents when this post was created. 96 + */ 97 + created: number 98 + /** 99 + * Returns true if this post contains audio. 100 + * Note: Only video formats support audio. The GIF image file format can't contain audio information. 101 + */ 102 + hasaudio: boolean 103 + /** 104 + * Tenor result identifier 105 + */ 106 + id: string 107 + /** 108 + * A dictionary with a content format as the key and a Media Object as the value. 109 + */ 110 + media_formats: Record<ContentFormats, MediaObject> 111 + /** 112 + * An array of tags for the post 113 + */ 114 + tags: string[] 115 + /** 116 + * The title of the post 117 + */ 118 + title: string 119 + /** 120 + * A textual description of the content. 121 + * We recommend that you use content_description for user accessibility features. 122 + */ 123 + content_description: string 124 + /** 125 + * The full URL to view the post on tenor.com. 126 + */ 127 + itemurl: string 128 + /** 129 + * Returns true if this post contains captions. 130 + */ 131 + hascaption: boolean 132 + /** 133 + * Comma-separated list to signify whether the content is a sticker or static image, has audio, or is any combination of these. If sticker and static aren't present, then the content is a GIF. A blank flags field signifies a GIF without audio. 134 + */ 135 + flags: string 136 + /** 137 + * The most common background pixel color of the content 138 + */ 139 + bg_color?: string 140 + /** 141 + * A short URL to view the post on tenor.com. 142 + */ 143 + url: string 144 + } 145 + 146 + type MediaObject = { 147 + /** 148 + * A URL to the media source 149 + */ 150 + url: string 151 + /** 152 + * Width and height of the media in pixels 153 + */ 154 + dims: [number, number] 155 + /** 156 + * Represents the time in seconds for one loop of the content. If the content is static, the duration is set to 0. 157 + */ 158 + duration: number 159 + /** 160 + * Size of the file in bytes 161 + */ 162 + size: number 163 + } 164 + 165 + type ContentFormats = 166 + | 'preview' 167 + | 'gif' 168 + // | 'mediumgif' 169 + | 'tinygif' 170 + // | 'nanogif' 171 + // | 'mp4' 172 + // | 'loopedmp4' 173 + // | 'tinymp4' 174 + // | 'nanomp4' 175 + // | 'webm' 176 + // | 'tinywebm' 177 + // | 'nanowebm'
+9 -8
src/view/com/composer/Composer.tsx
··· 29 29 useLanguagePrefs, 30 30 useLanguagePrefsApi, 31 31 } from '#/state/preferences/languages' 32 - import {Gif} from '#/state/queries/giphy' 33 32 import {useProfileQuery} from '#/state/queries/profile' 33 + import {Gif} from '#/state/queries/tenor' 34 34 import {ThreadgateSetting} from '#/state/queries/threadgate' 35 35 import {getAgent, useSession} from '#/state/session' 36 36 import {useComposerControls} from '#/state/shell/composer' ··· 316 316 }, []) 317 317 318 318 const onSelectGif = useCallback( 319 - (gif: Gif) => 319 + (gif: Gif) => { 320 320 setExtLink({ 321 - uri: `${gif.url}?hh=${gif.images.original.height}&ww=${gif.images.original.width}`, 321 + uri: `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[0]}&ww=${gif.media_formats.gif.dims[1]}`, 322 322 isLoading: true, 323 323 meta: { 324 - url: gif.url, 325 - image: gif.images.original_still.url, 324 + url: gif.media_formats.gif.url, 325 + image: gif.media_formats.preview.url, 326 326 likelyType: LikelyType.HTML, 327 - title: `${gif.title} - Find & Share on GIPHY`, 328 - description: `ALT: ${gif.alt_text}`, 327 + title: gif.content_description, 328 + description: `ALT: ${gif.content_description}`, 329 329 }, 330 - }), 330 + }) 331 + }, 331 332 [setExtLink], 332 333 ) 333 334
+1 -1
src/view/com/composer/photos/SelectGifBtn.tsx
··· 4 4 import {useLingui} from '@lingui/react' 5 5 6 6 import {logEvent} from '#/lib/statsig/statsig' 7 - import {Gif} from '#/state/queries/giphy' 7 + import {Gif} from '#/state/queries/tenor' 8 8 import {atoms as a, useTheme} from '#/alf' 9 9 import {Button} from '#/components/Button' 10 10 import {useDialogControl} from '#/components/Dialog'