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