forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
2import {ScrollView, View} from 'react-native'
3import Animated, {
4 Easing,
5 FadeIn,
6 FadeOut,
7 LayoutAnimationConfig,
8 LinearTransition,
9} from 'react-native-reanimated'
10import {type AppBskyFeedDefs} from '@atproto/api'
11import {Trans, useLingui} from '@lingui/react/macro'
12import {useNavigation} from '@react-navigation/native'
13
14import {type NavigationProp} from '#/lib/routes/types'
15import {useHideSimilarAccountsRecomm} from '#/state/preferences/hide-similar-accounts-recommendations'
16import {useModerationOpts} from '#/state/preferences/moderation-opts'
17import {useGetPopularFeedsQuery} from '#/state/queries/feed'
18import {type FeedDescriptor} from '#/state/queries/post-feed'
19import {useSuggestedFollowsByActorWithDismiss} from '#/state/queries/suggested-follows'
20import {useGetSuggestedUsersForDiscoverQuery} from '#/state/queries/trending/useGetSuggestedUsersForDiscoverQuery'
21import {useSession} from '#/state/session'
22import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
23import {
24 atoms as a,
25 native,
26 useBreakpoints,
27 useTheme,
28 type ViewStyleProp,
29 web,
30} from '#/alf'
31import {Button, ButtonIcon, ButtonText} from '#/components/Button'
32import {useDialogControl} from '#/components/Dialog'
33import * as FeedCard from '#/components/FeedCard'
34import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow'
35import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
36import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
37import {InlineLinkText} from '#/components/Link'
38import * as ProfileCard from '#/components/ProfileCard'
39import {ProgressGuideList} from '#/components/ProgressGuide/List'
40import {Text} from '#/components/Typography'
41import {type Metrics, useAnalytics} from '#/analytics'
42import {IS_IOS} from '#/env'
43import type * as bsky from '#/types/bsky'
44import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog'
45
46const DISMISS_ANIMATION_DURATION = 200
47
48const MOBILE_CARD_WIDTH = 165
49const FINAL_CARD_WIDTH = 120
50
51function CardOuter({
52 children,
53 style,
54}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
55 const t = useTheme()
56 const {gtMobile} = useBreakpoints()
57 return (
58 <View
59 testID="CardOuter"
60 style={[
61 a.flex_1,
62 a.w_full,
63 a.p_md,
64 a.rounded_lg,
65 a.border,
66 t.atoms.bg,
67 t.atoms.shadow_sm,
68 t.atoms.border_contrast_low,
69 !gtMobile && {
70 width: MOBILE_CARD_WIDTH,
71 },
72 style,
73 ]}>
74 {children}
75 </View>
76 )
77}
78
79export function SuggestedFollowPlaceholder() {
80 return (
81 <CardOuter>
82 <ProfileCard.Outer>
83 <View
84 style={[a.flex_col, a.align_center, a.gap_sm, a.pb_sm, a.mb_auto]}>
85 <ProfileCard.AvatarPlaceholder size={88} />
86 <ProfileCard.NamePlaceholder />
87 <View style={[a.w_full]}>
88 <ProfileCard.DescriptionPlaceholder numberOfLines={2} />
89 </View>
90 </View>
91
92 <ProfileCard.FollowButtonPlaceholder />
93 </ProfileCard.Outer>
94 </CardOuter>
95 )
96}
97
98export function SuggestedFeedsCardPlaceholder() {
99 return (
100 <CardOuter style={[a.gap_sm]}>
101 <FeedCard.Header>
102 <FeedCard.AvatarPlaceholder />
103 <FeedCard.TitleAndBylinePlaceholder creator />
104 </FeedCard.Header>
105
106 <FeedCard.DescriptionPlaceholder />
107 </CardOuter>
108 )
109}
110
111export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
112 const {currentAccount} = useSession()
113 const [feedType, feedUriOrDid] = feed.split('|')
114 if (feedType === 'author') {
115 if (currentAccount?.did === feedUriOrDid) {
116 return null
117 } else {
118 return <SuggestedFollowsProfile did={feedUriOrDid} />
119 }
120 } else {
121 return <SuggestedFollowsHome />
122 }
123}
124
125export function SuggestedFollowsProfile({did}: {did: string}) {
126 const {profiles, recId, onDismiss, isLoading, error} =
127 useSuggestedFollowsByActorWithDismiss({did})
128
129 return (
130 <ProfileGrid
131 isSuggestionsLoading={isLoading}
132 profiles={profiles}
133 recId={recId}
134 error={error}
135 viewContext="profile"
136 onDismiss={onDismiss}
137 />
138 )
139}
140
141export function SuggestedFollowsHome() {
142 const {isLoading, data, error} = useGetSuggestedUsersForDiscoverQuery()
143
144 const profiles = data?.actors
145
146 const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
147
148 const onDismiss = useCallback((did: string) => {
149 setDismissedDids(prev => new Set(prev).add(did))
150 }, [])
151
152 const allProfiles = useMemo(() => {
153 const result: Array<{
154 actor: bsky.profile.AnyProfileView
155 recId?: string
156 }> = []
157
158 for (const profile of profiles ?? []) {
159 result.push({actor: profile, recId: data?.recId})
160 }
161
162 return result
163 }, [data?.recId, profiles])
164
165 const filteredProfiles = useMemo(() => {
166 return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
167 }, [allProfiles, dismissedDids])
168
169 return (
170 <ProfileGrid
171 isSuggestionsLoading={isLoading}
172 profiles={filteredProfiles}
173 totalProfileCount={allProfiles.length}
174 error={error}
175 viewContext="feed"
176 onDismiss={onDismiss}
177 />
178 )
179}
180
181export function ProfileGrid({
182 isSuggestionsLoading,
183 error,
184 profiles,
185 recId,
186 totalProfileCount,
187 viewContext = 'feed',
188 onDismiss,
189 isVisible = true,
190 onRequestHide,
191}: {
192 isSuggestionsLoading: boolean
193 profiles: {actor: bsky.profile.AnyProfileView; recId?: string}[]
194 recId?: string
195 totalProfileCount?: number
196 error: Error | null
197 viewContext: 'profile' | 'profileHeader' | 'feed'
198 onDismiss?: (did: string) => void
199 isVisible?: boolean
200 onRequestHide?: () => void
201}) {
202 const t = useTheme()
203 const ax = useAnalytics()
204 const {t: l} = useLingui()
205 const moderationOpts = useModerationOpts()
206 const {gtMobile} = useBreakpoints()
207 const followDialogControl = useDialogControl()
208
209 const isLoading = isSuggestionsLoading || !moderationOpts
210 const isProfileHeaderContext = viewContext === 'profileHeader'
211 const isFeedContext = viewContext === 'feed'
212
213 const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6
214 const minLength = gtMobile ? 3 : 4
215
216 // hide similar accounts
217 const hideSimilarAccountsRecomm = useHideSimilarAccountsRecomm()
218
219 // Track seen profiles
220 const seenProfilesRef = useRef<Set<string>>(new Set())
221 const containerRef = useRef<View>(null)
222 const hasTrackedRef = useRef(false)
223 const logContext: Metrics['suggestedUser:seen']['logContext'] = isFeedContext
224 ? 'DiscoverInterstitial'
225 : isProfileHeaderContext
226 ? 'ProfileHeader'
227 : 'ProfileInterstitial'
228
229 // Callback to fire seen events
230 const fireSeen = useCallback(() => {
231 if (isLoading || error || !profiles.length) return
232 if (hasTrackedRef.current) return
233 hasTrackedRef.current = true
234
235 const profilesToShow = profiles.slice(0, maxLength)
236 profilesToShow.forEach((profile, index) => {
237 if (!seenProfilesRef.current.has(profile.actor.did)) {
238 seenProfilesRef.current.add(profile.actor.did)
239 ax.metric('suggestedUser:seen', {
240 logContext,
241 recId: profile.recId,
242 position: index,
243 suggestedDid: profile.actor.did,
244 category: null,
245 })
246 }
247 })
248 }, [isLoading, error, profiles, maxLength, ax, logContext])
249
250 // For profile header, fire when isVisible becomes true
251 useEffect(() => {
252 if (isProfileHeaderContext) {
253 if (!isVisible) {
254 hasTrackedRef.current = false
255 return
256 }
257 fireSeen()
258 }
259 }, [isVisible, isProfileHeaderContext, fireSeen])
260
261 // For feed interstitials, use IntersectionObserver to detect actual visibility
262 useEffect(() => {
263 if (isProfileHeaderContext) return // handled above
264 if (isLoading || error || !profiles.length) return
265
266 const node = containerRef.current
267 if (!node) return
268
269 // Use IntersectionObserver on web to detect when actually visible
270 if (typeof IntersectionObserver !== 'undefined') {
271 const observer = new IntersectionObserver(
272 entries => {
273 if (entries[0]?.isIntersecting) {
274 fireSeen()
275 observer.disconnect()
276 }
277 },
278 {threshold: 0.5},
279 )
280 // @ts-ignore - web only
281 observer.observe(node)
282 return () => observer.disconnect()
283 } else {
284 // On native, delay slightly to account for layout shifts during hydration
285 const timeout = setTimeout(() => {
286 fireSeen()
287 }, 500)
288 return () => clearTimeout(timeout)
289 }
290 }, [isProfileHeaderContext, isLoading, error, profiles.length, fireSeen])
291
292 const content = isLoading
293 ? Array(maxLength)
294 .fill(0)
295 .map((_, i) => (
296 <View
297 key={i}
298 style={[
299 a.flex_1,
300 gtMobile &&
301 web([
302 a.flex_0,
303 a.flex_grow,
304 {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
305 ]),
306 ]}>
307 <SuggestedFollowPlaceholder />
308 </View>
309 ))
310 : error || !profiles.length
311 ? null
312 : profiles.slice(0, maxLength).map((profile, index) => (
313 <Animated.View
314 key={profile.actor.did}
315 layout={native(
316 LinearTransition.delay(DISMISS_ANIMATION_DURATION).easing(
317 Easing.out(Easing.exp),
318 ),
319 )}
320 exiting={FadeOut.duration(DISMISS_ANIMATION_DURATION)}
321 // for web, as the cards are static, not in a list
322 entering={web(FadeIn.delay(DISMISS_ANIMATION_DURATION * 2))}
323 style={[
324 a.flex_1,
325 gtMobile &&
326 web([
327 a.flex_0,
328 a.flex_grow,
329 {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
330 ]),
331 ]}>
332 <ProfileCard.Link
333 profile={profile.actor}
334 onPress={() => {
335 ax.metric('suggestedUser:press', {
336 logContext,
337 recId: profile.recId,
338 position: index,
339 suggestedDid: profile.actor.did,
340 category: null,
341 })
342 }}
343 style={[a.flex_1]}>
344 {({hovered, pressed}) => (
345 <CardOuter
346 style={[
347 (hovered || pressed) && t.atoms.border_contrast_high,
348 ]}>
349 <ProfileCard.Outer>
350 {onDismiss && (
351 <Button
352 label={l`Dismiss this suggestion`}
353 onPress={e => {
354 e.preventDefault()
355 onDismiss(profile.actor.did)
356 ax.metric('suggestedUser:dismiss', {
357 logContext,
358 position: index,
359 suggestedDid: profile.actor.did,
360 recId: profile.recId,
361 })
362 }}
363 style={[
364 a.absolute,
365 a.z_10,
366 a.p_xs,
367 {top: -4, right: -4},
368 ]}>
369 {({
370 hovered: dismissHovered,
371 pressed: dismissPressed,
372 }) => (
373 <X
374 size="xs"
375 fill={
376 dismissHovered || dismissPressed
377 ? t.atoms.text.color
378 : t.atoms.text_contrast_medium.color
379 }
380 />
381 )}
382 </Button>
383 )}
384 <View
385 style={[
386 a.flex_col,
387 a.align_center,
388 a.gap_sm,
389 a.pb_sm,
390 a.mb_auto,
391 ]}>
392 <ProfileCard.Avatar
393 profile={profile.actor}
394 moderationOpts={moderationOpts}
395 disabledPreview
396 size={88}
397 />
398 <View style={[a.flex_col, a.align_center, a.max_w_full]}>
399 <ProfileCard.Name
400 profile={profile.actor}
401 moderationOpts={moderationOpts}
402 />
403 <ProfileCard.Description
404 profile={profile.actor}
405 numberOfLines={2}
406 style={[
407 t.atoms.text_contrast_medium,
408 a.text_center,
409 a.text_xs,
410 ]}
411 />
412 </View>
413 </View>
414
415 <ProfileCard.FollowButton
416 profile={profile.actor}
417 moderationOpts={moderationOpts}
418 logContext="FeedInterstitial"
419 withIcon={false}
420 style={[a.rounded_sm]}
421 onFollow={() => {
422 ax.metric('suggestedUser:follow', {
423 logContext,
424 location: 'Profile',
425 recId: profile.recId,
426 position: index,
427 suggestedDid: profile.actor.did,
428 category: null,
429 })
430 }}
431 />
432 </ProfileCard.Outer>
433 </CardOuter>
434 )}
435 </ProfileCard.Link>
436 </Animated.View>
437 ))
438
439 // Use totalProfileCount (before dismissals) for minLength check on initial render.
440 const profileCountForMinCheck = totalProfileCount ?? profiles.length
441
442 useEffect(() => {
443 if (error || (!isLoading && profileCountForMinCheck < minLength)) {
444 onRequestHide?.()
445 }
446 }, [error, isLoading, onRequestHide, profileCountForMinCheck, minLength])
447
448 if (error || (!isLoading && profileCountForMinCheck < minLength)) {
449 ax.logger.debug(`Not enough profiles to show suggested follows`)
450 return null
451 }
452
453 if (!hideSimilarAccountsRecomm) {
454 return (
455 <View
456 ref={containerRef}
457 style={[
458 !isProfileHeaderContext && a.border_t,
459 t.atoms.border_contrast_low,
460 t.atoms.bg_contrast_25,
461 ]}
462 pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
463 <View
464 style={[
465 a.px_lg,
466 a.pt_md,
467 a.flex_row,
468 a.align_center,
469 a.justify_between,
470 ]}
471 pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
472 <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text]}>
473 {isFeedContext ? (
474 <Trans>Suggested for you</Trans>
475 ) : (
476 <Trans>Similar accounts</Trans>
477 )}
478 </Text>
479 <Button
480 label={l`See more suggested profiles`}
481 onPress={() => {
482 followDialogControl.open()
483 ax.metric('suggestedUser:seeMore', {
484 logContext,
485 recId,
486 })
487 }}>
488 {({hovered}) => (
489 <Text
490 style={[
491 a.text_sm,
492 {color: t.palette.primary_500},
493 hovered &&
494 web({
495 textDecorationLine: 'underline',
496 textDecorationColor: t.palette.primary_500,
497 }),
498 ]}>
499 <Trans>See more</Trans>
500 </Text>
501 )}
502 </Button>
503 </View>
504 <FollowDialogWithoutGuide control={followDialogControl} />
505 <LayoutAnimationConfig skipExiting skipEntering>
506 {gtMobile ? (
507 <View style={[a.p_lg, a.pt_md]}>
508 <View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
509 {content}
510 </View>
511 </View>
512 ) : (
513 <BlockDrawerGesture>
514 <ScrollView
515 horizontal
516 showsHorizontalScrollIndicator={false}
517 contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
518 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
519 decelerationRate="fast">
520 {content}
521
522 <SeeMoreSuggestedProfilesCard
523 onPress={() => {
524 followDialogControl.open()
525 ax.metric('suggestedUser:seeMore', {
526 logContext,
527 })
528 }}
529 />
530 </ScrollView>
531 </BlockDrawerGesture>
532 )}
533 </LayoutAnimationConfig>
534 </View>
535 )
536 }
537}
538
539function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
540 const {t: l} = useLingui()
541
542 return (
543 <Button
544 label={l`Browse more accounts`}
545 onPress={onPress}
546 style={[
547 a.flex_col,
548 a.align_center,
549 a.justify_center,
550 a.gap_sm,
551 a.p_md,
552 a.rounded_lg,
553 {width: FINAL_CARD_WIDTH},
554 ]}>
555 <ButtonIcon icon={ArrowRight} size="lg" />
556 <ButtonText
557 style={[a.text_md, a.font_medium, a.leading_snug, a.text_center]}>
558 <Trans>See more</Trans>
559 </ButtonText>
560 </Button>
561 )
562}
563
564const numFeedsToDisplay = 3
565export function SuggestedFeeds() {
566 const t = useTheme()
567 const ax = useAnalytics()
568 const {t: l} = useLingui()
569 const {data, isLoading, error} = useGetPopularFeedsQuery({
570 limit: numFeedsToDisplay,
571 })
572 const navigation = useNavigation<NavigationProp>()
573 const {gtMobile} = useBreakpoints()
574
575 const feeds = useMemo(() => {
576 const items: AppBskyFeedDefs.GeneratorView[] = []
577
578 if (!data) return items
579
580 for (const page of data.pages) {
581 for (const feed of page.feeds) {
582 items.push(feed)
583 }
584 }
585
586 return items
587 }, [data])
588
589 const content = isLoading ? (
590 Array(numFeedsToDisplay)
591 .fill(0)
592 .map((_, i) => <SuggestedFeedsCardPlaceholder key={i} />)
593 ) : error || !feeds ? null : (
594 <>
595 {feeds.slice(0, numFeedsToDisplay).map(feed => (
596 <FeedCard.Link
597 key={feed.uri}
598 view={feed}
599 onPress={() => {
600 ax.metric('feed:interstitial:feedCard:press', {})
601 }}>
602 {({hovered, pressed}) => (
603 <CardOuter
604 style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
605 <FeedCard.Outer>
606 <FeedCard.Header>
607 <FeedCard.Avatar src={feed.avatar} />
608 <FeedCard.TitleAndByline
609 title={feed.displayName}
610 creator={feed.creator}
611 uri={feed.uri}
612 />
613 </FeedCard.Header>
614 <FeedCard.Description
615 description={feed.description}
616 numberOfLines={3}
617 />
618 </FeedCard.Outer>
619 </CardOuter>
620 )}
621 </FeedCard.Link>
622 ))}
623 </>
624 )
625
626 return error ? null : (
627 <View
628 style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
629 <View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
630 <Text
631 style={[
632 a.flex_1,
633 a.text_lg,
634 a.font_semi_bold,
635 t.atoms.text_contrast_medium,
636 ]}>
637 <Trans>Some other feeds you might like</Trans>
638 </Text>
639 <Hashtag fill={t.atoms.text_contrast_low.color} />
640 </View>
641
642 {gtMobile ? (
643 <View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
644 {content}
645
646 <View
647 style={[
648 a.flex_row,
649 a.justify_end,
650 a.align_center,
651 a.pt_xs,
652 a.gap_md,
653 ]}>
654 <InlineLinkText
655 label={l`Browse more suggestions`}
656 to="/search"
657 style={[t.atoms.text_contrast_medium]}>
658 <Trans>Browse more suggestions</Trans>
659 </InlineLinkText>
660 <ArrowRight size="sm" fill={t.atoms.text_contrast_medium.color} />
661 </View>
662 </View>
663 ) : (
664 <BlockDrawerGesture>
665 <ScrollView
666 horizontal
667 showsHorizontalScrollIndicator={false}
668 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
669 decelerationRate="fast">
670 <View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
671 {content}
672
673 <Button
674 label={l`Browse more feeds on the Explore page`}
675 onPress={() => {
676 navigation.navigate('SearchTab')
677 }}
678 style={[a.flex_col]}>
679 <CardOuter>
680 <View style={[a.flex_1, a.justify_center]}>
681 <View style={[a.flex_row, a.px_lg]}>
682 <Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
683 <Trans>
684 Browse more suggestions on the Explore page
685 </Trans>
686 </Text>
687
688 <ArrowRight size="xl" />
689 </View>
690 </View>
691 </CardOuter>
692 </Button>
693 </View>
694 </ScrollView>
695 </BlockDrawerGesture>
696 )}
697 </View>
698 )
699}
700
701export function ProgressGuide() {
702 const t = useTheme()
703 const {gtMobile} = useBreakpoints()
704 return (
705 <View
706 style={[
707 t.atoms.border_contrast_low,
708 a.px_lg,
709 a.py_lg,
710 !gtMobile && {marginTop: 4},
711 ]}>
712 <ProgressGuideList />
713 </View>
714 )
715}