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