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