forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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})