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

Configure Feed

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

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