this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at e28f6d2f370b4e882ed6f23d08ca0f8d94dbac5f 322 lines 9.0 kB view raw
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 { 17 type Gif, 18 tenorUrlToBskyGifUrl, 19 useFeaturedGifsQuery, 20 useGifSearchQuery, 21} from '#/state/queries/tenor' 22import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' 23import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 24import {type ListMethods} from '#/view/com/util/List' 25import {atoms as a, ios, native, useBreakpoints, useTheme, web} from '#/alf' 26import {Button, ButtonIcon, ButtonText} from '#/components/Button' 27import * as Dialog from '#/components/Dialog' 28import * as TextField from '#/components/forms/TextField' 29import {useThrottledValue} from '#/components/hooks/useThrottledValue' 30import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' 31import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' 32import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 33import {useAnalytics} from '#/analytics' 34import {IS_WEB} from '#/env' 35 36export function GifSelectDialog({ 37 controlRef, 38 onClose, 39 onSelectGif: onSelectGifProp, 40}: { 41 controlRef: React.RefObject<{open: () => void} | null> 42 onClose?: () => void 43 onSelectGif: (gif: Gif) => void 44}) { 45 const control = Dialog.useDialogControl() 46 47 useImperativeHandle(controlRef, () => ({ 48 open: () => control.open(), 49 })) 50 51 const onSelectGif = useCallback( 52 (gif: Gif) => { 53 control.close(() => onSelectGifProp(gif)) 54 }, 55 [control, onSelectGifProp], 56 ) 57 58 const renderErrorBoundary = useCallback( 59 (error: any) => <DialogError details={String(error)} />, 60 [], 61 ) 62 63 return ( 64 <Dialog.Outer 65 control={control} 66 onClose={onClose} 67 nativeOptions={{ 68 bottomInset: 0, 69 // use system corner radius on iOS 70 ...ios({cornerRadius: undefined}), 71 fullHeight: true, 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 { 103 data, 104 fetchNextPage, 105 isFetchingNextPage, 106 hasNextPage, 107 error, 108 isPending, 109 isError, 110 refetch, 111 } = isSearching ? searchQuery : trendingQuery 112 113 const flattenedData = useMemo(() => { 114 return data?.pages.flatMap(page => page.results) || [] 115 }, [data]) 116 117 const renderItem = useCallback( 118 ({item}: {item: Gif}) => { 119 return <GifPreview gif={item} onSelectGif={onSelectGif} /> 120 }, 121 [onSelectGif], 122 ) 123 124 const onEndReached = useCallback(() => { 125 if (isFetchingNextPage || !hasNextPage || error) return 126 fetchNextPage() 127 }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) 128 129 const hasData = flattenedData.length > 0 130 131 const onGoBack = useCallback(() => { 132 if (isSearching) { 133 // clear the input and reset the state 134 textInputRef.current?.clear() 135 setSearch('') 136 } else { 137 control.close() 138 } 139 }, [control, isSearching]) 140 141 const listHeader = useMemo(() => { 142 return ( 143 <View 144 style={[ 145 native(a.pt_4xl), 146 a.relative, 147 a.mb_lg, 148 a.flex_row, 149 a.align_center, 150 !gtMobile && web(a.gap_md), 151 a.pb_sm, 152 t.atoms.bg, 153 ]}> 154 {!gtMobile && IS_WEB && ( 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 style={[!gtMobile && IS_WEB && a.flex_1]}> 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 contentContainerStyle={[native([a.px_xl, {minHeight: height}])]} 201 webInnerStyle={[web({minHeight: '80vh'})]} 202 webInnerContentContainerStyle={[web(a.pb_0)]} 203 ListHeaderComponent={ 204 <> 205 {listHeader} 206 {!hasData && ( 207 <ListMaybePlaceholder 208 isLoading={isPending} 209 isError={isError} 210 onRetry={refetch} 211 onGoBack={onGoBack} 212 emptyType="results" 213 sideBorders={false} 214 topBorder={false} 215 errorTitle={_(msg`Failed to load GIFs`)} 216 errorMessage={_(msg`There was an issue connecting to Tenor.`)} 217 emptyMessage={ 218 isSearching 219 ? _(msg`No search results found for "${search}".`) 220 : _( 221 msg`No featured GIFs found. There may be an issue with Tenor.`, 222 ) 223 } 224 /> 225 )} 226 </> 227 } 228 stickyHeaderIndices={[0]} 229 onEndReached={onEndReached} 230 onEndReachedThreshold={4} 231 keyExtractor={(item: Gif) => item.id} 232 keyboardDismissMode="on-drag" 233 ListFooterComponent={ 234 hasData ? ( 235 <ListFooter 236 isFetchingNextPage={isFetchingNextPage} 237 error={cleanError(error)} 238 onRetry={fetchNextPage} 239 style={{borderTopWidth: 0}} 240 /> 241 ) : null 242 } 243 /> 244 </> 245 ) 246} 247 248function DialogError({details}: {details?: string}) { 249 const {_} = useLingui() 250 const control = Dialog.useDialogContext() 251 252 return ( 253 <Dialog.ScrollableInner 254 style={a.gap_md} 255 label={_(msg`An error has occurred`)}> 256 <Dialog.Close /> 257 <ErrorScreen 258 title={_(msg`Oh no!`)} 259 message={_( 260 msg`There was an unexpected issue in the application. Please let us know if this happened to you!`, 261 )} 262 details={details} 263 /> 264 <Button 265 label={_(msg`Close dialog`)} 266 onPress={() => control.close()} 267 color="primary" 268 size="large" 269 variant="solid"> 270 <ButtonText> 271 <Trans>Close</Trans> 272 </ButtonText> 273 </Button> 274 </Dialog.ScrollableInner> 275 ) 276} 277 278export function GifPreview({ 279 gif, 280 onSelectGif, 281}: { 282 gif: Gif 283 onSelectGif: (gif: Gif) => void 284}) { 285 const ax = useAnalytics() 286 const {gtTablet} = useBreakpoints() 287 const {_} = useLingui() 288 const t = useTheme() 289 290 const onPress = useCallback(() => { 291 ax.metric('composer:gif:select', {}) 292 onSelectGif(gif) 293 }, [ax, onSelectGif, gif]) 294 295 return ( 296 <Button 297 label={_(msg`Select GIF "${gif.title}"`)} 298 style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]} 299 onPress={onPress}> 300 {({pressed}) => ( 301 <Image 302 style={[ 303 a.flex_1, 304 a.mb_sm, 305 a.rounded_sm, 306 a.aspect_square, 307 {opacity: pressed ? 0.8 : 1}, 308 t.atoms.bg_contrast_25, 309 ]} 310 source={{ 311 uri: tenorUrlToBskyGifUrl(gif.media_formats.tinygif.url), 312 }} 313 contentFit="cover" 314 accessibilityLabel={gif.title} 315 accessibilityHint="" 316 cachePolicy="none" 317 accessibilityIgnoresInvertColors 318 /> 319 )} 320 </Button> 321 ) 322}