about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/dialogs/GifSelect.tsx65
-rw-r--r--src/lib/constants.ts14
-rw-r--r--src/state/queries/giphy.ts280
-rw-r--r--src/state/queries/tenor.ts177
-rw-r--r--src/view/com/composer/Composer.tsx17
-rw-r--r--src/view/com/composer/photos/SelectGifBtn.tsx2
6 files changed, 220 insertions, 335 deletions
diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx
index a8fe016d1..41612aa5d 100644
--- a/src/components/dialogs/GifSelect.tsx
+++ b/src/components/dialogs/GifSelect.tsx
@@ -5,7 +5,6 @@ import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {GIPHY_PRIVACY_POLICY} from '#/lib/constants'
 import {logEvent} from '#/lib/statsig/statsig'
 import {cleanError} from '#/lib/strings/errors'
 import {isWeb} from '#/platform/detection'
@@ -13,7 +12,11 @@ import {
   useExternalEmbedsPrefs,
   useSetExternalEmbedPref,
 } from '#/state/preferences'
-import {Gif, useGifphySearch, useGiphyTrending} from '#/state/queries/giphy'
+import {
+  Gif,
+  useFeaturedGifsQuery,
+  useGifSearchQuery,
+} from '#/state/queries/tenor'
 import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
 import {atoms as a, useBreakpoints, useTheme} from '#/alf'
@@ -22,7 +25,6 @@ import * as TextField from '#/components/forms/TextField'
 import {useThrottledValue} from '#/components/hooks/useThrottledValue'
 import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
 import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
-import {InlineLinkText} from '#/components/Link'
 import {Button, ButtonIcon, ButtonText} from '../Button'
 import {ListFooter, ListMaybePlaceholder} from '../Lists'
 import {Text} from '../Typography'
