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