Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

at theme-changes 218 lines 5.6 kB view raw
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