@@ -46,14 +48,14 @@ export function GifSelectDialog({
 
   let content = null
   let snapPoints
-  switch (externalEmbedsPrefs?.giphy) {
+  switch (externalEmbedsPrefs?.tenor) {
     case 'show':
       content = <GifList control={control} onSelectGif={onSelectGif} />
       snapPoints = ['100%']
       break
     case 'hide':
     default:
-      content = <GiphyConsentPrompt control={control} />
+      content = <TenorConsentPrompt control={control} />
       break
   }
 
@@ -90,8 +92,8 @@ function GifList({
 
   const isSearching = search.length > 0
 
-  const trendingQuery = useGiphyTrending()
-  const searchQuery = useGifphySearch(search)
+  const trendingQuery = useFeaturedGifsQuery()
+  const searchQuery = useGifSearchQuery(search)
 
   const {
     data,
@@ -105,17 +107,7 @@ function GifList({
   } = isSearching ? searchQuery : trendingQuery
 
   const flattenedData = useMemo(() => {
-    const uniquenessSet = new Set<string>()
-
-    function filter(gif: Gif) {
-      if (!gif) return false
-      if (uniquenessSet.has(gif.id)) {
-        return false
-      }
-      uniquenessSet.add(gif.id)
-      return true
-    }
-    return data?.pages.flatMap(page => page.data.filter(filter)) || []
+    return data?.pages.flatMap(page => page.results) || []
   }, [data])
 
   const renderItem = useCallback(
@@ -181,7 +173,7 @@ function GifList({
           <TextField.Icon icon={Search} />
           <TextField.Input
             label={_(msg`Search GIFs`)}
-            placeholder={_(msg`Powered by GIPHY`)}
+            placeholder={_(msg`Search Tenor`)}
             onChangeText={text => {
               setSearch(text)
               listRef.current?.scrollToOffset({offset: 0, animated: false})
@@ -223,12 +215,12 @@ function GifList({
                 emptyType="results"
                 sideBorders={false}
                 errorTitle={_(msg`Failed to load GIFs`)}
-                errorMessage={_(msg`There was an issue connecting to GIPHY.`)}
+                errorMessage={_(msg`There was an issue connecting to Tenor.`)}
                 emptyMessage={
                   isSearching
                     ? _(msg`No search results found for "${search}".`)
                     : _(
-                        msg`No trending GIFs found. There may be an issue with GIPHY.`,
+                        msg`No featured GIFs found. There may be an issue with Tenor.`,
                       )
                 }
               />
@@ -287,7 +279,9 @@ function GifPreview({
             {aspectRatio: 1, opacity: pressed ? 0.8 : 1},
             t.atoms.bg_contrast_25,
           ]}
-          source={{uri: gif.images.preview_gif.url}}
+          source={{
+            uri: gif.media_formats.tinygif.url,
+          }}
           contentFit="cover"
           accessibilityLabel={gif.title}
           accessibilityHint=""
@@ -299,61 +293,56 @@ function GifPreview({
   )
 }
 
-function GiphyConsentPrompt({control}: {control: Dialog.DialogControlProps}) {
+function TenorConsentPrompt({control}: {control: Dialog.DialogControlProps}) {
   const {_} = useLingui()
   const t = useTheme()
   const {gtMobile} = useBreakpoints()
   const setExternalEmbedPref = useSetExternalEmbedPref()
 
   const onShowPress = useCallback(() => {
-    setExternalEmbedPref('giphy', 'show')
+    setExternalEmbedPref('tenor', 'show')
   }, [setExternalEmbedPref])
 
   const onHidePress = useCallback(() => {
-    setExternalEmbedPref('giphy', 'hide')
+    setExternalEmbedPref('tenor', 'hide')
     control.close()
   }, [control, setExternalEmbedPref])
 
   const gtMobileWeb = gtMobile && isWeb
 
   return (
-    <Dialog.ScrollableInner label={_(msg`Permission to use GIPHY`)}>
+    <Dialog.ScrollableInner label={_(msg`Permission to use Tenor`)}>
       <View style={a.gap_sm}>
         <Text style={[a.text_2xl, a.font_bold]}>
-          <Trans>Permission to use GIPHY</Trans>
+          <Trans>Permission to use Tenor</Trans>
         </Text>
 
         <View style={[a.mt_sm, a.mb_2xl, a.gap_lg]}>
           <Text>
             <Trans>
-              Bluesky uses GIPHY to provide the GIF selector feature.
+              Bluesky uses Tenor to provide the GIF selector feature.
             </Trans>
           </Text>
 
           <Text style={t.atoms.text_contrast_medium}>
             <Trans>
-              GIPHY may collect information about you and your device. You can
-              find out more in their{' '}
-              <InlineLinkText
-                to={GIPHY_PRIVACY_POLICY}
-                onPress={() => control.close()}>
-                privacy policy
-              </InlineLinkText>
-              .
+              Tenor is a third-party service that provides GIFs for use in
+              Bluesky. By enabling Tenor, requests will be made to Tenor's
+              servers to retrieve the GIFs.
             </Trans>
           </Text>
         </View>
       </View>
       <View style={[a.gap_md, gtMobileWeb && a.flex_row_reverse]}>
         <Button
-          label={_(msg`Enable GIPHY`)}
+          label={_(msg`Enable Tenor`)}
           onPress={onShowPress}
           onAccessibilityEscape={control.close}
           color="primary"
           size={gtMobileWeb ? 'small' : 'medium'}
           variant="solid">
           <ButtonText>
-            <Trans>Enable GIPHY</Trans>
+            <Trans>Enable Tenor</Trans>
           </ButtonText>
         </Button>
         <Button
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index b96529b1f..bbfdbe27f 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -90,11 +90,9 @@ export const BSKY_FEED_OWNER_DIDS = [
   'did:plc:q6gjnaw2blty4crticxkmujt',
 ]
 
-export const GIPHY_API_URL = 'https://api.giphy.com'
-export const GIPHY_API_KEY = Platform.select({
-  ios: 'ydVxhrQkwlcUjkVKx15mF6vyaNJbMeez',
-  android: 'Vwj3Ib7857dj3EcIg24Hiz1LbRVdGeYF',
-  default: 'vyL3hQQ8AipwcmIB8kFvg0NDs9faWg7G',
-})
-export const GIPHY_PRIVACY_POLICY =
-  'https://support.giphy.com/hc/en-us/articles/360032872931-GIPHY-Privacy-Policy'
+export const GIF_SERVICE = 'https://gifs.bsky.app'
+
+export const GIF_SEARCH = (params: string) =>
+  `${GIF_SERVICE}/tenor/v2/search?${params}`
+export const GIF_FEATURED = (params: string) =>
+  `${GIF_SERVICE}/tenor/v2/featured?${params}`
diff --git a/src/state/queries/giphy.ts b/src/state/queries/giphy.ts
deleted file mode 100644
index ca5ff65f5..000000000
--- a/src/state/queries/giphy.ts
+++ /dev/null
@@ -1,280 +0,0 @@
-import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query'
-
-import {GIPHY_API_KEY, GIPHY_API_URL} from '#/lib/constants'
-
-export const RQKEY_ROOT = 'giphy'
-export const RQKEY_TRENDING = [RQKEY_ROOT, 'trending']
-export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query]
-
-const getTrendingGifs = createGiphyApi<
-  {
-    limit?: number
-    offset?: number
-    rating?: string
-    random_id?: string
-    bundle?: string
-  },
-  {data: Gif[]; pagination: Pagination}
->('/v1/gifs/trending')
-
-const searchGifs = createGiphyApi<
-  {
-    q: string
-    limit?: number
-    offset?: number
-    rating?: string
-    lang?: string
-    random_id?: string
-    bundle?: string
-  },
-  {data: Gif[]; pagination: Pagination}
->('/v1/gifs/search')
-
-export function useGiphyTrending() {
-  return useInfiniteQuery({
-    queryKey: RQKEY_TRENDING,
-    queryFn: ({pageParam}) => getTrendingGifs({offset: pageParam}),
-    initialPageParam: 0,
-    getNextPageParam: lastPage =>
-      lastPage.pagination.offset + lastPage.pagination.count,
-  })
-}
-
-export function useGifphySearch(query: string) {
-  return useInfiniteQuery({
-    queryKey: RQKEY_SEARCH(query),
-    queryFn: ({pageParam}) => searchGifs({q: query, offset: pageParam}),
-    initialPageParam: 0,
-    getNextPageParam: lastPage =>
-      lastPage.pagination.offset + lastPage.pagination.count,
-    enabled: !!query,
-    placeholderData: keepPreviousData,
-  })
-}
-
-function createGiphyApi<Input extends object, Ouput>(
-  path: string,
-): (input: Input) => Promise<
-  Ouput & {
-    meta: Meta
-  }
-> {
-  return async input => {
-    const url = new URL(path, GIPHY_API_URL)
-    url.searchParams.set('api_key', GIPHY_API_KEY)
-
-    for (const [key, value] of Object.entries(input)) {
-      url.searchParams.set(key, String(value))
-    }
-
-    const res = await fetch(url.toString(), {
-      method: 'GET',
-      headers: {
-        'Content-Type': 'application/json',
-      },
-    })
-    if (!res.ok) {
-      throw new Error('Failed to fetch Giphy API')
-    }
-    return res.json()
-  }
-}
-
-export type Gif = {
-  type: string
-  id: string
-  slug: string
-  url: string
-  bitly_url: string
-  embed_url: string
-  username: string
-  source: string
-  rating: string
-  content_url: string
-  user: User
-  source_tld: string
-  source_post_url: string
-  update_datetime: string
-  create_datetime: string
-  import_datetime: string
-  trending_datetime: string
-  images: Images
-  title: string
-  alt_text: string
-}
-
-type Images = {
-  fixed_height: {
-    url: string
-    width: string
-    height: string
-    size: string
-    mp4: string
-    mp4_size: string
-    webp: string
-    webp_size: string
-  }
-
-  fixed_height_still: {
-    url: string
-    width: string
-    height: string
-  }
-
-  fixed_height_downsampled: {
-    url: string
-    width: string
-    height: string
-    size: string
-    webp: string
-    webp_size: string
-  }
-
-  fixed_width: {
-    url: string
-    width: string
-    height: string
-    size: string
-    mp4: string
-    mp4_size: string
-    webp: string
-    webp_size: string
-  }
-
-  fixed_width_still: {
-    url: string
-    width: string
-    height: string
-  }
-
-  fixed_width_downsampled: {
-    url: string
-    width: string
-    height: string
-    size: string
-    webp: string
-    webp_size: string
-  }
-
-  fixed_height_small: {
-    url: string
-    width: string
-    height: string
-    size: string
-    mp4: string
-    mp4_size: string
-    webp: string
-    webp_size: string
-  }
-
-  fixed_height_small_still: {
-    url: string
-    width: string
-    height: string
-  }
-
-  fixed_width_small: {
-    url: string
-    width: string
-    height: string
-    size: string
-    mp4: string
-    mp4_size: string
-    webp: string
-    webp_size: string
-  }
-
-  fixed_width_small_still: {
-    url: string
-    width: string
-    height: string
-  }
-
-  downsized: {
-    url: string
-    width: string
-    height: string
-    size: string
-  }
-
-  downsized_still: {
-    url: string
-    width: string
-    height: string
-  }
-
-  downsized_large: {
-    url: string
-    width: string
-    height: string
-    size: string
-  }
-
-  downsized_medium: {
-    url: string
-    width: string
-    height: string
-    size: string
-  }
-
-  downsized_small: {
-    mp4: string
-    width: string
-    height: string
-    mp4_size: string
-  }
-
-  original: {
-    width: string
-    height: string
-    size: string
-    frames: string
-    mp4: string
-    mp4_size: string
-    webp: string
-    webp_size: string
-  }
-
-  original_still: {
-    url: string
-    width: string
-    height: string
-  }
-
-  looping: {
-    mp4: string
-  }
-
-  preview: {
-    mp4: string
-    mp4_size: string
-    width: string
-    height: string
-  }
-
-  preview_gif: {
-    url: string
-    width: string
-    height: string
-  }
-}
-
-type User = {
-  avatar_url: string
-  banner_url: string
-  profile_url: string
-  username: string
-  display_name: string
-}
-
-type Meta = {
-  msg: string
-  status: number
-  response_id: string
-}
-
-type Pagination = {
-  offset: number
-  total_count: number
-  count: number
-}
diff --git a/src/state/queries/tenor.ts b/src/state/queries/tenor.ts
new file mode 100644
index 000000000..66cfcec6a
--- /dev/null
+++ b/src/state/queries/tenor.ts
@@ -0,0 +1,177 @@
+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'
+
+export const RQKEY_ROOT = 'gif-service'
+export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
+export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query]
+
+const getTrendingGifs = createTenorApi(GIF_FEATURED)
+
+const searchGifs = createTenorApi<{q: string}>(GIF_SEARCH)
+
+export function useFeaturedGifsQuery() {
+  return useInfiniteQuery({
+    queryKey: RQKEY_FEATURED,
+    queryFn: ({pageParam}) => getTrendingGifs({pos: pageParam}),
+    initialPageParam: undefined as string | undefined,
+    getNextPageParam: lastPage => lastPage.next,
+  })
+}
+
+export function useGifSearchQuery(query: string) {
+  return useInfiniteQuery({
+    queryKey: RQKEY_SEARCH(query),
+    queryFn: ({pageParam}) => searchGifs({q: query, pos: pageParam}),
+    initialPageParam: undefined as string | undefined,
+    getNextPageParam: lastPage => lastPage.next,
+    enabled: !!query,
+    placeholderData: keepPreviousData,
+  })
+}
+
+function createTenorApi<Input extends object>(
+  urlFn: (params: string) => string,
+): (input: Input & {pos?: string}) => Promise<{
+  next: string
+  results: Gif[]
+}> {
+  return async input => {
+    const params = new URLSearchParams()
+
+    // set client key based on platform
+    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')
+
+    params.set(
+      'media_filter',
+      (['preview', 'gif', 'tinygif'] satisfies ContentFormats[]).join(','),
+    )
+
+    const locale = getLocales?.()?.[0]
+
+    if (locale) {
+      params.set('locale', locale.languageTag.replace('-', '_'))
+
+      if (locale.regionCode) {
+        params.set('country', locale.regionCode)
+      }
+    }
+
+    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 Tenor API')
+    }
+    return res.json()
+  }
+}
+
+export type Gif = {
+  /**
+   * A Unix timestamp that represents when this post was created.
+   */
+  created: number
+  /**
+   * Returns true if this post contains audio.
+   * Note: Only video formats support audio. The GIF image file format can't contain audio information.
+   */
+  hasaudio: boolean
+  /**
+   * Tenor result identifier
+   */
+  id: string
+  /**
+   * A dictionary with a content format as the key and a Media Object as the value.
+   */
+  media_formats: Record<ContentFormats, MediaObject>
+  /**
+   * An array of tags for the post
+   */
+  tags: string[]
+  /**
+   * The title of the post
+   */
+  title: string
+  /**
+   * A textual description of the content.
+   * We recommend that you use content_description for user accessibility features.
+   */
+  content_description: string
+  /**
+   * The full URL to view the post on tenor.com.
+   */
+  itemurl: string
+  /**
+   * Returns true if this post contains captions.
+   */
+  hascaption: boolean
+  /**
+   * 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.
+   */
+  flags: string
+  /**
+   * The most common background pixel color of the content
+   */
+  bg_color?: string
+  /**
+   * A short URL to view the post on tenor.com.
+   */
+  url: string
+}
+
+type MediaObject = {
+  /**
+   * A URL to the media source
+   */
+  url: string
+  /**
+   * Width and height of the media in pixels
+   */
+  dims: [number, number]
+  /**
+   * Represents the time in seconds for one loop of the content. If the content is static, the duration is set to 0.
+   */
+  duration: number
+  /**
+   * Size of the file in bytes
+   */
+  size: number
+}
+
+type ContentFormats =
+  | 'preview'
+  | 'gif'
+  // | 'mediumgif'
+  | 'tinygif'
+// | 'nanogif'
+// | 'mp4'
+// | 'loopedmp4'
+// | 'tinymp4'
+// | 'nanomp4'
+// | 'webm'
+// | 'tinywebm'
+// | 'nanowebm'
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index c5472c913..93e2dc6b5 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -29,8 +29,8 @@ import {
   useLanguagePrefs,
   useLanguagePrefsApi,
 } from '#/state/preferences/languages'
-import {Gif} from '#/state/queries/giphy'
 import {useProfileQuery} from '#/state/queries/profile'
+import {Gif} from '#/state/queries/tenor'
 import {ThreadgateSetting} from '#/state/queries/threadgate'
 import {getAgent, useSession} from '#/state/session'
 import {useComposerControls} from '#/state/shell/composer'
@@ -316,18 +316,19 @@ export const ComposePost = observer(function ComposePost({
   }, [])
 
   const onSelectGif = useCallback(
-    (gif: Gif) =>
+    (gif: Gif) => {
       setExtLink({
-        uri: `${gif.url}?hh=${gif.images.original.height}&ww=${gif.images.original.width}`,
+        uri: `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[0]}&ww=${gif.media_formats.gif.dims[1]}`,
         isLoading: true,
         meta: {
-          url: gif.url,
-          image: gif.images.original_still.url,
+          url: gif.media_formats.gif.url,
+          image: gif.media_formats.preview.url,
           likelyType: LikelyType.HTML,
-          title: `${gif.title} - Find & Share on GIPHY`,
-          description: `ALT: ${gif.alt_text}`,
+          title: gif.content_description,
+          description: `ALT: ${gif.content_description}`,
         },
-      }),
+      })
+    },
     [setExtLink],
   )
 
diff --git a/src/view/com/composer/photos/SelectGifBtn.tsx b/src/view/com/composer/photos/SelectGifBtn.tsx
index 31310fdc1..60cef9a19 100644
--- a/src/view/com/composer/photos/SelectGifBtn.tsx
+++ b/src/view/com/composer/photos/SelectGifBtn.tsx
@@ -4,7 +4,7 @@ import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 import {logEvent} from '#/lib/statsig/statsig'
-import {Gif} from '#/state/queries/giphy'
+import {Gif} from '#/state/queries/tenor'
 import {atoms as a, useTheme} from '#/alf'
 import {Button} from '#/components/Button'
 import {useDialogControl} from '#/components/Dialog'