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} from '@lingui/core/macro'
12import {useLingui} from '@lingui/react'
13import {Trans} from '@lingui/react/macro'
14import {useNavigation} from '@react-navigation/native'
15
16import {type NavigationProp} from '#/lib/routes/types'
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 // Track seen profiles
427 const seenProfilesRef = useRef<Set<string>>(new Set())
428 const containerRef = useRef<View>(null)
429 const hasTrackedRef = useRef(false)
430 const logContext: Metrics['suggestedUser:seen']['logContext'] = isFeedContext
431 ? 'InterstitialDiscover'
432 : isProfileHeaderContext
433 ? 'Profile'
434 : 'InterstitialProfile'
435
436 // Callback to fire seen events
437 const fireSeen = useCallback(() => {
438 if (isLoading || error || !profiles.length) return
439 if (hasTrackedRef.current) return
440 hasTrackedRef.current = true
441
442 const profilesToShow = profiles.slice(0, maxLength)
443 profilesToShow.forEach((profile, index) => {
444 if (!seenProfilesRef.current.has(profile.actor.did)) {
445 seenProfilesRef.current.add(profile.actor.did)
446 ax.metric('suggestedUser:seen', {
447 logContext,
448 recId: profile.recId,
449 position: index,
450 suggestedDid: profile.actor.did,
451 category: null,
452 })
453 }
454 })
455 }, [ax, isLoading, error, profiles, maxLength, logContext])
456
457 // For profile header, fire when isVisible becomes true
458 useEffect(() => {
459 if (isProfileHeaderContext) {
460 if (!isVisible) {
461 hasTrackedRef.current = false
462 return
463 }
464 fireSeen()
465 }
466 }, [isVisible, isProfileHeaderContext, fireSeen])
467
468 // For feed interstitials, use IntersectionObserver to detect actual visibility
469 useEffect(() => {
470 if (isProfileHeaderContext) return // handled above
471 if (isLoading || error || !profiles.length) return
472
473 const node = containerRef.current
474 if (!node) return
475
476 // Use IntersectionObserver on web to detect when actually visible
477 if (typeof IntersectionObserver !== 'undefined') {
478 const observer = new IntersectionObserver(
479 entries => {
480 if (entries[0]?.isIntersecting) {
481 fireSeen()
482 observer.disconnect()
483 }
484 },
485 {threshold: 0.5},
486 )
487 // @ts-ignore - web only
488 observer.observe(node)
489 return () => observer.disconnect()
490 } else {
491 // On native, delay slightly to account for layout shifts during hydration
492 const timeout = setTimeout(() => {
493 fireSeen()
494 }, 500)
495 return () => clearTimeout(timeout)
496 }
497 }, [isProfileHeaderContext, isLoading, error, profiles.length, fireSeen])
498
499 const content = isLoading
500 ? Array(maxLength)
501 .fill(0)
502 .map((_, i) => (
503 <View
504 key={i}
505 style={[
506 a.flex_1,
507 gtMobile &&
508 web([
509 a.flex_0,
510 a.flex_grow,
511 {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
512 ]),
513 ]}>
514 <SuggestedFollowPlaceholder />
515 </View>
516 ))
517 : error || !profiles.length
518 ? null
519 : profiles.slice(0, maxLength).map((profile, index) => (
520 <Animated.View
521 key={profile.actor.did}
522 layout={native(
523 LinearTransition.delay(DISMISS_ANIMATION_DURATION).easing(
524 Easing.out(Easing.exp),
525 ),
526 )}
527 exiting={FadeOut.duration(DISMISS_ANIMATION_DURATION)}
528 // for web, as the cards are static, not in a list
529 entering={web(FadeIn.delay(DISMISS_ANIMATION_DURATION * 2))}
530 style={[
531 a.flex_1,
532 gtMobile &&
533 web([
534 a.flex_0,
535 a.flex_grow,
536 {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
537 ]),
538 ]}>
539 <ProfileCard.Link
540 profile={profile.actor}
541 onPress={() => {
542 ax.metric('suggestedUser:press', {
543 logContext: isFeedContext
544 ? 'InterstitialDiscover'
545 : 'InterstitialProfile',
546 recId: profile.recId,
547 position: index,
548 suggestedDid: profile.actor.did,
549 category: null,
550 })
551 }}
552 style={[a.flex_1]}>
553 {({hovered, pressed}) => (
554 <CardOuter
555 style={[
556 (hovered || pressed) && t.atoms.border_contrast_high,
557 ]}>
558 <ProfileCard.Outer>
559 {onDismiss && (
560 <Button
561 label={_(msg`Dismiss this suggestion`)}
562 onPress={e => {
563 e.preventDefault()
564 onDismiss(profile.actor.did)
565 ax.metric('suggestedUser:dismiss', {
566 logContext: isFeedContext
567 ? 'InterstitialDiscover'
568 : 'InterstitialProfile',
569 position: index,
570 suggestedDid: profile.actor.did,
571 recId: profile.recId,
572 })
573 }}
574 style={[
575 a.absolute,
576 a.z_10,
577 a.p_xs,
578 {top: -4, right: -4},
579 ]}>
580 {({
581 hovered: dismissHovered,
582 pressed: dismissPressed,
583 }) => (
584 <X
585 size="xs"
586 fill={
587 dismissHovered || dismissPressed
588 ? t.atoms.text.color
589 : t.atoms.text_contrast_medium.color
590 }
591 />
592 )}
593 </Button>
594 )}
595 <View
596 style={[
597 a.flex_col,
598 a.align_center,
599 a.gap_sm,
600 a.pb_sm,
601 a.mb_auto,
602 ]}>
603 <ProfileCard.Avatar
604 profile={profile.actor}
605 moderationOpts={moderationOpts}
606 disabledPreview
607 size={88}
608 />
609 <View style={[a.flex_col, a.align_center, a.max_w_full]}>
610 <ProfileCard.Name
611 profile={profile.actor}
612 moderationOpts={moderationOpts}
613 />
614 <ProfileCard.Description
615 profile={profile.actor}
616 numberOfLines={2}
617 style={[
618 t.atoms.text_contrast_medium,
619 a.text_center,
620 a.text_xs,
621 ]}
622 />
623 </View>
624 </View>
625
626 <ProfileCard.FollowButton
627 profile={profile.actor}
628 moderationOpts={moderationOpts}
629 logContext="FeedInterstitial"
630 withIcon={false}
631 style={[a.rounded_sm]}
632 onFollow={() => {
633 ax.metric('suggestedUser:follow', {
634 logContext: isFeedContext
635 ? 'InterstitialDiscover'
636 : 'InterstitialProfile',
637 location: 'Card',
638 recId: profile.recId,
639 position: index,
640 suggestedDid: profile.actor.did,
641 category: null,
642 })
643 }}
644 />
645 </ProfileCard.Outer>
646 </CardOuter>
647 )}
648 </ProfileCard.Link>
649 </Animated.View>
650 ))
651
652 // Use totalProfileCount (before dismissals) for minLength check on initial render.
653 const profileCountForMinCheck = totalProfileCount ?? profiles.length
654 if (error || (!isLoading && profileCountForMinCheck < minLength)) {
655 ax.logger.debug(`Not enough profiles to show suggested follows`)
656 return null
657 }
658
659 return (
660 <View
661 ref={containerRef}
662 style={[
663 !isProfileHeaderContext && a.border_t,
664 t.atoms.border_contrast_low,
665 t.atoms.bg_contrast_25,
666 ]}
667 pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
668 <View
669 style={[
670 a.px_lg,
671 a.pt_md,
672 a.flex_row,
673 a.align_center,
674 a.justify_between,
675 ]}
676 pointerEvents={IS_IOS ? 'auto' : 'box-none'}>
677 <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text]}>
678 <Trans>Suggested for you</Trans>
679 </Text>
680 {!isProfileHeaderContext && (
681 <Button
682 label={_(msg`See more suggested profiles`)}
683 onPress={() => {
684 followDialogControl.open()
685 ax.metric('suggestedUser:seeMore', {
686 logContext: isFeedContext ? 'Explore' : 'Profile',
687 })
688 }}>
689 {({hovered}) => (
690 <Text
691 style={[
692 a.text_sm,
693 {color: t.palette.primary_500},
694 hovered &&
695 web({
696 textDecorationLine: 'underline',
697 textDecorationColor: t.palette.primary_500,
698 }),
699 ]}>
700 <Trans>See more</Trans>
701 </Text>
702 )}
703 </Button>
704 )}
705 </View>
706
707 <FollowDialogWithoutGuide control={followDialogControl} />
708
709 <LayoutAnimationConfig skipExiting skipEntering>
710 {gtMobile ? (
711 <View style={[a.p_lg, a.pt_md]}>
712 <View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
713 {content}
714 </View>
715 </View>
716 ) : (
717 <BlockDrawerGesture>
718 <ScrollView
719 horizontal
720 showsHorizontalScrollIndicator={false}
721 contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
722 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
723 decelerationRate="fast">
724 {content}
725
726 {!isProfileHeaderContext && (
727 <SeeMoreSuggestedProfilesCard
728 onPress={() => {
729 followDialogControl.open()
730 ax.metric('suggestedUser:seeMore', {
731 logContext: 'Explore',
732 })
733 }}
734 />
735 )}
736 </ScrollView>
737 </BlockDrawerGesture>
738 )}
739 </LayoutAnimationConfig>
740 </View>
741 )
742}
743
744function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
745 const {_} = useLingui()
746
747 return (
748 <Button
749 label={_(msg`Browse more accounts`)}
750 onPress={onPress}
751 style={[
752 a.flex_col,
753 a.align_center,
754 a.justify_center,
755 a.gap_sm,
756 a.p_md,
757 a.rounded_lg,
758 {width: FINAL_CARD_WIDTH},
759 ]}>
760 <ButtonIcon icon={ArrowRight} size="lg" />
761 <ButtonText
762 style={[a.text_md, a.font_medium, a.leading_snug, a.text_center]}>
763 <Trans>See more</Trans>
764 </ButtonText>
765 </Button>
766 )
767}
768
769const numFeedsToDisplay = 3
770export function SuggestedFeeds() {
771 const t = useTheme()
772 const ax = useAnalytics()
773 const {_} = useLingui()
774 const {data, isLoading, error} = useGetPopularFeedsQuery({
775 limit: numFeedsToDisplay,
776 })
777 const navigation = useNavigation<NavigationProp>()
778 const {gtMobile} = useBreakpoints()
779
780 const feeds = useMemo(() => {
781 const items: AppBskyFeedDefs.GeneratorView[] = []
782
783 if (!data) return items
784
785 for (const page of data.pages) {
786 for (const feed of page.feeds) {
787 items.push(feed)
788 }
789 }
790
791 return items
792 }, [data])
793
794 const content = isLoading ? (
795 Array(numFeedsToDisplay)
796 .fill(0)
797 .map((_, i) => <SuggestedFeedsCardPlaceholder key={i} />)
798 ) : error || !feeds ? null : (
799 <>
800 {feeds.slice(0, numFeedsToDisplay).map(feed => (
801 <FeedCard.Link
802 key={feed.uri}
803 view={feed}
804 onPress={() => {
805 ax.metric('feed:interstitial:feedCard:press', {})
806 }}>
807 {({hovered, pressed}) => (
808 <CardOuter
809 style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
810 <FeedCard.Outer>
811 <FeedCard.Header>
812 <FeedCard.Avatar src={feed.avatar} />
813 <FeedCard.TitleAndByline
814 title={feed.displayName}
815 creator={feed.creator}
816 uri={feed.uri}
817 />
818 </FeedCard.Header>
819 <FeedCard.Description
820 description={feed.description}
821 numberOfLines={3}
822 />
823 </FeedCard.Outer>
824 </CardOuter>
825 )}
826 </FeedCard.Link>
827 ))}
828 </>
829 )
830
831 return error ? null : (
832 <View
833 style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
834 <View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
835 <Text
836 style={[
837 a.flex_1,
838 a.text_lg,
839 a.font_semi_bold,
840 t.atoms.text_contrast_medium,
841 ]}>
842 <Trans>Some other feeds you might like</Trans>
843 </Text>
844 <Hashtag fill={t.atoms.text_contrast_low.color} />
845 </View>
846
847 {gtMobile ? (
848 <View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
849 {content}
850
851 <View
852 style={[
853 a.flex_row,
854 a.justify_end,
855 a.align_center,
856 a.pt_xs,
857 a.gap_md,
858 ]}>
859 <InlineLinkText
860 label={_(msg`Browse more suggestions`)}
861 to="/search"
862 style={[t.atoms.text_contrast_medium]}>
863 <Trans>Browse more suggestions</Trans>
864 </InlineLinkText>
865 <ArrowRight size="sm" fill={t.atoms.text_contrast_medium.color} />
866 </View>
867 </View>
868 ) : (
869 <BlockDrawerGesture>
870 <ScrollView
871 horizontal
872 showsHorizontalScrollIndicator={false}
873 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
874 decelerationRate="fast">
875 <View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
876 {content}
877
878 <Button
879 label={_(msg`Browse more feeds on the Explore page`)}
880 onPress={() => {
881 navigation.navigate('SearchTab')
882 }}
883 style={[a.flex_col]}>
884 <CardOuter>
885 <View style={[a.flex_1, a.justify_center]}>
886 <View style={[a.flex_row, a.px_lg]}>
887 <Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
888 <Trans>
889 Browse more suggestions on the Explore page
890 </Trans>
891 </Text>
892
893 <ArrowRight size="xl" />
894 </View>
895 </View>
896 </CardOuter>
897 </Button>
898 </View>
899 </ScrollView>
900 </BlockDrawerGesture>
901 )}
902 </View>
903 )
904}
905
906export function ProgressGuide() {
907 const t = useTheme()
908 const {gtMobile} = useBreakpoints()
909 return (
910 <View
911 style={[
912 t.atoms.border_contrast_low,
913 a.px_lg,
914 a.py_lg,
915 !gtMobile && {marginTop: 4},
916 ]}>
917 <ProgressGuideList />
918 </View>
919 )
920}