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