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