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