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 recId={data?.recId}
172 isSuggestionsLoading={isLoading}
173 profiles={filteredProfiles}
174 totalProfileCount={allProfiles.length}
175 error={error}
176 viewContext="feed"
177 onDismiss={onDismiss}
178 />
179 )
180}
181
182export function ProfileGrid({
183 isSuggestionsLoading,
184 error,
185 profiles,
186 recId,
187 totalProfileCount,
188 viewContext = 'feed',
189 onDismiss,
190 isVisible = true,
191 onRequestHide,
192}: {
193 isSuggestionsLoading: boolean
194 profiles: {actor: bsky.profile.AnyProfileView; recId?: string}[]
195 recId?: string
196 totalProfileCount?: number
197 error: Error | null
198 viewContext: 'profile' | 'profileHeader' | 'feed'
199 onDismiss?: (did: string) => void
200 isVisible?: boolean
201 onRequestHide?: () => void
202}) {
203 const t = useTheme()
204 const ax = useAnalytics()
205 const {t: l} = useLingui()
206 const moderationOpts = useModerationOpts()
207 const {gtMobile} = useBreakpoints()
208 const followDialogControl = useDialogControl()
209
210 const isLoading = isSuggestionsLoading || !moderationOpts
211 const isProfileHeaderContext = viewContext === 'profileHeader'
212 const isFeedContext = viewContext === 'feed'
213
214 const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6
215 const minLength = gtMobile ? 3 : 4
216
217 // hide similar accounts
218 const hideSimilarAccountsRecomm = useHideSimilarAccountsRecomm()
219
220 // Track seen profiles
221 const seenProfilesRef = useRef<Set<string>>(new Set())
222 const containerRef = useRef<View>(null)
223 const hasTrackedRef = useRef(false)
224 const logContext: Metrics['suggestedUser:seen']['logContext'] = isFeedContext
225 ? 'DiscoverInterstitial'
226 : isProfileHeaderContext
227 ? 'ProfileHeader'
228 : 'ProfileInterstitial'
229
230 // Callback to fire seen events
231 const fireSeen = useCallback(() => {
232 if (isLoading || error || !profiles.length) return
233 if (hasTrackedRef.current) return
234 hasTrackedRef.current = true
235
236 const profilesToShow = profiles.slice(0, maxLength)
237 profilesToShow.forEach((profile, index) => {
238 if (!seenProfilesRef.current.has(profile.actor.did)) {
239 seenProfilesRef.current.add(profile.actor.did)
240 ax.metric('suggestedUser:seen', {
241 logContext,
242 recId: profile.recId,
243 position: index,
244 suggestedDid: profile.actor.did,
245 category: null,
246 })
247 }
248 })
249 }, [isLoading, error, profiles, maxLength, ax, logContext])
250
251 // For profile header, fire when isVisible becomes true
252 useEffect(() => {
253 if (isProfileHeaderContext) {
254 if (!isVisible) {
255 hasTrackedRef.current = false
256 return
257 }
258 fireSeen()
259 }
260 }, [isVisible, isProfileHeaderContext, fireSeen])
261
262 // For feed interstitials, use IntersectionObserver to detect actual visibility
263 useEffect(() => {
264 if (isProfileHeaderContext) return // handled above
265 if (isLoading || error || !profiles.length) return
266
267 const node = containerRef.current
268 if (!node) return
269
270 // Use IntersectionObserver on web to detect when actually visible
271 if (typeof IntersectionObserver !== 'undefined') {
272 const observer = new IntersectionObserver(
273 entries => {
274 if (entries[0]?.isIntersecting) {
275 fireSeen()
276 observer.disconnect()
277 }
278 },
279 {threshold: 0.5},
280 )
281 // @ts-ignore - web only
282 observer.observe(node)
283 return () => observer.disconnect()
284 } else {
285 // On native, delay slightly to account for layout shifts during hydration
286 const timeout = setTimeout(() => {
287 fireSeen()
288 }, 500)
289 return () => clearTimeout(timeout)
290 }
291 }, [isProfileHeaderContext, isLoading, error, profiles.length, fireSeen])
292
293 const content = isLoading
294 ? Array(maxLength)
295 .fill(0)
296 .map((_, i) => (
297 <View
298 key={i}
299 style={[
300 a.flex_1,
301 gtMobile &&
302 web([
303 a.flex_0,
304 a.flex_grow,
305 {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
306 ]),
307 ]}>
308 <SuggestedFollowPlaceholder />
309 </View>
310 ))
311 : error || !profiles.length
312 ? null
313 : profiles.slice(0, maxLength).map((profile, index) => (
314 <Animated.View
315 key={profile.actor.did}
316 layout={native(
317 LinearTransition.delay(DISMISS_ANIMATION_DURATION).easing(
318 Easing.out(Easing.exp),
319 ),
320 )}
321 exiting={FadeOut.duration(DISMISS_ANIMATION_DURATION)}
322 // for web, as the cards are static, not in a list
323 entering={web(FadeIn.delay(DISMISS_ANIMATION_DURATION * 2))}
324 style={[
325 a.flex_1,
326 gtMobile &&
327 web([
328 a.flex_0,
329 a.flex_grow,
330 {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
331 ]),
332 ]}>
333 <ProfileCard.Link
334 profile={profile.actor}
335 onPress={() => {
336 ax.metric('suggestedUser:press', {
337 logContext,
338 recId: profile.recId,
339 position: index,
340 suggestedDid: profile.actor.did,
341 category: null,
342 })
343 }}
344 style={[a.flex_1]}>
345 {({hovered, pressed}) => (
346 <CardOuter
347 style={[
348 (hovered || pressed) && t.atoms.border_contrast_high,
349 ]}>
350 <ProfileCard.Outer>
351 {onDismiss && (
352 <Button
353 label={l`Dismiss this suggestion`}
354 onPress={e => {
355 e.preventDefault()
356 onDismiss(profile.actor.did)
357 ax.metric('suggestedUser:dismiss', {
358 logContext,
359 position: index,
360 suggestedDid: profile.actor.did,
361 recId: profile.recId,
362 })
363 }}
364 style={[
365 a.absolute,
366 a.z_10,
367 a.p_xs,
368 {top: -4, right: -4},
369 ]}>
370 {({
371 hovered: dismissHovered,
372 pressed: dismissPressed,
373 }) => (
374 <X
375 size="xs"
376 fill={
377 dismissHovered || dismissPressed
378 ? t.atoms.text.color
379 : t.atoms.text_contrast_medium.color
380 }
381 />
382 )}
383 </Button>
384 )}
385 <View
386 style={[
387 a.flex_col,
388 a.align_center,
389 a.gap_sm,
390 a.pb_sm,
391 a.mb_auto,
392 ]}>
393 <ProfileCard.Avatar
394 profile={profile.actor}
395 moderationOpts={moderationOpts}
396 disabledPreview
397 size={88}
398 />
399 <View style={[a.flex_col, a.align_center, a.max_w_full]}>
400 <ProfileCard.Name
401 profile={profile.actor}
402 moderationOpts={moderationOpts}
403 />
404 <ProfileCard.Description
405 profile={profile.actor}
406 numberOfLines={2}
407 style={[
408 t.atoms.text_contrast_medium,
409 a.text_center,
410 a.text_xs,
411 ]}
412 />
413 </View>
414 </View>
415
416 <ProfileCard.FollowButton
417 profile={profile.actor}
418 moderationOpts={moderationOpts}
419 logContext="FeedInterstitial"
420 withIcon={false}
421 style={[a.rounded_sm]}
422 onFollow={() => {
423 ax.metric('suggestedUser:follow', {
424 logContext,
425 location: 'Profile',
426 recId: profile.recId,
427 position: index,
428 suggestedDid: profile.actor.did,
429 category: null,
430 })
431 }}
432 />
433 </ProfileCard.Outer>
434 </CardOuter>
435 )}
436 </ProfileCard.Link>
437 </Animated.View>
438 ))
439
440 // Use totalProfileCount (before dismissals) for minLength check on initial render.
441 const profileCountForMinCheck = totalProfileCount ?? profiles.length
442
443 useEffect(() => {
444 if (error || (!isLoading && profileCountForMinCheck < minLength)) {
445 onRequestHide?.()
446 }
447 }, [error, isLoading, onRequestHide, profileCountForMinCheck, minLength])
448
449 if (error || (!isLoading && profileCountForMinCheck < minLength)) {
450 ax.logger.debug(`Not enough profiles to show suggested follows`)
451 return null
452 }
453
454 if (!hideSimilarAccountsRecomm) {
455 return (
456 <View
457 ref={containerRef}
458 style={[
459 !isProfileHeaderContext && a.border_t,
460 t.atoms.border_contrast_low,
461 t.atoms.bg_contrast_25,
462 ]}
463 pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
464 <View
465 style={[
466 a.px_lg,
467 a.pt_md,
468 a.flex_row,
469 a.align_center,
470 a.justify_between,
471 ]}
472 pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
473 <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text]}>
474 {isFeedContext ? (
475 <Trans>Suggested for you</Trans>
476 ) : (
477 <Trans>Similar accounts</Trans>
478 )}
479 </Text>
480 <Button
481 label={l`See more suggested profiles`}
482 onPress={() => {
483 followDialogControl.open()
484 ax.metric('suggestedUser:seeMore', {
485 logContext,
486 recId,
487 })
488 }}>
489 {({hovered}) => (
490 <Text
491 style={[
492 a.text_sm,
493 {color: t.palette.primary_500},
494 hovered &&
495 web({
496 textDecorationLine: 'underline',
497 textDecorationColor: t.palette.primary_500,
498 }),
499 ]}>
500 <Trans>See more</Trans>
501 </Text>
502 )}
503 </Button>
504 </View>
505 <FollowDialogWithoutGuide control={followDialogControl} />
506 <LayoutAnimationConfig skipExiting skipEntering>
507 {gtMobile ? (
508 <View style={[a.p_lg, a.pt_md]}>
509 <View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
510 {content}
511 </View>
512 </View>
513 ) : (
514 <BlockDrawerGesture>
515 <ScrollView
516 horizontal
517 showsHorizontalScrollIndicator={false}
518 contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
519 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
520 decelerationRate="fast">
521 {content}
522
523 <SeeMoreSuggestedProfilesCard
524 onPress={() => {
525 followDialogControl.open()
526 ax.metric('suggestedUser:seeMore', {
527 logContext,
528 })
529 }}
530 />
531 </ScrollView>
532 </BlockDrawerGesture>
533 )}
534 </LayoutAnimationConfig>
535 </View>
536 )
537 }
538}
539
540function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
541 const {t: l} = useLingui()
542
543 return (
544 <Button
545 label={l`Browse more accounts`}
546 onPress={onPress}
547 style={[
548 a.flex_col,
549 a.align_center,
550 a.justify_center,
551 a.gap_sm,
552 a.p_md,
553 a.rounded_lg,
554 {width: FINAL_CARD_WIDTH},
555 ]}>
556 <ButtonIcon icon={ArrowRight} size="lg" />
557 <ButtonText
558 style={[a.text_md, a.font_medium, a.leading_snug, a.text_center]}>
559 <Trans>See more</Trans>
560 </ButtonText>
561 </Button>
562 )
563}
564
565const numFeedsToDisplay = 3
566export function SuggestedFeeds() {
567 const t = useTheme()
568 const ax = useAnalytics()
569 const {t: l} = useLingui()
570 const {data, isLoading, error} = useGetPopularFeedsQuery({
571 limit: numFeedsToDisplay,
572 })
573 const navigation = useNavigation<NavigationProp>()
574 const {gtMobile} = useBreakpoints()
575
576 const feeds = useMemo(() => {
577 const items: AppBskyFeedDefs.GeneratorView[] = []
578
579 if (!data) return items
580
581 for (const page of data.pages) {
582 for (const feed of page.feeds) {
583 items.push(feed)
584 }
585 }
586
587 return items
588 }, [data])
589
590 const content = isLoading ? (
591 Array(numFeedsToDisplay)
592 .fill(0)
593 .map((_, i) => <SuggestedFeedsCardPlaceholder key={i} />)
594 ) : error || !feeds ? null : (
595 <>
596 {feeds.slice(0, numFeedsToDisplay).map(feed => (
597 <FeedCard.Link
598 key={feed.uri}
599 view={feed}
600 onPress={() => {
601 ax.metric('feed:interstitial:feedCard:press', {})
602 }}>
603 {({hovered, pressed}) => (
604 <CardOuter
605 style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
606 <FeedCard.Outer>
607 <FeedCard.Header>
608 <FeedCard.Avatar src={feed.avatar} />
609 <FeedCard.TitleAndByline
610 title={feed.displayName}
611 creator={feed.creator}
612 uri={feed.uri}
613 />
614 </FeedCard.Header>
615 <FeedCard.Description
616 description={feed.description}
617 numberOfLines={3}
618 />
619 </FeedCard.Outer>
620 </CardOuter>
621 )}
622 </FeedCard.Link>
623 ))}
624 </>
625 )
626
627 return error ? null : (
628 <View
629 style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
630 <View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
631 <Text
632 style={[
633 a.flex_1,
634 a.text_lg,
635 a.font_semi_bold,
636 t.atoms.text_contrast_medium,
637 ]}>
638 <Trans>Some other feeds you might like</Trans>
639 </Text>
640 <Hashtag fill={t.atoms.text_contrast_low.color} />
641 </View>
642
643 {gtMobile ? (
644 <View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
645 {content}
646
647 <View
648 style={[
649 a.flex_row,
650 a.justify_end,
651 a.align_center,
652 a.pt_xs,
653 a.gap_md,
654 ]}>
655 <InlineLinkText
656 label={l`Browse more suggestions`}
657 to="/search"
658 style={[t.atoms.text_contrast_medium]}>
659 <Trans>Browse more suggestions</Trans>
660 </InlineLinkText>
661 <ArrowRight size="sm" fill={t.atoms.text_contrast_medium.color} />
662 </View>
663 </View>
664 ) : (
665 <BlockDrawerGesture>
666 <ScrollView
667 horizontal
668 showsHorizontalScrollIndicator={false}
669 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
670 decelerationRate="fast">
671 <View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
672 {content}
673
674 <Button
675 label={l`Browse more feeds on the Explore page`}
676 onPress={() => {
677 navigation.navigate('SearchTab')
678 }}
679 style={[a.flex_col]}>
680 <CardOuter>
681 <View style={[a.flex_1, a.justify_center]}>
682 <View style={[a.flex_row, a.px_lg]}>
683 <Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
684 <Trans>
685 Browse more suggestions on the Explore page
686 </Trans>
687 </Text>
688
689 <ArrowRight size="xl" />
690 </View>
691 </View>
692 </CardOuter>
693 </Button>
694 </View>
695 </ScrollView>
696 </BlockDrawerGesture>
697 )}
698 </View>
699 )
700}
701
702export function ProgressGuide() {
703 const t = useTheme()
704 const {gtMobile} = useBreakpoints()
705 return (
706 <View
707 style={[
708 t.atoms.border_contrast_low,
709 a.px_lg,
710 a.py_lg,
711 !gtMobile && {marginTop: 4},
712 ]}>
713 <ProgressGuideList />
714 </View>
715 )
716}