mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {
2 useCallback,
3 useImperativeHandle,
4 useMemo,
5 useRef,
6 useState,
7} from 'react'
8import {TextInput, View} from 'react-native'
9import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
10import {msg, Trans} from '@lingui/macro'
11import {useLingui} from '@lingui/react'
12
13import {cleanError} from '#/lib/strings/errors'
14import {isWeb} from '#/platform/detection'
15import {
16 Gif,
17 useFeaturedGifsQuery,
18 useGifSearchQuery,
19} from '#/state/queries/tenor'
20import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
21import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
22import {atoms as a, useBreakpoints, useTheme} from '#/alf'
23import * as Dialog from '#/components/Dialog'
24import * as TextField from '#/components/forms/TextField'
25import {useThrottledValue} from '#/components/hooks/useThrottledValue'
26import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
27import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
28import {Button, ButtonIcon, ButtonText} from '../Button'
29import {ListFooter, ListMaybePlaceholder} from '../Lists'
30import {GifPreview} from './GifSelect.shared'
31
32export function GifSelectDialog({
33 controlRef,
34 onClose,
35 onSelectGif: onSelectGifProp,
36}: {
37 controlRef: React.RefObject<{open: () => void}>
38 onClose: () => void
39 onSelectGif: (gif: Gif) => void
40}) {
41 const control = Dialog.useDialogControl()
42
43 useImperativeHandle(controlRef, () => ({
44 open: () => control.open(),
45 }))
46
47 const onSelectGif = useCallback(
48 (gif: Gif) => {
49 control.close(() => onSelectGifProp(gif))
50 },
51 [control, onSelectGifProp],
52 )
53
54 const renderErrorBoundary = useCallback(
55 (error: any) => <DialogError details={String(error)} />,
56 [],
57 )
58
59 return (
60 <Dialog.Outer
61 control={control}
62 nativeOptions={{sheet: {snapPoints: ['100%']}}}
63 onClose={onClose}>
64 <Dialog.Handle />
65 <ErrorBoundary renderError={renderErrorBoundary}>
66 <GifList control={control} onSelectGif={onSelectGif} />
67 </ErrorBoundary>
68 </Dialog.Outer>
69 )
70}
71
72function GifList({
73 control,
74 onSelectGif,
75}: {
76 control: Dialog.DialogControlProps
77 onSelectGif: (gif: Gif) => void
78}) {
79 const {_} = useLingui()
80 const t = useTheme()
81 const {gtMobile} = useBreakpoints()
82 const textInputRef = useRef<TextInput>(null)
83 const listRef = useRef<BottomSheetFlatListMethods>(null)
84 const [undeferredSearch, setSearch] = useState('')
85 const search = useThrottledValue(undeferredSearch, 500)
86
87 const isSearching = search.length > 0
88
89 const trendingQuery = useFeaturedGifsQuery()
90 const searchQuery = useGifSearchQuery(search)
91
92 const {
93 data,
94 fetchNextPage,
95 isFetchingNextPage,
96 hasNextPage,
97 error,
98 isLoading,
99 isError,
100 refetch,
101 } = isSearching ? searchQuery : trendingQuery
102
103 const flattenedData = useMemo(() => {
104 return data?.pages.flatMap(page => page.results) || []
105 }, [data])
106
107 const renderItem = useCallback(
108 ({item}: {item: Gif}) => {
109 return <GifPreview gif={item} onSelectGif={onSelectGif} />
110 },
111 [onSelectGif],
112 )
113
114 const onEndReached = React.useCallback(() => {
115 if (isFetchingNextPage || !hasNextPage || error) return
116 fetchNextPage()
117 }, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
118
119 const hasData = flattenedData.length > 0
120
121 const onGoBack = useCallback(() => {
122 if (isSearching) {
123 // clear the input and reset the state
124 textInputRef.current?.clear()
125 setSearch('')
126 } else {
127 control.close()
128 }
129 }, [control, isSearching])
130
131 const listHeader = useMemo(() => {
132 return (
133 <View
134 style={[
135 a.relative,
136 a.mb_lg,
137 a.flex_row,
138 a.align_center,
139 !gtMobile && isWeb && a.gap_md,
140 ]}>
141 {/* cover top corners */}
142 <View
143 style={[
144 a.absolute,
145 a.inset_0,
146 {
147 borderBottomLeftRadius: 8,
148 borderBottomRightRadius: 8,
149 },
150 t.atoms.bg,
151 ]}
152 />
153
154 {!gtMobile && isWeb && (
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>
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 ListHeaderComponent={
201 <>
202 {listHeader}
203 {!hasData && (
204 <ListMaybePlaceholder
205 isLoading={isLoading}
206 isError={isError}
207 onRetry={refetch}
208 onGoBack={onGoBack}
209 emptyType="results"
210 sideBorders={false}
211 topBorder={false}
212 errorTitle={_(msg`Failed to load GIFs`)}
213 errorMessage={_(msg`There was an issue connecting to Tenor.`)}
214 emptyMessage={
215 isSearching
216 ? _(msg`No search results found for "${search}".`)
217 : _(
218 msg`No featured GIFs found. There may be an issue with Tenor.`,
219 )
220 }
221 />
222 )}
223 </>
224 }
225 stickyHeaderIndices={[0]}
226 onEndReached={onEndReached}
227 onEndReachedThreshold={4}
228 keyExtractor={(item: Gif) => item.id}
229 // @ts-expect-error web only
230 style={isWeb && {minHeight: '100vh'}}
231 keyboardDismissMode="on-drag"
232 ListFooterComponent={
233 hasData ? (
234 <ListFooter
235 isFetchingNextPage={isFetchingNextPage}
236 error={cleanError(error)}
237 onRetry={fetchNextPage}
238 style={{borderTopWidth: 0}}
239 />
240 ) : null
241 }
242 />
243 </>
244 )
245}
246
247function DialogError({details}: {details?: string}) {
248 const {_} = useLingui()
249 const control = Dialog.useDialogContext()
250
251 return (
252 <Dialog.ScrollableInner style={a.gap_md} label={_(msg`An error occured`)}>
253 <Dialog.Close />
254 <ErrorScreen
255 title={_(msg`Oh no!`)}
256 message={_(
257 msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
258 )}
259 details={details}
260 />
261 <Button
262 label={_(msg`Close dialog`)}
263 onPress={() => control.close()}
264 color="primary"
265 size="medium"
266 variant="solid">
267 <ButtonText>
268 <Trans>Close</Trans>
269 </ButtonText>
270 </Button>
271 </Dialog.ScrollableInner>
272 )
273}