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