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