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