Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
120
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 337 lines 9.8 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 {Trans, useLingui} from '@lingui/react/macro' 12 13import {cleanError} from '#/lib/strings/errors' 14import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 15import { 16 useFeaturedGifsQuery as useKlipyFeaturedGifsQuery, 17 useGifSearchQuery as useKlipyGifSearchQuery, 18} from '#/state/queries/klipy' 19import { 20 type Gif, 21 gifPreviewUrl, 22 useTenorFeaturedGifsQuery, 23 useTenorGifSearchQuery, 24} from '#/state/queries/tenor' 25import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' 26import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 27import {type ListMethods} from '#/view/com/util/List' 28import {atoms as a, ios, native, useBreakpoints, useTheme, web} from '#/alf' 29import {Button, ButtonIcon, ButtonText} from '#/components/Button' 30import * as Dialog from '#/components/Dialog' 31import * as TextField from '#/components/forms/TextField' 32import {useThrottledValue} from '#/components/hooks/useThrottledValue' 33import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow' 34import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' 35import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 36import {useAnalytics} from '#/analytics' 37import {IS_WEB} from '#/env' 38 39export function GifSelectDialog({ 40 controlRef, 41 onClose, 42 onSelectGif: onSelectGifProp, 43}: { 44 controlRef: React.RefObject<{open: () => void} | null> 45 onClose?: () => void 46 onSelectGif: (gif: Gif) => void 47}) { 48 const control = Dialog.useDialogControl() 49 50 useImperativeHandle(controlRef, () => ({ 51 open: () => control.open(), 52 })) 53 54 const onSelectGif = useCallback( 55 (gif: Gif) => { 56 control.close(() => onSelectGifProp(gif)) 57 }, 58 [control, onSelectGifProp], 59 ) 60 61 const renderErrorBoundary = useCallback( 62 (error: any) => <DialogError details={String(error)} />, 63 [], 64 ) 65 66 return ( 67 <Dialog.Outer 68 control={control} 69 onClose={onClose} 70 nativeOptions={{ 71 bottomInset: 0, 72 // use system corner radius on iOS 73 ...ios({cornerRadius: undefined}), 74 fullHeight: true, 75 }}> 76 <Dialog.Handle /> 77 <ErrorBoundary renderError={renderErrorBoundary}> 78 <GifList control={control} onSelectGif={onSelectGif} /> 79 </ErrorBoundary> 80 </Dialog.Outer> 81 ) 82} 83 84function GifList({ 85 control, 86 onSelectGif, 87}: { 88 control: Dialog.DialogControlProps 89 onSelectGif: (gif: Gif) => void 90}) { 91 const ax = useAnalytics() 92 const {t: l} = useLingui() 93 const t = useTheme() 94 const {gtMobile} = useBreakpoints() 95 const textInputRef = useRef<TextInput>(null) 96 const listRef = useRef<ListMethods>(null) 97 const [undeferredSearch, setSearch] = useState('') 98 const search = useThrottledValue(undeferredSearch, 500) 99 const {height} = useWindowDimensions() 100 const klipyEnabled = ax.features.enabled(ax.features.KlipyGifProviderEnable) 101 102 const isSearching = search.length > 0 103 104 const klipyTrending = useKlipyFeaturedGifsQuery({enabled: klipyEnabled}) 105 const klipySearch = useKlipyGifSearchQuery(search, {enabled: klipyEnabled}) 106 const tenorTrending = useTenorFeaturedGifsQuery({enabled: !klipyEnabled}) 107 const tenorSearch = useTenorGifSearchQuery(search, {enabled: !klipyEnabled}) 108 109 const enableSquareButtons = useEnableSquareButtons() 110 111 const { 112 data, 113 fetchNextPage, 114 isFetchingNextPage, 115 hasNextPage, 116 error, 117 isPending, 118 isError, 119 refetch, 120 } = klipyEnabled 121 ? isSearching 122 ? klipySearch 123 : klipyTrending 124 : isSearching 125 ? tenorSearch 126 : tenorTrending 127 128 const flattenedData = useMemo(() => { 129 return data?.pages.flatMap(page => page.results) || [] 130 }, [data]) 131 132 const renderItem = useCallback( 133 ({item}: {item: Gif}) => { 134 return <GifPreview gif={item} onSelectGif={onSelectGif} /> 135 }, 136 [onSelectGif], 137 ) 138 139 const onEndReached = useCallback(() => { 140 if (isFetchingNextPage || !hasNextPage || error) return 141 fetchNextPage() 142 }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) 143 144 const hasData = flattenedData.length > 0 145 146 const onGoBack = useCallback(() => { 147 if (isSearching) { 148 // clear the input and reset the state 149 textInputRef.current?.clear() 150 setSearch('') 151 } else { 152 control.close() 153 } 154 }, [control, isSearching]) 155 156 const listHeader = useMemo(() => { 157 return ( 158 <View 159 style={[ 160 native(a.pt_4xl), 161 a.relative, 162 a.mb_lg, 163 a.flex_row, 164 a.align_center, 165 !gtMobile && web(a.gap_md), 166 a.pb_sm, 167 t.atoms.bg, 168 ]}> 169 {!gtMobile && IS_WEB && ( 170 <Button 171 size="small" 172 variant="ghost" 173 color="secondary" 174 shape={enableSquareButtons ? 'square' : 'round'} 175 onPress={() => control.close()} 176 label={l`Close GIF dialog`}> 177 <ButtonIcon icon={Arrow} size="md" /> 178 </Button> 179 )} 180 181 <TextField.Root style={[!gtMobile && IS_WEB && a.flex_1]}> 182 <TextField.Icon icon={Search} /> 183 <TextField.Input 184 label={l`Search GIFs`} 185 placeholder={klipyEnabled ? l`Search KLIPY` : l`Search Tenor`} 186 onChangeText={text => { 187 setSearch(text) 188 listRef.current?.scrollToOffset({offset: 0, animated: false}) 189 }} 190 returnKeyType="search" 191 clearButtonMode="while-editing" 192 inputRef={textInputRef} 193 maxLength={50} 194 onKeyPress={({nativeEvent}) => { 195 if (nativeEvent.key === 'Escape') { 196 control.close() 197 } 198 }} 199 /> 200 </TextField.Root> 201 </View> 202 ) 203 }, [gtMobile, t.atoms.bg, l, control, klipyEnabled, enableSquareButtons]) 204 205 return ( 206 <> 207 {gtMobile && <Dialog.Close />} 208 <Dialog.InnerFlatList 209 ref={listRef} 210 key={gtMobile ? '3 cols' : '2 cols'} 211 data={flattenedData} 212 renderItem={renderItem} 213 numColumns={gtMobile ? 3 : 2} 214 columnWrapperStyle={[a.gap_sm]} 215 contentContainerStyle={[native([a.px_xl, {minHeight: height}])]} 216 webInnerStyle={[web({minHeight: '80vh'})]} 217 webInnerContentContainerStyle={[web(a.pb_0)]} 218 ListHeaderComponent={ 219 <> 220 {listHeader} 221 {!hasData && ( 222 <ListMaybePlaceholder 223 isLoading={isPending} 224 isError={isError} 225 onRetry={refetch} 226 onGoBack={onGoBack} 227 emptyType="results" 228 sideBorders={false} 229 topBorder={false} 230 errorTitle={l`Failed to load GIFs`} 231 errorMessage={ 232 klipyEnabled 233 ? l`There was an issue connecting to KLIPY.` 234 : l`There was an issue connecting to Tenor.` 235 } 236 emptyMessage={ 237 isSearching 238 ? l`No search results found for "${search}".` 239 : klipyEnabled 240 ? l`No featured GIFs found. There may be an issue with KLIPY.` 241 : l`No featured GIFs found. There may be an issue with Tenor.` 242 } 243 /> 244 )} 245 </> 246 } 247 stickyHeaderIndices={[0]} 248 onEndReached={onEndReached} 249 onEndReachedThreshold={4} 250 keyExtractor={(item: Gif) => item.id} 251 keyboardDismissMode="on-drag" 252 ListFooterComponent={ 253 hasData ? ( 254 <ListFooter 255 isFetchingNextPage={isFetchingNextPage} 256 error={cleanError(error)} 257 onRetry={fetchNextPage} 258 style={{borderTopWidth: 0}} 259 /> 260 ) : null 261 } 262 /> 263 </> 264 ) 265} 266 267function DialogError({details}: {details?: string}) { 268 const {t: l} = useLingui() 269 const control = Dialog.useDialogContext() 270 271 return ( 272 <Dialog.ScrollableInner style={a.gap_md} label={l`An error has occurred`}> 273 <Dialog.Close /> 274 <ErrorScreen 275 title={l`Oh no!`} 276 message={l`There was an unexpected issue in the application. Please let us know if this happened to you!`} 277 details={details} 278 /> 279 <Button 280 label={l`Close dialog`} 281 onPress={() => control.close()} 282 color="primary" 283 size="large" 284 variant="solid"> 285 <ButtonText> 286 <Trans>Close</Trans> 287 </ButtonText> 288 </Button> 289 </Dialog.ScrollableInner> 290 ) 291} 292 293export function GifPreview({ 294 gif, 295 onSelectGif, 296}: { 297 gif: Gif 298 onSelectGif: (gif: Gif) => void 299}) { 300 const ax = useAnalytics() 301 const {gtTablet} = useBreakpoints() 302 const {t: l} = useLingui() 303 const t = useTheme() 304 305 const onPress = useCallback(() => { 306 ax.metric('composer:gif:select', {}) 307 onSelectGif(gif) 308 }, [ax, onSelectGif, gif]) 309 310 return ( 311 <Button 312 label={l`Select GIF "${gif.title}"`} 313 style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]} 314 onPress={onPress}> 315 {({pressed}) => ( 316 <Image 317 style={[ 318 a.flex_1, 319 a.mb_sm, 320 a.rounded_sm, 321 a.aspect_square, 322 {opacity: pressed ? 0.8 : 1}, 323 t.atoms.bg_contrast_25, 324 ]} 325 source={{ 326 uri: gifPreviewUrl(gif.media_formats.tinygif.url), 327 }} 328 contentFit="cover" 329 accessibilityLabel={gif.title} 330 accessibilityHint="" 331 cachePolicy="none" 332 accessibilityIgnoresInvertColors 333 /> 334 )} 335 </Button> 336 ) 337}