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