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

Configure Feed

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

at main 325 lines 9.2 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 {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}