Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
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})