forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {Platform} from 'react-native'
2import {getLocales} from 'expo-localization'
3import {keepPreviousData, useInfiniteQuery} from '@tanstack/react-query'
4
5import {GIF_FEATURED, GIF_SEARCH} from '#/lib/constants'
6import {logger} from '#/logger'
7
8export const RQKEY_ROOT = 'gif-service'
9export const RQKEY_FEATURED = [RQKEY_ROOT, 'featured']
10export const RQKEY_SEARCH = (query: string) => [RQKEY_ROOT, 'search', query]
11
12const getTrendingGifs = createTenorApi(GIF_FEATURED)
13
14const searchGifs = createTenorApi<{q: string}>(GIF_SEARCH)
15
16export function useTenorFeaturedGifsQuery(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
26export function useTenorGifSearchQuery(
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
40function createTenorApi<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 // set client key based on platform
50 params.set(
51 'client_key',
52 Platform.select({
53 ios: 'bluesky-ios',
54 android: 'bluesky-android',
55 default: 'bluesky-web',
56 }),
57 )
58
59 // 30 is divisible by 2 and 3, so both 2 and 3 column layouts can be used
60 params.set('limit', '30')
61
62 params.set('contentfilter', 'high')
63
64 params.set(
65 'media_filter',
66 (['preview', 'gif', 'tinygif'] satisfies ContentFormats[]).join(','),
67 )
68
69 const locale = getLocales?.()?.[0]
70
71 if (locale) {
72 params.set('locale', locale.languageTag.replace('-', '_'))
73 }
74
75 for (const [key, value] of Object.entries(input)) {
76 if (value !== undefined) {
77 params.set(key, String(value))
78 }
79 }
80
81 const res = await fetch(urlFn(params.toString()), {
82 method: 'GET',
83 headers: {
84 'Content-Type': 'application/json',
85 },
86 })
87 if (!res.ok) {
88 throw new Error('Failed to fetch Tenor API')
89 }
90 return res.json()
91 }
92}
93
94export function tenorUrlToBskyGifUrl(tenorUrl: string) {
95 let url
96 try {
97 url = new URL(tenorUrl)
98 } catch (e) {
99 logger.debug('invalid url passed to tenorUrlToBskyGifUrl()')
100 return ''
101 }
102 url.hostname = 't.gifs.bsky.app'
103 return url.href
104}
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 */
111export 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
129export type Gif = {
130 /**
131 * A Unix timestamp that represents when this post was created.
132 */
133 created: number
134 /**
135 * Returns true if this post contains audio.
136 * Note: Only video formats support audio. The GIF image file format can't contain audio information.
137 */
138 hasaudio: boolean
139 /**
140 * Tenor result identifier
141 */
142 id: string
143 /**
144 * A dictionary with a content format as the key and a Media Object as the value.
145 */
146 media_formats: Record<BaseContentFormats, MediaObject> &
147 Partial<Record<VideoContentFormats, MediaObject>>
148 /**
149 * An array of tags for the post
150 */
151 tags: string[]
152 /**
153 * The title of the post
154 */
155 title: string
156 /**
157 * A textual description of the content.
158 * We recommend that you use content_description for user accessibility features.
159 */
160 content_description: string
161 /**
162 * The full URL to view the post on tenor.com.
163 */
164 itemurl: string
165 /**
166 * Returns true if this post contains captions.
167 */
168 hascaption: boolean
169 /**
170 * 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.
171 */
172 flags: string
173 /**
174 * The most common background pixel color of the content
175 */
176 bg_color?: string
177 /**
178 * A short URL to view the post on tenor.com.
179 */
180 url: string
181}
182
183type MediaObject = {
184 /**
185 * A URL to the media source
186 */
187 url: string
188 /**
189 * Width and height of the media in pixels
190 */
191 dims: [number, number]
192 /**
193 * Represents the time in seconds for one loop of the content. If the content is static, the duration is set to 0.
194 */
195 duration: number
196 /**
197 * Size of the file in bytes
198 */
199 size: number
200}
201
202type BaseContentFormats =
203 | 'preview'
204 | 'gif'
205 // | 'mediumgif'
206 | 'tinygif'
207// | 'nanogif'
208
209type VideoContentFormats =
210 | 'mp4'
211 // | 'loopedmp4'
212 // | 'tinymp4'
213 // | 'nanomp4'
214 | 'webm'
215// | 'tinywebm'
216// | 'nanowebm'
217
218type ContentFormats = BaseContentFormats | VideoContentFormats