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