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 652 lines 22 kB view raw
1import React, {useCallback, useMemo} from 'react' 2import {StyleSheet} from 'react-native' 3import {SafeAreaView} from 'react-native-safe-area-context' 4import { 5 type AppBskyActorDefs, 6 moderateProfile, 7 type ModerationOpts, 8 RichText as RichTextAPI, 9} from '@atproto/api' 10import {msg, Trans} from '@lingui/macro' 11import {useLingui} from '@lingui/react' 12import {useFocusEffect, useNavigation} from '@react-navigation/native' 13import {useQueryClient} from '@tanstack/react-query' 14 15import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 16import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' 17import {useSetTitle} from '#/lib/hooks/useSetTitle' 18import {ComposeIcon2} from '#/lib/icons' 19import { 20 type CommonNavigatorParams, 21 type NativeStackScreenProps, 22 type NavigationProp, 23} from '#/lib/routes/types' 24import {combinedDisplayName} from '#/lib/strings/display-names' 25import {cleanError} from '#/lib/strings/errors' 26import {isInvalidHandle} from '#/lib/strings/handles' 27import {colors, s} from '#/lib/styles' 28import {useProfileShadow} from '#/state/cache/profile-shadow' 29import {listenSoftReset} from '#/state/events' 30import {useModerationOpts} from '#/state/preferences/moderation-opts' 31import {useLabelerInfoQuery} from '#/state/queries/labeler' 32import {resetProfilePostsQueries} from '#/state/queries/post-feed' 33import {useProfileQuery} from '#/state/queries/profile' 34import {useResolveDidQuery} from '#/state/queries/resolve-uri' 35import {useAgent, useSession} from '#/state/session' 36import {useSetMinimalShellMode} from '#/state/shell' 37import {ProfileFeedgens} from '#/view/com/feeds/ProfileFeedgens' 38import {ProfileLists} from '#/view/com/lists/ProfileLists' 39import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader' 40import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' 41import {FAB} from '#/view/com/util/fab/FAB' 42import {type ListRef} from '#/view/com/util/List' 43import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' 44import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' 45import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' 46import {atoms as a} from '#/alf' 47import {Circle_And_Square_Stroke1_Corner0_Rounded_Filled as CircleAndSquareIcon} from '#/components/icons/CircleAndSquare' 48import {Heart2_Stroke1_Corner0_Rounded as HeartIcon} from '#/components/icons/Heart2' 49import {Image_Stroke1_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' 50import {Message_Stroke1_Corner0_Rounded_Filled as MessageIcon} from '#/components/icons/Message' 51import {VideoClip_Stroke1_Corner0_Rounded as VideoIcon} from '#/components/icons/VideoClip' 52import * as Layout from '#/components/Layout' 53import {ScreenHider} from '#/components/moderation/ScreenHider' 54import {ProfileStarterPacks} from '#/components/StarterPack/ProfileStarterPacks' 55import {navigate} from '#/Navigation' 56import {ExpoScrollForwarderView} from '../../../modules/expo-scroll-forwarder' 57 58interface SectionRef { 59 scrollToTop: () => void 60} 61 62type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'> 63export function ProfileScreen(props: Props) { 64 return ( 65 <Layout.Screen testID="profileScreen" style={[a.pt_0]}> 66 <ProfileScreenInner {...props} /> 67 </Layout.Screen> 68 ) 69} 70 71function ProfileScreenInner({route}: Props) { 72 const {_} = useLingui() 73 const {currentAccount} = useSession() 74 const queryClient = useQueryClient() 75 const name = 76 route.params.name === 'me' ? currentAccount?.did : route.params.name 77 const moderationOpts = useModerationOpts() 78 const { 79 data: resolvedDid, 80 error: resolveError, 81 refetch: refetchDid, 82 isLoading: isLoadingDid, 83 } = useResolveDidQuery(name) 84 const { 85 data: profile, 86 error: profileError, 87 refetch: refetchProfile, 88 isLoading: isLoadingProfile, 89 isPlaceholderData: isPlaceholderProfile, 90 } = useProfileQuery({ 91 did: resolvedDid, 92 }) 93 94 const onPressTryAgain = React.useCallback(() => { 95 if (resolveError) { 96 refetchDid() 97 } else { 98 refetchProfile() 99 } 100 }, [resolveError, refetchDid, refetchProfile]) 101 102 // Apply hard-coded redirects as need 103 React.useEffect(() => { 104 if (resolveError) { 105 if (name === 'lulaoficial.bsky.social') { 106 console.log('Applying redirect to lula.com.br') 107 navigate('Profile', {name: 'lula.com.br'}) 108 } 109 } 110 }, [name, resolveError]) 111 112 // When we open the profile, we want to reset the posts query if we are blocked. 113 React.useEffect(() => { 114 if (resolvedDid && profile?.viewer?.blockedBy) { 115 resetProfilePostsQueries(queryClient, resolvedDid) 116 } 117 }, [queryClient, profile?.viewer?.blockedBy, resolvedDid]) 118 119 // Most pushes will happen here, since we will have only placeholder data 120 if (isLoadingDid || isLoadingProfile) { 121 return ( 122 <Layout.Content> 123 <ProfileHeaderLoading /> 124 </Layout.Content> 125 ) 126 } 127 if (resolveError || profileError) { 128 return ( 129 <SafeAreaView style={[a.flex_1]}> 130 <ErrorScreen 131 testID="profileErrorScreen" 132 title={profileError ? _(msg`Not Found`) : _(msg`Oops!`)} 133 message={cleanError(resolveError || profileError)} 134 onPressTryAgain={onPressTryAgain} 135 showHeader 136 /> 137 </SafeAreaView> 138 ) 139 } 140 if (profile && moderationOpts) { 141 return ( 142 <ProfileScreenLoaded 143 profile={profile} 144 moderationOpts={moderationOpts} 145 isPlaceholderProfile={isPlaceholderProfile} 146 hideBackButton={!!route.params.hideBackButton} 147 /> 148 ) 149 } 150 // should never happen 151 return ( 152 <SafeAreaView style={[a.flex_1]}> 153 <ErrorScreen 154 testID="profileErrorScreen" 155 title="Oops!" 156 message="Something went wrong and we're not sure what." 157 onPressTryAgain={onPressTryAgain} 158 showHeader 159 /> 160 </SafeAreaView> 161 ) 162} 163 164function ProfileScreenLoaded({ 165 profile: profileUnshadowed, 166 isPlaceholderProfile, 167 moderationOpts, 168 hideBackButton, 169}: { 170 profile: AppBskyActorDefs.ProfileViewDetailed 171 moderationOpts: ModerationOpts 172 hideBackButton: boolean 173 isPlaceholderProfile: boolean 174}) { 175 const profile = useProfileShadow(profileUnshadowed) 176 const {hasSession, currentAccount} = useSession() 177 const setMinimalShellMode = useSetMinimalShellMode() 178 const {openComposer} = useOpenComposer() 179 const navigation = useNavigation<NavigationProp>() 180 const requireEmailVerification = useRequireEmailVerification() 181 const { 182 data: labelerInfo, 183 error: labelerError, 184 isLoading: isLabelerLoading, 185 } = useLabelerInfoQuery({ 186 did: profile.did, 187 enabled: !!profile.associated?.labeler, 188 }) 189 const [currentPage, setCurrentPage] = React.useState(0) 190 const {_} = useLingui() 191 192 const [scrollViewTag, setScrollViewTag] = React.useState<number | null>(null) 193 194 const postsSectionRef = React.useRef<SectionRef>(null) 195 const repliesSectionRef = React.useRef<SectionRef>(null) 196 const mediaSectionRef = React.useRef<SectionRef>(null) 197 const videosSectionRef = React.useRef<SectionRef>(null) 198 const likesSectionRef = React.useRef<SectionRef>(null) 199 const feedsSectionRef = React.useRef<SectionRef>(null) 200 const listsSectionRef = React.useRef<SectionRef>(null) 201 const starterPacksSectionRef = React.useRef<SectionRef>(null) 202 const labelsSectionRef = React.useRef<SectionRef>(null) 203 204 useSetTitle(combinedDisplayName(profile)) 205 206 const description = profile.description ?? '' 207 const hasDescription = description !== '' 208 const [descriptionRT, isResolvingDescriptionRT] = useRichText(description) 209 const showPlaceholder = isPlaceholderProfile || isResolvingDescriptionRT 210 const moderation = useMemo( 211 () => moderateProfile(profile, moderationOpts), 212 [profile, moderationOpts], 213 ) 214 215 const isMe = profile.did === currentAccount?.did 216 const hasLabeler = !!profile.associated?.labeler 217 const showFiltersTab = hasLabeler 218 const showPostsTab = true 219 const showRepliesTab = hasSession 220 const showMediaTab = !hasLabeler 221 const showVideosTab = !hasLabeler 222 const showLikesTab = isMe 223 const feedGenCount = profile.associated?.feedgens || 0 224 const showFeedsTab = isMe || feedGenCount > 0 225 const starterPackCount = profile.associated?.starterPacks || 0 226 const showStarterPacksTab = isMe || starterPackCount > 0 227 // subtract starterpack count from list count, since starterpacks are a type of list 228 const listCount = (profile.associated?.lists || 0) - starterPackCount 229 const showListsTab = hasSession && (isMe || listCount > 0) 230 231 const sectionTitles = [ 232 showFiltersTab ? _(msg`Labels`) : undefined, 233 showListsTab && hasLabeler ? _(msg`Lists`) : undefined, 234 showPostsTab ? _(msg`Posts`) : undefined, 235 showRepliesTab ? _(msg`Replies`) : undefined, 236 showMediaTab ? _(msg`Media`) : undefined, 237 showVideosTab ? _(msg`Videos`) : undefined, 238 showLikesTab ? _(msg`Likes`) : undefined, 239 showFeedsTab ? _(msg`Feeds`) : undefined, 240 showStarterPacksTab ? _(msg`Starter Packs`) : undefined, 241 showListsTab && !hasLabeler ? _(msg`Lists`) : undefined, 242 ].filter(Boolean) as string[] 243 244 let nextIndex = 0 245 let filtersIndex: number | null = null 246 let postsIndex: number | null = null 247 let repliesIndex: number | null = null 248 let mediaIndex: number | null = null 249 let videosIndex: number | null = null 250 let likesIndex: number | null = null 251 let feedsIndex: number | null = null 252 let starterPacksIndex: number | null = null 253 let listsIndex: number | null = null 254 if (showFiltersTab) { 255 filtersIndex = nextIndex++ 256 } 257 if (showPostsTab) { 258 postsIndex = nextIndex++ 259 } 260 if (showRepliesTab) { 261 repliesIndex = nextIndex++ 262 } 263 if (showMediaTab) { 264 mediaIndex = nextIndex++ 265 } 266 if (showVideosTab) { 267 videosIndex = nextIndex++ 268 } 269 if (showLikesTab) { 270 likesIndex = nextIndex++ 271 } 272 if (showFeedsTab) { 273 feedsIndex = nextIndex++ 274 } 275 if (showStarterPacksTab) { 276 starterPacksIndex = nextIndex++ 277 } 278 if (showListsTab) { 279 listsIndex = nextIndex++ 280 } 281 282 const scrollSectionToTop = useCallback( 283 (index: number) => { 284 if (index === filtersIndex) { 285 labelsSectionRef.current?.scrollToTop() 286 } else if (index === postsIndex) { 287 postsSectionRef.current?.scrollToTop() 288 } else if (index === repliesIndex) { 289 repliesSectionRef.current?.scrollToTop() 290 } else if (index === mediaIndex) { 291 mediaSectionRef.current?.scrollToTop() 292 } else if (index === videosIndex) { 293 videosSectionRef.current?.scrollToTop() 294 } else if (index === likesIndex) { 295 likesSectionRef.current?.scrollToTop() 296 } else if (index === feedsIndex) { 297 feedsSectionRef.current?.scrollToTop() 298 } else if (index === starterPacksIndex) { 299 starterPacksSectionRef.current?.scrollToTop() 300 } else if (index === listsIndex) { 301 listsSectionRef.current?.scrollToTop() 302 } 303 }, 304 [ 305 filtersIndex, 306 postsIndex, 307 repliesIndex, 308 mediaIndex, 309 videosIndex, 310 likesIndex, 311 feedsIndex, 312 listsIndex, 313 starterPacksIndex, 314 ], 315 ) 316 317 useFocusEffect( 318 React.useCallback(() => { 319 setMinimalShellMode(false) 320 return listenSoftReset(() => { 321 scrollSectionToTop(currentPage) 322 }) 323 }, [setMinimalShellMode, currentPage, scrollSectionToTop]), 324 ) 325 326 // events 327 // = 328 329 const onPressCompose = () => { 330 const mention = 331 profile.handle === currentAccount?.handle || 332 isInvalidHandle(profile.handle) 333 ? undefined 334 : profile.handle 335 openComposer({mention}) 336 } 337 338 const onPageSelected = (i: number) => { 339 setCurrentPage(i) 340 } 341 342 const onCurrentPageSelected = (index: number) => { 343 scrollSectionToTop(index) 344 } 345 346 const navToWizard = useCallback(() => { 347 navigation.navigate('StarterPackWizard', {}) 348 }, [navigation]) 349 const wrappedNavToWizard = requireEmailVerification(navToWizard, { 350 instructions: [ 351 <Trans key="nav"> 352 Before creating a starter pack, you must first verify your email. 353 </Trans>, 354 ], 355 }) 356 357 // rendering 358 // = 359 360 const renderHeader = ({ 361 setMinimumHeight, 362 }: { 363 setMinimumHeight: (height: number) => void 364 }) => { 365 return ( 366 <ExpoScrollForwarderView scrollViewTag={scrollViewTag}> 367 <ProfileHeader 368 profile={profile} 369 labeler={labelerInfo} 370 descriptionRT={hasDescription ? descriptionRT : null} 371 moderationOpts={moderationOpts} 372 hideBackButton={hideBackButton} 373 isPlaceholderProfile={showPlaceholder} 374 setMinimumHeight={setMinimumHeight} 375 /> 376 </ExpoScrollForwarderView> 377 ) 378 } 379 380 return ( 381 <ScreenHider 382 testID="profileView" 383 style={styles.container} 384 screenDescription={_(msg`profile`)} 385 modui={moderation.ui('profileView')}> 386 <PagerWithHeader 387 testID="profilePager" 388 isHeaderReady={!showPlaceholder} 389 items={sectionTitles} 390 onPageSelected={onPageSelected} 391 onCurrentPageSelected={onCurrentPageSelected} 392 renderHeader={renderHeader} 393 allowHeaderOverScroll> 394 {showFiltersTab 395 ? ({headerHeight, isFocused, scrollElRef}) => ( 396 <ProfileLabelsSection 397 ref={labelsSectionRef} 398 labelerInfo={labelerInfo} 399 labelerError={labelerError} 400 isLabelerLoading={isLabelerLoading} 401 moderationOpts={moderationOpts} 402 scrollElRef={scrollElRef as ListRef} 403 headerHeight={headerHeight} 404 isFocused={isFocused} 405 setScrollViewTag={setScrollViewTag} 406 /> 407 ) 408 : null} 409 {showListsTab && !!profile.associated?.labeler 410 ? ({headerHeight, isFocused, scrollElRef}) => ( 411 <ProfileLists 412 ref={listsSectionRef} 413 did={profile.did} 414 scrollElRef={scrollElRef as ListRef} 415 headerOffset={headerHeight} 416 enabled={isFocused} 417 setScrollViewTag={setScrollViewTag} 418 /> 419 ) 420 : null} 421 {showPostsTab 422 ? ({headerHeight, isFocused, scrollElRef}) => ( 423 <ProfileFeedSection 424 ref={postsSectionRef} 425 feed={`author|${profile.did}|posts_and_author_threads`} 426 headerHeight={headerHeight} 427 isFocused={isFocused} 428 scrollElRef={scrollElRef as ListRef} 429 ignoreFilterFor={profile.did} 430 setScrollViewTag={setScrollViewTag} 431 emptyStateMessage={_(msg`No posts yet`)} 432 emptyStateButton={ 433 isMe 434 ? { 435 label: _(msg`Write a post`), 436 text: _(msg`Write a post`), 437 onPress: () => openComposer({}), 438 size: 'small', 439 color: 'primary', 440 } 441 : undefined 442 } 443 /> 444 ) 445 : null} 446 {showRepliesTab 447 ? ({headerHeight, isFocused, scrollElRef}) => ( 448 <ProfileFeedSection 449 ref={repliesSectionRef} 450 feed={`author|${profile.did}|posts_with_replies`} 451 headerHeight={headerHeight} 452 isFocused={isFocused} 453 scrollElRef={scrollElRef as ListRef} 454 ignoreFilterFor={profile.did} 455 setScrollViewTag={setScrollViewTag} 456 emptyStateMessage={_(msg`No replies yet`)} 457 emptyStateIcon={MessageIcon} 458 /> 459 ) 460 : null} 461 {showMediaTab 462 ? ({headerHeight, isFocused, scrollElRef}) => ( 463 <ProfileFeedSection 464 ref={mediaSectionRef} 465 feed={`author|${profile.did}|posts_with_media`} 466 headerHeight={headerHeight} 467 isFocused={isFocused} 468 scrollElRef={scrollElRef as ListRef} 469 ignoreFilterFor={profile.did} 470 setScrollViewTag={setScrollViewTag} 471 emptyStateMessage={_(msg`No media yet`)} 472 emptyStateButton={ 473 isMe 474 ? { 475 label: _(msg`Post a photo`), 476 text: _(msg`Post a photo`), 477 onPress: () => openComposer({}), 478 size: 'small', 479 color: 'primary', 480 } 481 : undefined 482 } 483 emptyStateIcon={ImageIcon} 484 /> 485 ) 486 : null} 487 {showVideosTab 488 ? ({headerHeight, isFocused, scrollElRef}) => ( 489 <ProfileFeedSection 490 ref={videosSectionRef} 491 feed={`author|${profile.did}|posts_with_video`} 492 headerHeight={headerHeight} 493 isFocused={isFocused} 494 scrollElRef={scrollElRef as ListRef} 495 ignoreFilterFor={profile.did} 496 setScrollViewTag={setScrollViewTag} 497 emptyStateMessage={_(msg`No video posts yet`)} 498 emptyStateButton={ 499 isMe 500 ? { 501 label: _(msg`Post a video`), 502 text: _(msg`Post a video`), 503 onPress: () => openComposer({}), 504 size: 'small', 505 color: 'primary', 506 } 507 : undefined 508 } 509 emptyStateIcon={VideoIcon} 510 /> 511 ) 512 : null} 513 {showLikesTab 514 ? ({headerHeight, isFocused, scrollElRef}) => ( 515 <ProfileFeedSection 516 ref={likesSectionRef} 517 feed={`likes|${profile.did}`} 518 headerHeight={headerHeight} 519 isFocused={isFocused} 520 scrollElRef={scrollElRef as ListRef} 521 ignoreFilterFor={profile.did} 522 setScrollViewTag={setScrollViewTag} 523 emptyStateMessage={_(msg`No likes yet`)} 524 emptyStateIcon={HeartIcon} 525 /> 526 ) 527 : null} 528 {showFeedsTab 529 ? ({headerHeight, isFocused, scrollElRef}) => ( 530 <ProfileFeedgens 531 ref={feedsSectionRef} 532 did={profile.did} 533 scrollElRef={scrollElRef as ListRef} 534 headerOffset={headerHeight} 535 enabled={isFocused} 536 setScrollViewTag={setScrollViewTag} 537 /> 538 ) 539 : null} 540 {showStarterPacksTab 541 ? ({headerHeight, isFocused, scrollElRef}) => ( 542 <ProfileStarterPacks 543 ref={starterPacksSectionRef} 544 did={profile.did} 545 isMe={isMe} 546 scrollElRef={scrollElRef as ListRef} 547 headerOffset={headerHeight} 548 enabled={isFocused} 549 setScrollViewTag={setScrollViewTag} 550 emptyStateMessage={ 551 isMe 552 ? _( 553 msg`Starter Packs let you share your favorite feeds and people with your friends.`, 554 ) 555 : _(msg`No Starter Packs yet`) 556 } 557 emptyStateButton={ 558 isMe 559 ? { 560 label: _(msg`Create a Starter Pack`), 561 text: _(msg`Create a Starter Pack`), 562 onPress: wrappedNavToWizard, 563 color: 'primary', 564 size: 'small', 565 } 566 : undefined 567 } 568 emptyStateIcon={CircleAndSquareIcon} 569 /> 570 ) 571 : null} 572 {showListsTab && !profile.associated?.labeler 573 ? ({headerHeight, isFocused, scrollElRef}) => ( 574 <ProfileLists 575 ref={listsSectionRef} 576 did={profile.did} 577 scrollElRef={scrollElRef as ListRef} 578 headerOffset={headerHeight} 579 enabled={isFocused} 580 setScrollViewTag={setScrollViewTag} 581 /> 582 ) 583 : null} 584 </PagerWithHeader> 585 {hasSession && ( 586 <FAB 587 testID="composeFAB" 588 onPress={onPressCompose} 589 icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} 590 accessibilityRole="button" 591 accessibilityLabel={_(msg`New post`)} 592 accessibilityHint="" 593 /> 594 )} 595 </ScreenHider> 596 ) 597} 598 599function useRichText(text: string): [RichTextAPI, boolean] { 600 const agent = useAgent() 601 const [prevText, setPrevText] = React.useState(text) 602 const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text})) 603 const [resolvedRT, setResolvedRT] = React.useState<RichTextAPI | null>(null) 604 if (text !== prevText) { 605 setPrevText(text) 606 setRawRT(new RichTextAPI({text})) 607 setResolvedRT(null) 608 // This will queue an immediate re-render 609 } 610 React.useEffect(() => { 611 let ignore = false 612 async function resolveRTFacets() { 613 // new each time 614 const resolvedRT = new RichTextAPI({text}) 615 await resolvedRT.detectFacets(agent) 616 if (!ignore) { 617 setResolvedRT(resolvedRT) 618 } 619 } 620 resolveRTFacets() 621 return () => { 622 ignore = true 623 } 624 }, [text, agent]) 625 const isResolving = resolvedRT === null 626 return [resolvedRT ?? rawRT, isResolving] 627} 628 629const styles = StyleSheet.create({ 630 container: { 631 flexDirection: 'column', 632 height: '100%', 633 // @ts-ignore Web-only. 634 overflowAnchor: 'none', // Fixes jumps when switching tabs while scrolled down. 635 }, 636 loading: { 637 paddingVertical: 10, 638 paddingHorizontal: 14, 639 }, 640 emptyState: { 641 paddingVertical: 40, 642 }, 643 loadingMoreFooter: { 644 paddingVertical: 20, 645 }, 646 endItem: { 647 paddingTop: 20, 648 paddingBottom: 30, 649 color: colors.gray5, 650 textAlign: 'center', 651 }, 652})