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