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 785 lines 22 kB view raw
1import React from 'react' 2import {ActivityIndicator, StyleSheet, View} from 'react-native' 3import {type AppBskyFeedDefs} from '@atproto/api' 4import {msg} from '@lingui/core/macro' 5import {useLingui} from '@lingui/react' 6import {Trans} from '@lingui/react/macro' 7import {useFocusEffect} from '@react-navigation/native' 8import debounce from 'lodash.debounce' 9 10import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 11import {usePalette} from '#/lib/hooks/usePalette' 12import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 13import {ComposeIcon2} from '#/lib/icons' 14import { 15 type CommonNavigatorParams, 16 type NativeStackScreenProps, 17} from '#/lib/routes/types' 18import {cleanError} from '#/lib/strings/errors' 19import {s} from '#/lib/styles' 20import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 21import { 22 type SavedFeedItem, 23 useGetPopularFeedsQuery, 24 useSavedFeeds, 25 useSearchPopularFeedsMutation, 26} from '#/state/queries/feed' 27import {useSession} from '#/state/session' 28import {useSetMinimalShellMode} from '#/state/shell' 29import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 30import {FAB} from '#/view/com/util/fab/FAB' 31import {List, type ListMethods} from '#/view/com/util/List' 32import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 33import {Text} from '#/view/com/util/text/Text' 34import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed' 35import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType' 36import {atoms as a, useTheme} from '#/alf' 37import {ButtonIcon} from '#/components/Button' 38import {Divider} from '#/components/Divider' 39import * as FeedCard from '#/components/FeedCard' 40import {SearchInput} from '#/components/forms/SearchInput' 41import {IconCircle} from '#/components/IconCircle' 42import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 43import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline' 44import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass' 45import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle' 46import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' 47import * as Layout from '#/components/Layout' 48import {Link} from '#/components/Link' 49import * as ListCard from '#/components/ListCard' 50import {IS_NATIVE, IS_WEB} from '#/env' 51 52type Props = NativeStackScreenProps<CommonNavigatorParams, 'Feeds'> 53 54type FlatlistSlice = 55 | { 56 type: 'error' 57 key: string 58 error: string 59 } 60 | { 61 type: 'savedFeedsHeader' 62 key: string 63 } 64 | { 65 type: 'savedFeedPlaceholder' 66 key: string 67 } 68 | { 69 type: 'savedFeedNoResults' 70 key: string 71 } 72 | { 73 type: 'savedFeed' 74 key: string 75 savedFeed: SavedFeedItem 76 } 77 | { 78 type: 'savedFeedsLoadMore' 79 key: string 80 } 81 | { 82 type: 'popularFeedsHeader' 83 key: string 84 } 85 | { 86 type: 'popularFeedsLoading' 87 key: string 88 } 89 | { 90 type: 'popularFeedsNoResults' 91 key: string 92 } 93 | { 94 type: 'popularFeed' 95 key: string 96 feedUri: string 97 feed: AppBskyFeedDefs.GeneratorView 98 } 99 | { 100 type: 'popularFeedsLoadingMore' 101 key: string 102 } 103 | { 104 type: 'noFollowingFeed' 105 key: string 106 } 107 108export function FeedsScreen(_props: Props) { 109 const pal = usePalette('default') 110 const t = useTheme() 111 const {openComposer} = useOpenComposer() 112 const {isMobile} = useWebMediaQueries() 113 const [query, setQuery] = React.useState('') 114 const [isPTR, setIsPTR] = React.useState(false) 115 const { 116 data: savedFeeds, 117 isPlaceholderData: isSavedFeedsPlaceholder, 118 error: savedFeedsError, 119 refetch: refetchSavedFeeds, 120 } = useSavedFeeds() 121 const { 122 data: popularFeeds, 123 isFetching: isPopularFeedsFetching, 124 error: popularFeedsError, 125 refetch: refetchPopularFeeds, 126 fetchNextPage: fetchNextPopularFeedsPage, 127 isFetchingNextPage: isPopularFeedsFetchingNextPage, 128 hasNextPage: hasNextPopularFeedsPage, 129 } = useGetPopularFeedsQuery() 130 const {_} = useLingui() 131 const setMinimalShellMode = useSetMinimalShellMode() 132 const { 133 data: searchResults, 134 mutate: search, 135 reset: resetSearch, 136 isPending: isSearchPending, 137 error: searchError, 138 } = useSearchPopularFeedsMutation() 139 const {hasSession} = useSession() 140 const listRef = React.useRef<ListMethods>(null) 141 142 const enableSquareButtons = useEnableSquareButtons() 143 144 /** 145 * A search query is present. We may not have search results yet. 146 */ 147 const isUserSearching = query.length > 1 148 const debouncedSearch = React.useMemo( 149 () => debounce(q => search(q), 500), // debounce for 500ms 150 [search], 151 ) 152 const onPressCompose = React.useCallback(() => { 153 openComposer({logContext: 'Fab'}) 154 }, [openComposer]) 155 const onChangeQuery = React.useCallback( 156 (text: string) => { 157 setQuery(text) 158 if (text.length > 1) { 159 debouncedSearch(text) 160 } else { 161 refetchPopularFeeds() 162 resetSearch() 163 } 164 }, 165 [setQuery, refetchPopularFeeds, debouncedSearch, resetSearch], 166 ) 167 const onPressCancelSearch = React.useCallback(() => { 168 setQuery('') 169 refetchPopularFeeds() 170 resetSearch() 171 }, [refetchPopularFeeds, setQuery, resetSearch]) 172 const onSubmitQuery = React.useCallback(() => { 173 debouncedSearch(query) 174 }, [query, debouncedSearch]) 175 const onPullToRefresh = React.useCallback(async () => { 176 setIsPTR(true) 177 await Promise.all([ 178 refetchSavedFeeds().catch(_e => undefined), 179 refetchPopularFeeds().catch(_e => undefined), 180 ]) 181 setIsPTR(false) 182 }, [setIsPTR, refetchSavedFeeds, refetchPopularFeeds]) 183 const onEndReached = React.useCallback(() => { 184 if ( 185 isPopularFeedsFetching || 186 isUserSearching || 187 !hasNextPopularFeedsPage || 188 popularFeedsError 189 ) 190 return 191 fetchNextPopularFeedsPage() 192 }, [ 193 isPopularFeedsFetching, 194 isUserSearching, 195 popularFeedsError, 196 hasNextPopularFeedsPage, 197 fetchNextPopularFeedsPage, 198 ]) 199 200 useFocusEffect( 201 React.useCallback(() => { 202 setMinimalShellMode(false) 203 }, [setMinimalShellMode]), 204 ) 205 206 const items = React.useMemo(() => { 207 let slices: FlatlistSlice[] = [] 208 const hasActualSavedCount = 209 !isSavedFeedsPlaceholder || 210 (isSavedFeedsPlaceholder && (savedFeeds?.count || 0) > 0) 211 const canShowDiscoverSection = 212 !hasSession || (hasSession && hasActualSavedCount) 213 214 if (hasSession) { 215 slices.push({ 216 key: 'savedFeedsHeader', 217 type: 'savedFeedsHeader', 218 }) 219 220 if (savedFeedsError) { 221 slices.push({ 222 key: 'savedFeedsError', 223 type: 'error', 224 error: cleanError(savedFeedsError.toString()), 225 }) 226 } else { 227 if (isSavedFeedsPlaceholder && !savedFeeds?.feeds.length) { 228 /* 229 * Initial render in placeholder state is 0 on a cold page load, 230 * because preferences haven't loaded yet. 231 * 232 * In practice, `savedFeeds` is always defined, but we check for TS 233 * and for safety. 234 * 235 * In both cases, we show 4 as the the loading state. 236 */ 237 const min = 8 238 const count = savedFeeds 239 ? savedFeeds.count === 0 240 ? min 241 : savedFeeds.count 242 : min 243 Array(count) 244 .fill(0) 245 .forEach((_, i) => { 246 slices.push({ 247 key: 'savedFeedPlaceholder' + i, 248 type: 'savedFeedPlaceholder', 249 }) 250 }) 251 } else { 252 if (savedFeeds?.feeds?.length) { 253 const noFollowingFeed = savedFeeds.feeds.every( 254 f => f.type !== 'timeline', 255 ) 256 257 slices = slices.concat( 258 savedFeeds.feeds 259 .filter(s => { 260 return s.config.pinned 261 }) 262 .map(s => ({ 263 key: `savedFeed:${s.view?.uri}:${s.config.id}`, 264 type: 'savedFeed', 265 savedFeed: s, 266 })), 267 ) 268 slices = slices.concat( 269 savedFeeds.feeds 270 .filter(s => { 271 return !s.config.pinned 272 }) 273 .map(s => ({ 274 key: `savedFeed:${s.view?.uri}:${s.config.id}`, 275 type: 'savedFeed', 276 savedFeed: s, 277 })), 278 ) 279 280 if (noFollowingFeed) { 281 slices.push({ 282 key: 'noFollowingFeed', 283 type: 'noFollowingFeed', 284 }) 285 } 286 } else { 287 slices.push({ 288 key: 'savedFeedNoResults', 289 type: 'savedFeedNoResults', 290 }) 291 } 292 } 293 } 294 } 295 296 if (!hasSession || (hasSession && canShowDiscoverSection)) { 297 slices.push({ 298 key: 'popularFeedsHeader', 299 type: 'popularFeedsHeader', 300 }) 301 302 if (popularFeedsError || searchError) { 303 slices.push({ 304 key: 'popularFeedsError', 305 type: 'error', 306 error: cleanError( 307 popularFeedsError?.toString() ?? searchError?.toString() ?? '', 308 ), 309 }) 310 } else { 311 if (isUserSearching) { 312 if (isSearchPending || !searchResults) { 313 slices.push({ 314 key: 'popularFeedsLoading', 315 type: 'popularFeedsLoading', 316 }) 317 } else { 318 if (!searchResults || searchResults?.length === 0) { 319 slices.push({ 320 key: 'popularFeedsNoResults', 321 type: 'popularFeedsNoResults', 322 }) 323 } else { 324 slices = slices.concat( 325 searchResults.map(feed => ({ 326 key: `popularFeed:${feed.uri}`, 327 type: 'popularFeed', 328 feedUri: feed.uri, 329 feed, 330 })), 331 ) 332 } 333 } 334 } else { 335 if (isPopularFeedsFetching && !popularFeeds?.pages) { 336 slices.push({ 337 key: 'popularFeedsLoading', 338 type: 'popularFeedsLoading', 339 }) 340 } else { 341 if (!popularFeeds?.pages) { 342 slices.push({ 343 key: 'popularFeedsNoResults', 344 type: 'popularFeedsNoResults', 345 }) 346 } else { 347 for (const page of popularFeeds.pages || []) { 348 slices = slices.concat( 349 page.feeds.map(feed => ({ 350 key: `popularFeed:${feed.uri}`, 351 type: 'popularFeed', 352 feedUri: feed.uri, 353 feed, 354 })), 355 ) 356 } 357 358 if (isPopularFeedsFetchingNextPage) { 359 slices.push({ 360 key: 'popularFeedsLoadingMore', 361 type: 'popularFeedsLoadingMore', 362 }) 363 } 364 } 365 } 366 } 367 } 368 } 369 370 return slices 371 }, [ 372 hasSession, 373 savedFeeds, 374 isSavedFeedsPlaceholder, 375 savedFeedsError, 376 popularFeeds, 377 isPopularFeedsFetching, 378 popularFeedsError, 379 isPopularFeedsFetchingNextPage, 380 searchResults, 381 isSearchPending, 382 searchError, 383 isUserSearching, 384 ]) 385 386 const searchBarIndex = items.findIndex( 387 item => item.type === 'popularFeedsHeader', 388 ) 389 390 const onChangeSearchFocus = React.useCallback( 391 (focus: boolean) => { 392 if (focus && searchBarIndex > -1) { 393 if (IS_NATIVE) { 394 // scrollToIndex scrolls the exact right amount, so use if available 395 listRef.current?.scrollToIndex({ 396 index: searchBarIndex, 397 animated: true, 398 }) 399 } else { 400 // web implementation only supports scrollToOffset 401 // thus, we calculate the offset based on the index 402 // pixel values are estimates, I wasn't able to get it pixel perfect :( 403 const headerHeight = isMobile ? 43 : 53 404 const feedItemHeight = isMobile ? 49 : 58 405 listRef.current?.scrollToOffset({ 406 offset: searchBarIndex * feedItemHeight - headerHeight, 407 animated: true, 408 }) 409 } 410 } 411 }, 412 [searchBarIndex, isMobile], 413 ) 414 415 const renderItem = React.useCallback( 416 ({item}: {item: FlatlistSlice}) => { 417 if (item.type === 'error') { 418 return <ErrorMessage message={item.error} /> 419 } else if (item.type === 'popularFeedsLoadingMore') { 420 return ( 421 <View style={s.p10}> 422 <ActivityIndicator size="large" color={t.palette.primary_500} /> 423 </View> 424 ) 425 } else if (item.type === 'savedFeedsHeader') { 426 return <FeedsSavedHeader /> 427 } else if (item.type === 'savedFeedNoResults') { 428 return ( 429 <View 430 style={[ 431 pal.border, 432 { 433 borderBottomWidth: 1, 434 }, 435 ]}> 436 <NoSavedFeedsOfAnyType /> 437 </View> 438 ) 439 } else if (item.type === 'savedFeedPlaceholder') { 440 return <SavedFeedPlaceholder /> 441 } else if (item.type === 'savedFeed') { 442 return <FeedOrFollowing savedFeed={item.savedFeed} /> 443 } else if (item.type === 'popularFeedsHeader') { 444 return ( 445 <> 446 <FeedsAboutHeader /> 447 <View style={{paddingHorizontal: 12, paddingBottom: 4}}> 448 <SearchInput 449 placeholder={_(msg`Search feeds`)} 450 value={query} 451 onChangeText={onChangeQuery} 452 onClearText={onPressCancelSearch} 453 onSubmitEditing={onSubmitQuery} 454 onFocus={() => onChangeSearchFocus(true)} 455 onBlur={() => onChangeSearchFocus(false)} 456 /> 457 </View> 458 </> 459 ) 460 } else if (item.type === 'popularFeedsLoading') { 461 return <FeedFeedLoadingPlaceholder /> 462 } else if (item.type === 'popularFeed') { 463 return ( 464 <View style={[a.px_lg, a.pt_lg, a.gap_lg]}> 465 <FeedCard.Default view={item.feed} /> 466 <Divider /> 467 </View> 468 ) 469 } else if (item.type === 'popularFeedsNoResults') { 470 return ( 471 <View 472 style={{ 473 paddingHorizontal: 16, 474 paddingTop: 10, 475 paddingBottom: '150%', 476 }}> 477 <Text type="lg" style={pal.textLight}> 478 <Trans>No results found for "{query}"</Trans> 479 </Text> 480 </View> 481 ) 482 } else if (item.type === 'noFollowingFeed') { 483 return ( 484 <View 485 style={[ 486 pal.border, 487 { 488 borderBottomWidth: 1, 489 }, 490 ]}> 491 <NoFollowingFeed /> 492 </View> 493 ) 494 } 495 return null 496 }, 497 [ 498 _, 499 t.palette.primary_500, 500 pal.border, 501 pal.textLight, 502 query, 503 onChangeQuery, 504 onPressCancelSearch, 505 onSubmitQuery, 506 onChangeSearchFocus, 507 ], 508 ) 509 510 return ( 511 <Layout.Screen testID="FeedsScreen"> 512 <Layout.Center> 513 <Layout.Header.Outer> 514 <Layout.Header.BackButton /> 515 <Layout.Header.Content> 516 <Layout.Header.TitleText> 517 <Trans>Feeds</Trans> 518 </Layout.Header.TitleText> 519 </Layout.Header.Content> 520 <Layout.Header.Slot> 521 <Link 522 testID="editFeedsBtn" 523 to="/settings/saved-feeds" 524 label={_(msg`Edit My Feeds`)} 525 size="small" 526 variant="ghost" 527 color="secondary" 528 shape={enableSquareButtons ? 'square' : 'round'} 529 style={[a.justify_center, {right: -3}]}> 530 <ButtonIcon icon={Gear} size="lg" /> 531 </Link> 532 </Layout.Header.Slot> 533 </Layout.Header.Outer> 534 535 <List 536 ref={listRef} 537 data={items} 538 keyExtractor={item => item.key} 539 contentContainerStyle={styles.contentContainer} 540 renderItem={renderItem} 541 refreshing={isPTR} 542 onRefresh={isUserSearching ? undefined : onPullToRefresh} 543 initialNumToRender={10} 544 onEndReached={onEndReached} 545 desktopFixedHeight 546 keyboardShouldPersistTaps="handled" 547 keyboardDismissMode="on-drag" 548 sideBorders={false} 549 /> 550 </Layout.Center> 551 552 {hasSession && ( 553 <FAB 554 testID="composeFAB" 555 onPress={onPressCompose} 556 icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} 557 accessibilityRole="button" 558 accessibilityLabel={_(msg`New post`)} 559 accessibilityHint="" 560 /> 561 )} 562 </Layout.Screen> 563 ) 564} 565 566function FeedOrFollowing({savedFeed}: {savedFeed: SavedFeedItem}) { 567 return savedFeed.type === 'timeline' ? ( 568 <FollowingFeed /> 569 ) : ( 570 <SavedFeed savedFeed={savedFeed} /> 571 ) 572} 573 574function FollowingFeed() { 575 const t = useTheme() 576 const {_} = useLingui() 577 return ( 578 <View 579 style={[ 580 a.flex_1, 581 a.px_lg, 582 a.py_md, 583 a.border_b, 584 t.atoms.border_contrast_low, 585 ]}> 586 <FeedCard.Header> 587 <View 588 style={[ 589 a.align_center, 590 a.justify_center, 591 { 592 width: 28, 593 height: 28, 594 borderRadius: 3, 595 backgroundColor: t.palette.primary_500, 596 }, 597 ]}> 598 <FilterTimeline 599 style={[ 600 { 601 width: 18, 602 height: 18, 603 }, 604 ]} 605 fill={t.palette.white} 606 /> 607 </View> 608 <FeedCard.TitleAndByline 609 title={_(msg({message: 'Following', context: 'feed-name'}))} 610 /> 611 </FeedCard.Header> 612 </View> 613 ) 614} 615 616function SavedFeed({ 617 savedFeed, 618}: { 619 savedFeed: SavedFeedItem & {type: 'feed' | 'list'} 620}) { 621 const t = useTheme() 622 623 const commonStyle = [ 624 a.w_full, 625 a.flex_1, 626 a.px_lg, 627 a.py_md, 628 a.border_b, 629 t.atoms.border_contrast_low, 630 ] 631 632 return savedFeed.type === 'feed' ? ( 633 <FeedCard.Link 634 testID={`saved-feed-${savedFeed.view.displayName}`} 635 {...savedFeed}> 636 {({hovered, pressed}) => ( 637 <View 638 style={[commonStyle, (hovered || pressed) && t.atoms.bg_contrast_25]}> 639 <FeedCard.Header> 640 <FeedCard.Avatar src={savedFeed.view.avatar} size={28} /> 641 <FeedCard.TitleAndByline title={savedFeed.view.displayName} /> 642 643 <ChevronRight size="sm" fill={t.atoms.text_contrast_low.color} /> 644 </FeedCard.Header> 645 </View> 646 )} 647 </FeedCard.Link> 648 ) : ( 649 <ListCard.Link testID={`saved-feed-${savedFeed.view.name}`} {...savedFeed}> 650 {({hovered, pressed}) => ( 651 <View 652 style={[commonStyle, (hovered || pressed) && t.atoms.bg_contrast_25]}> 653 <ListCard.Header> 654 <ListCard.Avatar src={savedFeed.view.avatar} size={28} /> 655 <ListCard.TitleAndByline title={savedFeed.view.name} /> 656 657 <ChevronRight size="sm" fill={t.atoms.text_contrast_low.color} /> 658 </ListCard.Header> 659 </View> 660 )} 661 </ListCard.Link> 662 ) 663} 664 665function SavedFeedPlaceholder() { 666 const t = useTheme() 667 return ( 668 <View 669 style={[ 670 a.flex_1, 671 a.px_lg, 672 a.py_md, 673 a.border_b, 674 t.atoms.border_contrast_low, 675 ]}> 676 <FeedCard.Header> 677 <FeedCard.AvatarPlaceholder size={28} /> 678 <FeedCard.TitleAndBylinePlaceholder /> 679 </FeedCard.Header> 680 </View> 681 ) 682} 683 684function FeedsSavedHeader() { 685 const t = useTheme() 686 687 return ( 688 <View 689 style={ 690 IS_WEB 691 ? [ 692 a.flex_row, 693 a.px_md, 694 a.py_lg, 695 a.gap_md, 696 a.border_b, 697 t.atoms.border_contrast_low, 698 ] 699 : [ 700 {flexDirection: 'row-reverse'}, 701 a.p_lg, 702 a.gap_md, 703 a.border_b, 704 t.atoms.border_contrast_low, 705 ] 706 }> 707 <IconCircle icon={ListSparkle_Stroke2_Corner0_Rounded} size="lg" /> 708 <View style={[a.flex_1, a.gap_xs]}> 709 <Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}> 710 <Trans>My Feeds</Trans> 711 </Text> 712 <Text style={[t.atoms.text_contrast_high]}> 713 <Trans>All the feeds you've saved, right in one place.</Trans> 714 </Text> 715 </View> 716 </View> 717 ) 718} 719 720function FeedsAboutHeader() { 721 const t = useTheme() 722 723 return ( 724 <View 725 style={ 726 IS_WEB 727 ? [a.flex_row, a.px_md, a.pt_lg, a.pb_lg, a.gap_md] 728 : [{flexDirection: 'row-reverse'}, a.p_lg, a.gap_md] 729 }> 730 <IconCircle 731 icon={ListMagnifyingGlass_Stroke2_Corner0_Rounded} 732 size="lg" 733 /> 734 <View style={[a.flex_1, a.gap_sm]}> 735 <Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}> 736 <Trans>Discover New Feeds</Trans> 737 </Text> 738 <Text style={[t.atoms.text_contrast_high]}> 739 <Trans> 740 Choose your own timeline! Feeds built by the community help you find 741 content you love. 742 </Trans> 743 </Text> 744 </View> 745 </View> 746 ) 747} 748 749const styles = StyleSheet.create({ 750 contentContainer: { 751 paddingBottom: 100, 752 }, 753 754 header: { 755 flexDirection: 'row', 756 alignItems: 'center', 757 justifyContent: 'space-between', 758 gap: 16, 759 paddingHorizontal: 18, 760 paddingVertical: 12, 761 }, 762 763 savedFeed: { 764 flexDirection: 'row', 765 alignItems: 'center', 766 paddingHorizontal: 16, 767 paddingVertical: 14, 768 gap: 12, 769 borderBottomWidth: StyleSheet.hairlineWidth, 770 }, 771 savedFeedMobile: { 772 paddingVertical: 10, 773 }, 774 offlineSlug: { 775 borderWidth: StyleSheet.hairlineWidth, 776 borderRadius: 4, 777 paddingHorizontal: 4, 778 paddingVertical: 2, 779 }, 780 headerBtnGroup: { 781 flexDirection: 'row', 782 gap: 15, 783 alignItems: 'center', 784 }, 785})