mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

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

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