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, AtUri} from '@atproto/api'
11import {msg, Trans} from '@lingui/macro'
12import {useLingui} from '@lingui/react'
13import {useNavigation} from '@react-navigation/native'
14
15import {type NavigationProp} from '#/lib/routes/types'
16import {useHideSimilarAccountsRecomm} from '#/state/preferences/hide-similar-accounts-recommendations'
17import {useModerationOpts} from '#/state/preferences/moderation-opts'
18import {useGetPopularFeedsQuery} from '#/state/queries/feed'
19import {type FeedDescriptor} from '#/state/queries/post-feed'
20import {useProfilesQuery} from '#/state/queries/profile'
21import {
22 useSuggestedFollowsByActorQuery,
23 useSuggestedFollowsQuery,
24} from '#/state/queries/suggested-follows'
25import {useSession} from '#/state/session'
26import * as userActionHistory from '#/state/userActionHistory'
27import {type SeenPost} from '#/state/userActionHistory'
28import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
29import {
30 atoms as a,
31 native,
32 useBreakpoints,
33 useTheme,
34 type ViewStyleProp,
35 web,
36} from '#/alf'
37import {Button, ButtonIcon, ButtonText} from '#/components/Button'
38import {useDialogControl} from '#/components/Dialog'
39import * as FeedCard from '#/components/FeedCard'
40import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow'
41import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
42import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
43import {InlineLinkText} from '#/components/Link'
44import * as ProfileCard from '#/components/ProfileCard'
45import {Text} from '#/components/Typography'
46import {type Metrics, useAnalytics} from '#/analytics'
47import {IS_IOS} from '#/env'
48import type * as bsky from '#/types/bsky'
49import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog'
50import {ProgressGuideList} from './ProgressGuide/List'
51
52const DISMISS_ANIMATION_DURATION = 200
53
54const MOBILE_CARD_WIDTH = 165
55const FINAL_CARD_WIDTH = 120
56
57function CardOuter({
58 children,
59 style,
60}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
61 const t = useTheme()
62 const {gtMobile} = useBreakpoints()
63 return (
64 <View
65 testID="CardOuter"
66 style={[
67 a.flex_1,
68 a.w_full,
69 a.p_md,
70 a.rounded_lg,
71 a.border,
72 t.atoms.bg,
73 t.atoms.shadow_sm,
74 t.atoms.border_contrast_low,
75 !gtMobile && {
76 width: MOBILE_CARD_WIDTH,
77 },
78 style,
79 ]}>
80 {children}
81 </View>
82 )
83}
84
85export function SuggestedFollowPlaceholder() {
86 return (
87 <CardOuter>
88 <ProfileCard.Outer>
89 <View
90 style={[a.flex_col, a.align_center, a.gap_sm, a.pb_sm, a.mb_auto]}>
91 <ProfileCard.AvatarPlaceholder size={88} />
92 <ProfileCard.NamePlaceholder />
93 <View style={[a.w_full]}>
94 <ProfileCard.DescriptionPlaceholder numberOfLines={2} />
95 </View>
96 </View>
97
98 <ProfileCard.FollowButtonPlaceholder />
99 </ProfileCard.Outer>
100 </CardOuter>
101 )
102}
103
104export function SuggestedFeedsCardPlaceholder() {
105 return (
106 <CardOuter style={[a.gap_sm]}>
107 <FeedCard.Header>
108 <FeedCard.AvatarPlaceholder />
109 <FeedCard.TitleAndBylinePlaceholder creator />
110 </FeedCard.Header>
111
112 <FeedCard.DescriptionPlaceholder />
113 </CardOuter>
114 )
115}
116
117function getRank(seenPost: SeenPost): string {
118 let tier: string
119 if (seenPost.feedContext === 'popfriends') {
120 tier = 'a'
121 } else if (seenPost.feedContext?.startsWith('cluster')) {
122 tier = 'b'
123 } else if (seenPost.feedContext === 'popcluster') {
124 tier = 'c'
125 } else if (seenPost.feedContext?.startsWith('ntpc')) {
126 tier = 'd'
127 } else if (seenPost.feedContext?.startsWith('t-')) {
128 tier = 'e'
129 } else if (seenPost.feedContext === 'nettop') {
130 tier = 'f'
131 } else {
132 tier = 'g'
133 }
134 let score = Math.round(
135 Math.log(
136 1 + seenPost.likeCount + seenPost.repostCount + seenPost.replyCount,
137 ),
138 )
139 if (seenPost.isFollowedBy || Math.random() > 0.9) {
140 score *= 2
141 }
142 const rank = 100 - score
143 return `${tier}-${rank}`
144}
145
146function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
147 const rankA = getRank(postA)
148 const rankB = getRank(postB)
149 // Yes, we're comparing strings here.
150 // The "larger" string means a worse rank.
151 if (rankA > rankB) {
152 return 1
153 } else if (rankA < rankB) {
154 return -1
155 } else {
156 return 0
157 }
158}
159
160function useExperimentalSuggestedUsersQuery() {
161 const {currentAccount} = useSession()
162 const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
163 const dids = useMemo(() => {
164 const {likes, follows, followSuggestions, seen} = userActionSnapshot
165 const likeDids = likes
166 .map(l => new AtUri(l))
167 .map(uri => uri.host)
168 .filter(did => !follows.includes(did))
169 let suggestedDids: string[] = []
170 if (followSuggestions.length > 0) {
171 suggestedDids = [
172 // It's ok if these will pick the same item (weighed by its frequency)
173 followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
174 followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
175 followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
176 followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
177 ]
178 }
179 const seenDids = seen
180 .sort(sortSeenPosts)
181 .map(l => new AtUri(l.uri))
182 .map(uri => uri.host)
183 return [...new Set([...suggestedDids, ...likeDids, ...seenDids])].filter(
184 did => did !== currentAccount?.did,
185 )
186 }, [userActionSnapshot, currentAccount])
187 const {data, isLoading, error} = useProfilesQuery({
188 handles: dids.slice(0, 16),
189 })
190
191 const profiles = data
192 ? data.profiles.filter(profile => {
193 return !profile.viewer?.following
194 })
195 : []
196
197 return {
198 isLoading,
199 error,
200 profiles: profiles.slice(0, 6),
201 }
202}
203
204export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
205 const {currentAccount} = useSession()
206 const [feedType, feedUriOrDid] = feed.split('|')
207 if (feedType === 'author') {
208 if (currentAccount?.did === feedUriOrDid) {
209 return null
210 } else {
211 return <SuggestedFollowsProfile did={feedUriOrDid} />
212 }
213 } else {
214 return <SuggestedFollowsHome />
215 }
216}
217
218export function SuggestedFollowsProfile({did}: {did: string}) {
219 const {gtMobile} = useBreakpoints()
220 const moderationOpts = useModerationOpts()
221 const maxLength = gtMobile ? 4 : 6
222 const {
223 isLoading: isSuggestionsLoading,
224 data,
225 error,
226 } = useSuggestedFollowsByActorQuery({
227 did,
228 })
229 const {
230 data: moreSuggestions,
231 fetchNextPage,
232 hasNextPage,
233 isFetchingNextPage,
234 } = useSuggestedFollowsQuery({limit: 25})
235
236 const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
237
238 const onDismiss = useCallback((dismissedDid: string) => {
239 setDismissedDids(prev => new Set(prev).add(dismissedDid))
240 }, [])
241
242 // Combine profiles from the actor-specific query with fallback suggestions
243 const allProfiles = useMemo(() => {
244 const actorProfiles = data?.suggestions ?? []
245 const fallbackProfiles =
246 moreSuggestions?.pages.flatMap(page =>
247 page.actors.map(actor => ({actor, recId: page.recId})),
248 ) ?? []
249
250 // Dedupe by did, preferring actor-specific profiles
251 const seen = new Set<string>()
252 const combined: {actor: bsky.profile.AnyProfileView; recId?: number}[] = []
253
254 for (const profile of actorProfiles) {
255 if (!seen.has(profile.did)) {
256 seen.add(profile.did)
257 combined.push({actor: profile, recId: data?.recId})
258 }
259 }
260
261 for (const profile of fallbackProfiles) {
262 if (!seen.has(profile.actor.did) && profile.actor.did !== did) {
263 seen.add(profile.actor.did)
264 combined.push(profile)
265 }
266 }
267
268 return combined
269 }, [data?.suggestions, moreSuggestions?.pages, did, data?.recId])
270
271 const filteredProfiles = useMemo(() => {
272 return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
273 }, [allProfiles, dismissedDids])
274
275 // Fetch more when running low
276 useEffect(() => {
277 if (
278 moderationOpts &&
279 filteredProfiles.length < maxLength &&
280 hasNextPage &&
281 !isFetchingNextPage
282 ) {
283 void fetchNextPage()
284 }
285 }, [
286 filteredProfiles.length,
287 maxLength,
288 hasNextPage,
289 isFetchingNextPage,
290 fetchNextPage,
291 moderationOpts,
292 ])
293
294 return (
295 <ProfileGrid
296 isSuggestionsLoading={isSuggestionsLoading}
297 profiles={filteredProfiles}
298 totalProfileCount={allProfiles.length}
299 error={error}
300 viewContext="profile"
301 onDismiss={onDismiss}
302 />
303 )
304}
305
306export function SuggestedFollowsHome() {
307 const {gtMobile} = useBreakpoints()
308 const moderationOpts = useModerationOpts()
309 const maxLength = gtMobile ? 4 : 6
310 const {
311 isLoading: isSuggestionsLoading,
312 profiles: experimentalProfiles,
313 error: experimentalError,
314 } = useExperimentalSuggestedUsersQuery()
315 const {
316 data: moreSuggestions,
317 fetchNextPage,
318 hasNextPage,
319 isFetchingNextPage,
320 error: suggestionsError,
321 } = useSuggestedFollowsQuery({limit: 25})
322
323 const [dismissedDids, setDismissedDids] = useState<Set<string>>(new Set())
324
325 const onDismiss = useCallback((did: string) => {
326 setDismissedDids(prev => new Set(prev).add(did))
327 }, [])
328
329 // Combine profiles from experimental query with paginated suggestions
330 const allProfiles = useMemo(() => {
331 const fallbackProfiles =
332 moreSuggestions?.pages.flatMap(page =>
333 page.actors.map(actor => ({actor, recId: page.recId})),
334 ) ?? []
335
336 // Dedupe by did, preferring experimental profiles
337 const seen = new Set<string>()
338 const combined: Array<{
339 actor: bsky.profile.AnyProfileView
340 recId?: number
341 }> = []
342
343 for (const profile of experimentalProfiles) {
344 if (!seen.has(profile.did)) {
345 seen.add(profile.did)
346 combined.push({actor: profile, recId: undefined})
347 }
348 }
349
350 for (const profile of fallbackProfiles) {
351 if (!seen.has(profile.actor.did)) {
352 seen.add(profile.actor.did)
353 combined.push(profile)
354 }
355 }
356
357 return combined
358 }, [experimentalProfiles, moreSuggestions?.pages])
359
360 const filteredProfiles = useMemo(() => {
361 return allProfiles.filter(p => !dismissedDids.has(p.actor.did))
362 }, [allProfiles, dismissedDids])
363
364 // Fetch more when running low
365 useEffect(() => {
366 if (
367 moderationOpts &&
368 filteredProfiles.length < maxLength &&
369 hasNextPage &&
370 !isFetchingNextPage
371 ) {
372 void fetchNextPage()
373 }
374 }, [
375 filteredProfiles.length,
376 maxLength,
377 hasNextPage,
378 isFetchingNextPage,
379 fetchNextPage,
380 moderationOpts,
381 ])
382
383 return (
384 <ProfileGrid
385 isSuggestionsLoading={isSuggestionsLoading}
386 profiles={filteredProfiles}
387 totalProfileCount={allProfiles.length}
388 error={experimentalError || suggestionsError}
389 viewContext="feed"
390 onDismiss={onDismiss}
391 />
392 )
393}
394
395export function ProfileGrid({
396 isSuggestionsLoading,
397 error,
398 profiles,
399 totalProfileCount,
400 viewContext = 'feed',
401 onDismiss,
402 isVisible = true,
403}: {
404 isSuggestionsLoading: boolean
405 profiles: {actor: bsky.profile.AnyProfileView; recId?: number}[]
406 totalProfileCount?: number
407 error: Error | null
408 viewContext: 'profile' | 'profileHeader' | 'feed'
409 onDismiss?: (did: string) => void
410 isVisible?: boolean
411}) {
412 const t = useTheme()
413 const ax = useAnalytics()
414 const {_} = useLingui()
415 const moderationOpts = useModerationOpts()
416 const {gtMobile} = useBreakpoints()
417 const followDialogControl = useDialogControl()
418
419 const isLoading = isSuggestionsLoading || !moderationOpts
420 const isProfileHeaderContext = viewContext === 'profileHeader'
421 const isFeedContext = viewContext === 'feed'
422
423 const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6
424 const minLength = gtMobile ? 3 : 4
425
426 // hide similar accounts
427 const hideSimilarAccountsRecomm = useHideSimilarAccountsRecomm()
428
429 // Track seen profiles
430 const seenProfilesRef = useRef<Set<string>>(new Set())
431 const containerRef = useRef<View>(null)
432 const hasTrackedRef = useRef(false)
433 const logContext: Metrics['suggestedUser:seen']['logContext'] = isFeedContext
434 ? 'InterstitialDiscover'
435 : isProfileHeaderContext
436 ? 'Profile'
437 : 'InterstitialProfile'
438
439 // Callback to fire seen events
440 const fireSeen = useCallback(() => {
441 if (isLoading || error || !profiles.length) return
442 if (hasTrackedRef.current) return
443 hasTrackedRef.current = true
444
445 const profilesToShow = profiles.slice(0, maxLength)
446 profilesToShow.forEach((profile, index) => {
447 if (!seenProfilesRef.current.has(profile.actor.did)) {
448 seenProfilesRef.current.add(profile.actor.did)
449 ax.metric('suggestedUser:seen', {
450 logContext,
451 recId: profile.recId,
452 position: index,
453 suggestedDid: profile.actor.did,
454 category: null,
455 })
456 }
457 })
458 }, [ax, isLoading, error, profiles, maxLength, logContext])
459
460 // For profile header, fire when isVisible becomes true
461 useEffect(() => {
462 if (isProfileHeaderContext) {
463 if (!isVisible) {
464 hasTrackedRef.current = false
465 return
466 }
467 fireSeen()
468 }
469 }, [isVisible, isProfileHeaderContext, fireSeen])
470
471 // For feed interstitials, use IntersectionObserver to detect actual visibility
472 useEffect(() => {
473 if (isProfileHeaderContext) return // handled above
474 if (isLoading || error || !profiles.length) return
475
476 const node = containerRef.current
477 if (!node) return
478
479 // Use IntersectionObserver on web to detect when actually visible
480 if (typeof IntersectionObserver !== 'undefined') {
481 const observer = new IntersectionObserver(
482 entries => {
483 if (entries[0]?.isIntersecting) {
484 fireSeen()
485 observer.disconnect()
486 }
487 },
488 {threshold: 0.5},
489 )
490 // @ts-ignore - web only
491 observer.observe(node)
492 return () => observer.disconnect()
493 } else {
494 // On native, delay slightly to account for layout shifts during hydration
495 const timeout = setTimeout(() => {
496 fireSeen()
497 }, 500)
498 return () => clearTimeout(timeout)
499 }
500 }, [isProfileHeaderContext, isLoading, error, profiles.length, fireSeen])
501
502 const content = isLoading
503 ? Array(maxLength)
504 .fill(0)
505 .map((_, i) => (
506 <View
507 key={i}
508 style={[
509 a.flex_1,
510 gtMobile &&
511 web([
512 a.flex_0,
513 a.flex_grow,
514 {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
515 ]),
516 ]}>
517 <SuggestedFollowPlaceholder />
518 </View>
519 ))
520 : error || !profiles.length
521 ? null
522 : profiles.slice(0, maxLength).map((profile, index) => (
523 <Animated.View
524 key={profile.actor.did}
525 layout={native(
526 LinearTransition.delay(DISMISS_ANIMATION_DURATION).easing(
527 Easing.out(Easing.exp),
528 ),
529 )}
530 exiting={FadeOut.duration(DISMISS_ANIMATION_DURATION)}
531 // for web, as the cards are static, not in a list
532 entering={web(FadeIn.delay(DISMISS_ANIMATION_DURATION * 2))}
533 style={[
534 a.flex_1,
535 gtMobile &&
536 web([
537 a.flex_0,
538 a.flex_grow,
539 {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
540 ]),
541 ]}>
542 <ProfileCard.Link
543 profile={profile.actor}
544 onPress={() => {
545 ax.metric('suggestedUser:press', {
546 logContext: isFeedContext
547 ? 'InterstitialDiscover'
548 : 'InterstitialProfile',
549 recId: profile.recId,
550 position: index,
551 suggestedDid: profile.actor.did,
552 category: null,
553 })
554 }}
555 style={[a.flex_1]}>
556 {({hovered, pressed}) => (
557 <CardOuter
558 style={[
559 (hovered || pressed) && t.atoms.border_contrast_high,
560 ]}>
561 <ProfileCard.Outer>
562 {onDismiss && (
563 <Button
564 label={_(msg`Dismiss this suggestion`)}
565 onPress={e => {
566 e.preventDefault()
567 onDismiss(profile.actor.did)
568 ax.metric('suggestedUser:dismiss', {
569 logContext: isFeedContext
570 ? 'InterstitialDiscover'
571 : 'InterstitialProfile',
572 position: index,
573 suggestedDid: profile.actor.did,
574 recId: profile.recId,
575 })
576 }}
577 style={[
578 a.absolute,
579 a.z_10,
580 a.p_xs,
581 {top: -4, right: -4},
582 ]}>
583 {({
584 hovered: dismissHovered,
585 pressed: dismissPressed,
586 }) => (
587 <X
588 size="xs"
589 fill={
590 dismissHovered || dismissPressed
591 ? t.atoms.text.color
592 : t.atoms.text_contrast_medium.color
593 }
594 />
595 )}
596 </Button>
597 )}
598 <View
599 style={[
600 a.flex_col,
601 a.align_center,
602 a.gap_sm,
603 a.pb_sm,
604 a.mb_auto,
605 ]}>
606 <ProfileCard.Avatar
607 profile={profile.actor}
608 moderationOpts={moderationOpts}
609 disabledPreview
610 size={88}
611 />
612 <View style={[a.flex_col, a.align_center, a.max_w_full]}>
613 <ProfileCard.Name
614 profile={profile.actor}
615 moderationOpts={moderationOpts}
616 />
617 <ProfileCard.Description
618 profile={profile.actor}
619 numberOfLines={2}
620 style={[
621 t.atoms.text_contrast_medium,
622 a.text_center,
623 a.text_xs,
624 ]}
625 />
626 </View>
627 </View>
628
629 <ProfileCard.FollowButton
630 profile={profile.actor}
631 moderationOpts={moderationOpts}
632 logContext="FeedInterstitial"
633 withIcon={false}
634 style={[a.rounded_sm]}
635 onFollow={() => {
636 ax.metric('suggestedUser:follow', {
637 logContext: isFeedContext
638 ? 'InterstitialDiscover'
639 : 'InterstitialProfile',
640 location: 'Card',
641 recId: profile.recId,
642 position: index,
643 suggestedDid: profile.actor.did,
644 category: null,
645 })
646 }}
647 />
648 </ProfileCard.Outer>
649 </CardOuter>
650 )}
651 </ProfileCard.Link>
652 </Animated.View>
653 ))
654
655 // Use totalProfileCount (before dismissals) for minLength check on initial render.
656 const profileCountForMinCheck = totalProfileCount ?? profiles.length
657 if (error || (!isLoading && profileCountForMinCheck < minLength)) {
658 ax.logger.debug(`Not enough profiles to show suggested follows`)
659 return null
660 }
661
662 if (!hideSimilarAccountsRecomm) {
663 return (
664 <View
665 ref={containerRef}
666 style={[
667 !isProfileHeaderContext && a.border_t,
668 t.atoms.border_contrast_low,
669 t.atoms.bg_contrast_25,
670 ]}
671 pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
672 <View
673 style={[
674 a.px_lg,
675 a.pt_md,
676 a.flex_row,
677 a.align_center,
678 a.justify_between,
679 ]}
680 pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
681 <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text]}>
682 {isFeedContext ? (
683 <Trans>Suggested for you</Trans>
684 ) : (
685 <Trans>Similar accounts</Trans>
686 )}
687 </Text>
688 {!isProfileHeaderContext && (
689 <Button
690 label={_(msg`See more suggested profiles`)}
691 onPress={() => {
692 followDialogControl.open()
693 ax.metric('suggestedUser:seeMore', {
694 logContext: isFeedContext ? 'Explore' : 'Profile',
695 })
696 }}>
697 {({hovered}) => (
698 <Text
699 style={[
700 a.text_sm,
701 {color: t.palette.primary_500},
702 hovered &&
703 web({
704 textDecorationLine: 'underline',
705 textDecorationColor: t.palette.primary_500,
706 }),
707 ]}>
708 <Trans>See more</Trans>
709 </Text>
710 )}
711 </Button>
712 )}
713 </View>
714
715 <FollowDialogWithoutGuide control={followDialogControl} />
716
717 <LayoutAnimationConfig skipExiting skipEntering>
718 {gtMobile ? (
719 <View style={[a.p_lg, a.pt_md]}>
720 <View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
721 {content}
722 </View>
723 </View>
724 ) : (
725 <BlockDrawerGesture>
726 <ScrollView
727 horizontal
728 showsHorizontalScrollIndicator={false}
729 contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
730 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
731 decelerationRate="fast">
732 {content}
733
734 {!isProfileHeaderContext && (
735 <SeeMoreSuggestedProfilesCard
736 onPress={() => {
737 followDialogControl.open()
738 ax.metric('suggestedUser:seeMore', {
739 logContext: 'Explore',
740 })
741 }}
742 />
743 )}
744 </ScrollView>
745 </BlockDrawerGesture>
746 )}
747 </LayoutAnimationConfig>
748 </View>
749 )
750 }
751}
752
753function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
754 const {_} = useLingui()
755
756 return (
757 <Button
758 label={_(msg`Browse more accounts`)}
759 onPress={onPress}
760 style={[
761 a.flex_col,
762 a.align_center,
763 a.justify_center,
764 a.gap_sm,
765 a.p_md,
766 a.rounded_lg,
767 {width: FINAL_CARD_WIDTH},
768 ]}>
769 <ButtonIcon icon={ArrowRight} size="lg" />
770 <ButtonText
771 style={[a.text_md, a.font_medium, a.leading_snug, a.text_center]}>
772 <Trans>See more</Trans>
773 </ButtonText>
774 </Button>
775 )
776}
777
778const numFeedsToDisplay = 3
779export function SuggestedFeeds() {
780 const t = useTheme()
781 const ax = useAnalytics()
782 const {_} = useLingui()
783 const {data, isLoading, error} = useGetPopularFeedsQuery({
784 limit: numFeedsToDisplay,
785 })
786 const navigation = useNavigation<NavigationProp>()
787 const {gtMobile} = useBreakpoints()
788
789 const feeds = useMemo(() => {
790 const items: AppBskyFeedDefs.GeneratorView[] = []
791
792 if (!data) return items
793
794 for (const page of data.pages) {
795 for (const feed of page.feeds) {
796 items.push(feed)
797 }
798 }
799
800 return items
801 }, [data])
802
803 const content = isLoading ? (
804 Array(numFeedsToDisplay)
805 .fill(0)
806 .map((_, i) => <SuggestedFeedsCardPlaceholder key={i} />)
807 ) : error || !feeds ? null : (
808 <>
809 {feeds.slice(0, numFeedsToDisplay).map(feed => (
810 <FeedCard.Link
811 key={feed.uri}
812 view={feed}
813 onPress={() => {
814 ax.metric('feed:interstitial:feedCard:press', {})
815 }}>
816 {({hovered, pressed}) => (
817 <CardOuter
818 style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
819 <FeedCard.Outer>
820 <FeedCard.Header>
821 <FeedCard.Avatar src={feed.avatar} />
822 <FeedCard.TitleAndByline
823 title={feed.displayName}
824 creator={feed.creator}
825 uri={feed.uri}
826 />
827 </FeedCard.Header>
828 <FeedCard.Description
829 description={feed.description}
830 numberOfLines={3}
831 />
832 </FeedCard.Outer>
833 </CardOuter>
834 )}
835 </FeedCard.Link>
836 ))}
837 </>
838 )
839
840 return error ? null : (
841 <View
842 style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
843 <View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
844 <Text
845 style={[
846 a.flex_1,
847 a.text_lg,
848 a.font_semi_bold,
849 t.atoms.text_contrast_medium,
850 ]}>
851 <Trans>Some other feeds you might like</Trans>
852 </Text>
853 <Hashtag fill={t.atoms.text_contrast_low.color} />
854 </View>
855
856 {gtMobile ? (
857 <View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
858 {content}
859
860 <View
861 style={[
862 a.flex_row,
863 a.justify_end,
864 a.align_center,
865 a.pt_xs,
866 a.gap_md,
867 ]}>
868 <InlineLinkText
869 label={_(msg`Browse more suggestions`)}
870 to="/search"
871 style={[t.atoms.text_contrast_medium]}>
872 <Trans>Browse more suggestions</Trans>
873 </InlineLinkText>
874 <ArrowRight size="sm" fill={t.atoms.text_contrast_medium.color} />
875 </View>
876 </View>
877 ) : (
878 <BlockDrawerGesture>
879 <ScrollView
880 horizontal
881 showsHorizontalScrollIndicator={false}
882 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
883 decelerationRate="fast">
884 <View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
885 {content}
886
887 <Button
888 label={_(msg`Browse more feeds on the Explore page`)}
889 onPress={() => {
890 navigation.navigate('SearchTab')
891 }}
892 style={[a.flex_col]}>
893 <CardOuter>
894 <View style={[a.flex_1, a.justify_center]}>
895 <View style={[a.flex_row, a.px_lg]}>
896 <Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
897 <Trans>
898 Browse more suggestions on the Explore page
899 </Trans>
900 </Text>
901
902 <ArrowRight size="xl" />
903 </View>
904 </View>
905 </CardOuter>
906 </Button>
907 </View>
908 </ScrollView>
909 </BlockDrawerGesture>
910 )}
911 </View>
912 )
913}
914
915export function ProgressGuide() {
916 const t = useTheme()
917 const {gtMobile} = useBreakpoints()
918 return (
919 <View
920 style={[
921 t.atoms.border_contrast_low,
922 a.px_lg,
923 a.py_lg,
924 !gtMobile && {marginTop: 4},
925 ]}>
926 <ProgressGuideList />
927 </View>
928 )
929}