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