Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import {
2 useCallback,
3 useImperativeHandle,
4 useMemo,
5 useRef,
6 useState,
7} from 'react'
8import {type TextInput, View} from 'react-native'
9import {useWindowDimensions} from 'react-native'
10import {Image} from 'expo-image'
11import {Trans, useLingui} from '@lingui/react/macro'
12
13import {cleanError} from '#/lib/strings/errors'
14import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
15import {
16 useFeaturedGifsQuery as useKlipyFeaturedGifsQuery,
17 useGifSearchQuery as useKlipyGifSearchQuery,
18} from '#/state/queries/klipy'
19import {
20 type Gif,
21 gifPreviewUrl,
22 useTenorFeaturedGifsQuery,
23 useTenorGifSearchQuery,
24} from '#/state/queries/tenor'
25import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
26import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
27import {type ListMethods} from '#/view/com/util/List'
28import {atoms as a, ios, native, useBreakpoints, useTheme, web} from '#/alf'
29import {Button, ButtonIcon, ButtonText} from '#/components/Button'
30import * as Dialog from '#/components/Dialog'
31import * as TextField from '#/components/forms/TextField'
32import {useThrottledValue} from '#/components/hooks/useThrottledValue'
33import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
34import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
35import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
36import {useAnalytics} from '#/analytics'
37import {IS_WEB} from '#/env'
38
39export function GifSelectDialog({
40 controlRef,
41 onClose,
42 onSelectGif: onSelectGifProp,
43}: {
44 controlRef: React.RefObject<{open: () => void} | null>
45 onClose?: () => void
46 onSelectGif: (gif: Gif) => void
47}) {
48 const control = Dialog.useDialogControl()
49
50 useImperativeHandle(controlRef, () => ({
51 open: () => control.open(),
52 }))
53
54 const onSelectGif = useCallback(
55 (gif: Gif) => {
56 control.close(() => onSelectGifProp(gif))
57 },
58 [control, onSelectGifProp],
59 )
60
61 const renderErrorBoundary = useCallback(
62 (error: any) => <DialogError details={String(error)} />,
63 [],
64 )
65
66 return (
67 <Dialog.Outer
68 control={control}
69 onClose={onClose}
70 nativeOptions={{
71 bottomInset: 0,
72 // use system corner radius on iOS
73 ...ios({cornerRadius: undefined}),
74 fullHeight: true,
75 }}>
76 <Dialog.Handle />
77 <ErrorBoundary renderError={renderErrorBoundary}>
78 <GifList control={control} onSelectGif={onSelectGif} />
79 </ErrorBoundary>
80 </Dialog.Outer>
81 )
82}
83
84function GifList({
85 control,
86 onSelectGif,
87}: {
88 control: Dialog.DialogControlProps
89 onSelectGif: (gif: Gif) => void
90}) {
91 const ax = useAnalytics()
92 const {t: l} = useLingui()
93 const t = useTheme()
94 const {gtMobile} = useBreakpoints()
95 const textInputRef = useRef<TextInput>(null)
96 const listRef = useRef<ListMethods>(null)
97 const [undeferredSearch, setSearch] = useState('')
98 const search = useThrottledValue(undeferredSearch, 500)
99 const {height} = useWindowDimensions()
100 const klipyEnabled = ax.features.enabled(ax.features.KlipyGifProviderEnable)
101
102 const isSearching = search.length > 0
103
104 const klipyTrending = useKlipyFeaturedGifsQuery({enabled: klipyEnabled})
105 const klipySearch = useKlipyGifSearchQuery(search, {enabled: klipyEnabled})
106 const tenorTrending = useTenorFeaturedGifsQuery({enabled: !klipyEnabled})
107 const tenorSearch = useTenorGifSearchQuery(search, {enabled: !klipyEnabled})
108
109 const enableSquareButtons = useEnableSquareButtons()
110
111 const {
112 data,
113 fetchNextPage,
114 isFetchingNextPage,
115 hasNextPage,
116 error,
117 isPending,
118 isError,
119 refetch,
120 } = klipyEnabled
121 ? isSearching
122 ? klipySearch
123 : klipyTrending
124 : isSearching
125 ? tenorSearch
126 : tenorTrending
127
128 const flattenedData = useMemo(() => {
129 return data?.pages.flatMap(page => page.results) || []
130 }, [data])
131
132 const renderItem = useCallback(
133 ({item}: {item: Gif}) => {
134 return <GifPreview gif={item} onSelectGif={onSelectGif} />
135 },
136 [onSelectGif],
137 )
138
139 const onEndReached = useCallback(() => {
140 if (isFetchingNextPage || !hasNextPage || error) return
141 fetchNextPage()
142 }, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
143
144 const hasData = flattenedData.length > 0
145
146 const onGoBack = useCallback(() => {
147 if (isSearching) {
148 // clear the input and reset the state
149 textInputRef.current?.clear()
150 setSearch('')
151 } else {
152 control.close()
153 }
154 }, [control, isSearching])
155
156 const listHeader = useMemo(() => {
157 return (
158 <View
159 style={[
160 native(a.pt_4xl),
161 a.relative,
162 a.mb_lg,
163 a.flex_row,
164 a.align_center,
165 !gtMobile && web(a.gap_md),
166 a.pb_sm,
167 t.atoms.bg,
168 ]}>
169 {!gtMobile && IS_WEB && (
170 <Button
171 size="small"
172 variant="ghost"
173 color="secondary"
174 shape={enableSquareButtons ? 'square' : 'round'}
175 onPress={() => control.close()}
176 label={l`Close GIF dialog`}>
177 <ButtonIcon icon={Arrow} size="md" />
178 </Button>
179 )}
180
181 <TextField.Root style={[!gtMobile && IS_WEB && a.flex_1]}>
182 <TextField.Icon icon={Search} />
183 <TextField.Input
184 label={l`Search GIFs`}
185 placeholder={klipyEnabled ? l`Search KLIPY` : l`Search Tenor`}
186 onChangeText={text => {
187 setSearch(text)
188 listRef.current?.scrollToOffset({offset: 0, animated: false})
189 }}
190 returnKeyType="search"
191 clearButtonMode="while-editing"
192 inputRef={textInputRef}
193 maxLength={50}
194 onKeyPress={({nativeEvent}) => {
195 if (nativeEvent.key === 'Escape') {
196 control.close()
197 }
198 }}
199 />
200 </TextField.Root>
201 </View>
202 )
203 }, [gtMobile, t.atoms.bg, l, control, klipyEnabled, enableSquareButtons])
204
205 return (
206 <>
207 {gtMobile && <Dialog.Close />}
208 <Dialog.InnerFlatList
209 ref={listRef}
210 key={gtMobile ? '3 cols' : '2 cols'}
211 data={flattenedData}
212 renderItem={renderItem}
213 numColumns={gtMobile ? 3 : 2}
214 columnWrapperStyle={[a.gap_sm]}
215 contentContainerStyle={[native([a.px_xl, {minHeight: height}])]}
216 webInnerStyle={[web({minHeight: '80vh'})]}
217 webInnerContentContainerStyle={[web(a.pb_0)]}
218 ListHeaderComponent={
219 <>
220 {listHeader}
221 {!hasData && (
222 <ListMaybePlaceholder
223 isLoading={isPending}
224 isError={isError}
225 onRetry={refetch}
226 onGoBack={onGoBack}
227 emptyType="results"
228 sideBorders={false}
229 topBorder={false}
230 errorTitle={l`Failed to load GIFs`}
231 errorMessage={
232 klipyEnabled
233 ? l`There was an issue connecting to KLIPY.`
234 : l`There was an issue connecting to Tenor.`
235 }
236 emptyMessage={
237 isSearching
238 ? l`No search results found for "${search}".`
239 : klipyEnabled
240 ? l`No featured GIFs found. There may be an issue with KLIPY.`
241 : l`No featured GIFs found. There may be an issue with Tenor.`
242 }
243 />
244 )}
245 </>
246 }
247 stickyHeaderIndices={[0]}
248 onEndReached={onEndReached}
249 onEndReachedThreshold={4}
250 keyExtractor={(item: Gif) => item.id}
251 keyboardDismissMode="on-drag"
252 ListFooterComponent={
253 hasData ? (
254 <ListFooter
255 isFetchingNextPage={isFetchingNextPage}
256 error={cleanError(error)}
257 onRetry={fetchNextPage}
258 style={{borderTopWidth: 0}}
259 />
260 ) : null
261 }
262 />
263 </>
264 )
265}
266
267function DialogError({details}: {details?: string}) {
268 const {t: l} = useLingui()
269 const control = Dialog.useDialogContext()
270
271 return (
272 <Dialog.ScrollableInner style={a.gap_md} label={l`An error has occurred`}>
273 <Dialog.Close />
274 <ErrorScreen
275 title={l`Oh no!`}
276 message={l`There was an unexpected issue in the application. Please let us know if this happened to you!`}
277 details={details}
278 />
279 <Button
280 label={l`Close dialog`}
281 onPress={() => control.close()}
282 color="primary"
283 size="large"
284 variant="solid">
285 <ButtonText>
286 <Trans>Close</Trans>
287 </ButtonText>
288 </Button>
289 </Dialog.ScrollableInner>
290 )
291}
292
293export function GifPreview({
294 gif,
295 onSelectGif,
296}: {
297 gif: Gif
298 onSelectGif: (gif: Gif) => void
299}) {
300 const ax = useAnalytics()
301 const {gtTablet} = useBreakpoints()
302 const {t: l} = useLingui()
303 const t = useTheme()
304
305 const onPress = useCallback(() => {
306 ax.metric('composer:gif:select', {})
307 onSelectGif(gif)
308 }, [ax, onSelectGif, gif])
309
310 return (
311 <Button
312 label={l`Select GIF "${gif.title}"`}
313 style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
314 onPress={onPress}>
315 {({pressed}) => (
316 <Image
317 style={[
318 a.flex_1,
319 a.mb_sm,
320 a.rounded_sm,
321 a.aspect_square,
322 {opacity: pressed ? 0.8 : 1},
323 t.atoms.bg_contrast_25,
324 ]}
325 source={{
326 uri: gifPreviewUrl(gif.media_formats.tinygif.url),
327 }}
328 contentFit="cover"
329 accessibilityLabel={gif.title}
330 accessibilityHint=""
331 cachePolicy="none"
332 accessibilityIgnoresInvertColors
333 />
334 )}
335 </Button>
336 )
337}