mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
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})