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