Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

at 6d68a5bd212dd4eeee816828ffe4e27601cdd7f3 320 lines 8.9 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, Trans} from '@lingui/macro' 12import {useLingui} from '@lingui/react' 13 14import {cleanError} from '#/lib/strings/errors' 15import { 16 type Gif, 17 tenorUrlToBskyGifUrl, 18 useFeaturedGifsQuery, 19 useGifSearchQuery, 20} from '#/state/queries/tenor' 21import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' 22import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 23import {type ListMethods} from '#/view/com/util/List' 24import {atoms as a, ios, native, useBreakpoints, useTheme, web} from '#/alf' 25import {Button, ButtonIcon, ButtonText} from '#/components/Button' 26import * as Dialog from '#/components/Dialog' 27import * as TextField from '#/components/forms/TextField' 28import {useThrottledValue} from '#/components/hooks/useThrottledValue' 29import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' 30import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' 31import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 32import {useAnalytics} from '#/analytics' 33import {IS_WEB} from '#/env' 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 && IS_WEB && ( 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 && IS_WEB && 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 ax = useAnalytics() 284 const {gtTablet} = useBreakpoints() 285 const {_} = useLingui() 286 const t = useTheme() 287 288 const onPress = useCallback(() => { 289 ax.metric('composer:gif:select', {}) 290 onSelectGif(gif) 291 }, [ax, onSelectGif, gif]) 292 293 return ( 294 <Button 295 label={_(msg`Select GIF "${gif.title}"`)} 296 style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]} 297 onPress={onPress}> 298 {({pressed}) => ( 299 <Image 300 style={[ 301 a.flex_1, 302 a.mb_sm, 303 a.rounded_sm, 304 a.aspect_square, 305 {opacity: pressed ? 0.8 : 1}, 306 t.atoms.bg_contrast_25, 307 ]} 308 source={{ 309 uri: tenorUrlToBskyGifUrl(gif.media_formats.tinygif.url), 310 }} 311 contentFit="cover" 312 accessibilityLabel={gif.title} 313 accessibilityHint="" 314 cachePolicy="none" 315 accessibilityIgnoresInvertColors 316 /> 317 )} 318 </Button> 319 ) 320}