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

Configure Feed

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

at theme-changes 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}