···11+import {Platform} from 'react-native'
22+import {getLocales} from 'expo-localization'
33+import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query'
44+55+import {GIF_FEATURED, GIF_SEARCH} from '#/lib/constants'
66+77+export const RQKEY_ROOT = 'gif-service'
88+export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
99+export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query]
1010+1111+const getTrendingGifs = createTenorApi(GIF_FEATURED)
1212+1313+const searchGifs = createTenorApi<{q: string}>(GIF_SEARCH)
1414+1515+export function useFeaturedGifsQuery() {
1616+ return useInfiniteQuery({
1717+ queryKey: RQKEY_FEATURED,
1818+ queryFn: ({pageParam}) => getTrendingGifs({pos: pageParam}),
1919+ initialPageParam: undefined as string | undefined,
2020+ getNextPageParam: lastPage => lastPage.next,
2121+ })
2222+}
2323+2424+export function useGifSearchQuery(query: string) {
2525+ return useInfiniteQuery({
2626+ queryKey: RQKEY_SEARCH(query),
2727+ queryFn: ({pageParam}) => searchGifs({q: query, pos: pageParam}),
2828+ initialPageParam: undefined as string | undefined,
2929+ getNextPageParam: lastPage => lastPage.next,
3030+ enabled: !!query,
3131+ placeholderData: keepPreviousData,
3232+ })
3333+}
3434+3535+function createTenorApi<Input extends object>(
3636+ urlFn: (params: string) => string,
3737+): (input: Input & {pos?: string}) => Promise<{
3838+ next: string
3939+ results: Gif[]
4040+}> {
4141+ return async input => {
4242+ const params = new URLSearchParams()
4343+4444+ // set client key based on platform
4545+ params.set(
4646+ 'client_key',
4747+ Platform.select({
4848+ ios: 'bluesky-ios',
4949+ android: 'bluesky-android',
5050+ default: 'bluesky-web',
5151+ }),
5252+ )
5353+5454+ // 30 is divisible by 2 and 3, so both 2 and 3 column layouts can be used
5555+ params.set('limit', '30')
5656+5757+ params.set('contentfilter', 'high')
5858+5959+ params.set(
6060+ 'media_filter',
6161+ (['preview', 'gif', 'tinygif'] satisfies ContentFormats[]).join(','),
6262+ )
6363+6464+ const locale = getLocales?.()?.[0]
6565+6666+ if (locale) {
6767+ params.set('locale', locale.languageTag.replace('-', '_'))
6868+6969+ if (locale.regionCode) {
7070+ params.set('country', locale.regionCode)
7171+ }
7272+ }
7373+7474+ for (const [key, value] of Object.entries(input)) {
7575+ if (value !== undefined) {
7676+ params.set(key, String(value))
7777+ }
7878+ }
7979+8080+ const res = await fetch(urlFn(params.toString()), {
8181+ method: 'GET',
8282+ headers: {
8383+ 'Content-Type': 'application/json',
8484+ },
8585+ })
8686+ if (!res.ok) {
8787+ throw new Error('Failed to fetch Tenor API')
8888+ }
8989+ return res.json()
9090+ }
9191+}
9292+9393+export type Gif = {
9494+ /**
9595+ * A Unix timestamp that represents when this post was created.
9696+ */
9797+ created: number
9898+ /**
9999+ * Returns true if this post contains audio.
100100+ * Note: Only video formats support audio. The GIF image file format can't contain audio information.
101101+ */
102102+ hasaudio: boolean
103103+ /**
104104+ * Tenor result identifier
105105+ */
106106+ id: string
107107+ /**
108108+ * A dictionary with a content format as the key and a Media Object as the value.
109109+ */
110110+ media_formats: Record<ContentFormats, MediaObject>
111111+ /**
112112+ * An array of tags for the post
113113+ */
114114+ tags: string[]
115115+ /**
116116+ * The title of the post
117117+ */
118118+ title: string
119119+ /**
120120+ * A textual description of the content.
121121+ * We recommend that you use content_description for user accessibility features.
122122+ */
123123+ content_description: string
124124+ /**
125125+ * The full URL to view the post on tenor.com.
126126+ */
127127+ itemurl: string
128128+ /**
129129+ * Returns true if this post contains captions.
130130+ */
131131+ hascaption: boolean
132132+ /**
133133+ * 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.
134134+ */
135135+ flags: string
136136+ /**
137137+ * The most common background pixel color of the content
138138+ */
139139+ bg_color?: string
140140+ /**
141141+ * A short URL to view the post on tenor.com.
142142+ */
143143+ url: string
144144+}
145145+146146+type MediaObject = {
147147+ /**
148148+ * A URL to the media source
149149+ */
150150+ url: string
151151+ /**
152152+ * Width and height of the media in pixels
153153+ */
154154+ dims: [number, number]
155155+ /**
156156+ * Represents the time in seconds for one loop of the content. If the content is static, the duration is set to 0.
157157+ */
158158+ duration: number
159159+ /**
160160+ * Size of the file in bytes
161161+ */
162162+ size: number
163163+}
164164+165165+type ContentFormats =
166166+ | 'preview'
167167+ | 'gif'
168168+ // | 'mediumgif'
169169+ | 'tinygif'
170170+// | 'nanogif'
171171+// | 'mp4'
172172+// | 'loopedmp4'
173173+// | 'tinymp4'
174174+// | 'nanomp4'
175175+// | 'webm'
176176+// | 'tinywebm'
177177+// | 'nanowebm'
···44import {useLingui} from '@lingui/react'
5566import {logEvent} from '#/lib/statsig/statsig'
77-import {Gif} from '#/state/queries/giphy'
77+import {Gif} from '#/state/queries/tenor'
88import {atoms as a, useTheme} from '#/alf'
99import {Button} from '#/components/Button'
1010import {useDialogControl} from '#/components/Dialog'