Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at main 324 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 }}> 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}