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 useFeaturedGifsQuery() {
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 })
23}
24
25export function useGifSearchQuery(query: string) {
26 return useInfiniteQuery({
27 queryKey: RQKEY_SEARCH(query),
28 queryFn: ({pageParam}) => searchGifs({q: query, pos: pageParam}),
29 initialPageParam: undefined as string | undefined,
30 getNextPageParam: lastPage => lastPage.next,
31 enabled: !!query,
32 placeholderData: keepPreviousData,
33 })
34}
35
36function createTenorApi<Input extends object>(
37 urlFn: (params: string) => string,
38): (input: Input & {pos?: string}) => Promise<{
39 next: string
40 results: Gif[]
41}> {
42 return async input => {
43 const params = new URLSearchParams()
44
45 // set client key based on platform
46 params.set(
47 'client_key',
48 Platform.select({
49 ios: 'bluesky-ios',
50 android: 'bluesky-android',
51 default: 'bluesky-web',
52 }),
53 )
54
55 // 30 is divisible by 2 and 3, so both 2 and 3 column layouts can be used
56 params.set('limit', '30')
57
58 params.set('contentfilter', 'high')
59
60 params.set(
61 'media_filter',
62 (['preview', 'gif', 'tinygif'] satisfies ContentFormats[]).join(','),
63 )
64
65 const locale = getLocales?.()?.[0]
66
67 if (locale) {
68 params.set('locale', locale.languageTag.replace('-', '_'))
69 }
70
71 for (const [key, value] of Object.entries(input)) {
72 if (value !== undefined) {
73 params.set(key, String(value))
74 }
75 }
76
77 const res = await fetch(urlFn(params.toString()), {
78 method: 'GET',
79 headers: {
80 'Content-Type': 'application/json',
81 },
82 })
83 if (!res.ok) {
84 throw new Error('Failed to fetch Tenor API')
85 }
86 return res.json()
87 }
88}
89
90export function tenorUrlToBskyGifUrl(tenorUrl: string) {
91 let url
92 try {
93 url = new URL(tenorUrl)
94 } catch (e) {
95 logger.debug('invalid url passed to tenorUrlToBskyGifUrl()')
96 return ''
97 }
98 url.hostname = 't.gifs.bsky.app'
99 return url.href
100}
101
102export type Gif = {
103 /**
104 * A Unix timestamp that represents when this post was created.
105 */
106 created: number
107 /**
108 * Returns true if this post contains audio.
109 * Note: Only video formats support audio. The GIF image file format can't contain audio information.
110 */
111 hasaudio: boolean
112 /**
113 * Tenor result identifier
114 */
115 id: string
116 /**
117 * A dictionary with a content format as the key and a Media Object as the value.
118 */
119 media_formats: Record<ContentFormats, MediaObject>
120 /**
121 * An array of tags for the post
122 */
123 tags: string[]
124 /**
125 * The title of the post
126 */
127 title: string
128 /**
129 * A textual description of the content.
130 * We recommend that you use content_description for user accessibility features.
131 */
132 content_description: string
133 /**
134 * The full URL to view the post on tenor.com.
135 */
136 itemurl: string
137 /**
138 * Returns true if this post contains captions.
139 */
140 hascaption: boolean
141 /**
142 * 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.
143 */
144 flags: string
145 /**
146 * The most common background pixel color of the content
147 */
148 bg_color?: string
149 /**
150 * A short URL to view the post on tenor.com.
151 */
152 url: string
153}
154
155type MediaObject = {
156 /**
157 * A URL to the media source
158 */
159 url: string
160 /**
161 * Width and height of the media in pixels
162 */
163 dims: [number, number]
164 /**
165 * Represents the time in seconds for one loop of the content. If the content is static, the duration is set to 0.
166 */
167 duration: number
168 /**
169 * Size of the file in bytes
170 */
171 size: number
172}
173
174type ContentFormats =
175 | 'preview'
176 | 'gif'
177 // | 'mediumgif'
178 | 'tinygif'
179// | 'nanogif'
180// | 'mp4'
181// | 'loopedmp4'
182// | 'tinymp4'
183// | 'nanomp4'
184// | 'webm'
185// | 'tinywebm'
186// | 'nanowebm'