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 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 {_} = useLingui()
437 const gate = useGate()
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 const showDismissButton = onDismiss && gate('suggested_users_dismiss')
446
447 const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6
448 const minLength = gtMobile ? 3 : 4
449
450 // Track seen profiles
451 const seenProfilesRef = useRef<Set<string>>(new Set())
452 const containerRef = useRef<View>(null)
453 const hasTrackedRef = useRef(false)
454 const logContext: MetricEvents['suggestedUser:seen']['logContext'] =
455 isFeedContext
456 ? 'InterstitialDiscover'
457 : isProfileHeaderContext
458 ? 'Profile'
459 : 'InterstitialProfile'
460
461 // Callback to fire seen events
462 const fireSeen = useCallback(() => {
463 if (isLoading || error || !profiles.length) return
464 if (hasTrackedRef.current) return
465 hasTrackedRef.current = true
466
467 const profilesToShow = profiles.slice(0, maxLength)
468 profilesToShow.forEach((profile, index) => {
469 if (!seenProfilesRef.current.has(profile.did)) {
470 seenProfilesRef.current.add(profile.did)
471 logger.metric(
472 'suggestedUser:seen',
473 {
474 logContext,
475 recId,
476 position: index,
477 suggestedDid: profile.did,
478 category: null,
479 },
480 {statsig: true},
481 )
482 }
483 })
484 }, [isLoading, error, profiles, maxLength, logContext, recId])
485
486 // For profile header, fire when isVisible becomes true
487 useEffect(() => {
488 if (isProfileHeaderContext) {
489 if (!isVisible) {
490 hasTrackedRef.current = false
491 return
492 }
493 fireSeen()
494 }
495 }, [isVisible, isProfileHeaderContext, fireSeen])
496
497 // For feed interstitials, use IntersectionObserver to detect actual visibility
498 useEffect(() => {
499 if (isProfileHeaderContext) return // handled above
500 if (isLoading || error || !profiles.length) return
501
502 const node = containerRef.current
503 if (!node) return
504
505 // Use IntersectionObserver on web to detect when actually visible
506 if (typeof IntersectionObserver !== 'undefined') {
507 const observer = new IntersectionObserver(
508 entries => {
509 if (entries[0]?.isIntersecting) {
510 fireSeen()
511 observer.disconnect()
512 }
513 },
514 {threshold: 0.5},
515 )
516 // @ts-ignore - web only
517 observer.observe(node)
518 return () => observer.disconnect()
519 } else {
520 // On native, delay slightly to account for layout shifts during hydration
521 const timeout = setTimeout(() => {
522 fireSeen()
523 }, 500)
524 return () => clearTimeout(timeout)
525 }
526 }, [isProfileHeaderContext, isLoading, error, profiles.length, fireSeen])
527
528 const content = isLoading
529 ? Array(maxLength)
530 .fill(0)
531 .map((_, i) => (
532 <View
533 key={i}
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 <SuggestedFollowPlaceholder />
544 </View>
545 ))
546 : error || !profiles.length
547 ? null
548 : profiles.slice(0, maxLength).map((profile, index) => (
549 <Animated.View
550 key={profile.did}
551 layout={LinearTransition.duration(DISMISS_ANIMATION_DURATION)}
552 style={[
553 a.flex_1,
554 gtMobile &&
555 web([
556 a.flex_0,
557 a.flex_grow,
558 {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
559 ]),
560 {
561 opacity: dismissingDids?.has(profile.did) ? 0 : 1,
562 transitionProperty: 'opacity',
563 transitionDuration: `${DISMISS_ANIMATION_DURATION}ms`,
564 },
565 ]}>
566 <ProfileCard.Link
567 profile={profile}
568 onPress={() => {
569 logEvent('suggestedUser:press', {
570 logContext: isFeedContext
571 ? 'InterstitialDiscover'
572 : 'InterstitialProfile',
573 recId,
574 position: index,
575 suggestedDid: profile.did,
576 category: null,
577 })
578 }}>
579 {({hovered, pressed}) => (
580 <CardOuter
581 style={[
582 (hovered || pressed) && t.atoms.border_contrast_high,
583 ]}>
584 <ProfileCard.Outer>
585 {showDismissButton && (
586 <Button
587 label={_(msg`Dismiss this suggestion`)}
588 onPress={e => {
589 e.preventDefault()
590 onDismiss!(profile.did)
591 logEvent('suggestedUser:dismiss', {
592 logContext: isFeedContext
593 ? 'InterstitialDiscover'
594 : 'InterstitialProfile',
595 position: index,
596 suggestedDid: profile.did,
597 recId,
598 })
599 }}
600 style={[
601 a.absolute,
602 a.z_10,
603 a.p_xs,
604 {top: -4, right: -4},
605 ]}>
606 {({
607 hovered: dismissHovered,
608 pressed: dismissPressed,
609 }) => (
610 <X
611 size="xs"
612 fill={
613 dismissHovered || dismissPressed
614 ? t.atoms.text.color
615 : t.atoms.text_contrast_medium.color
616 }
617 />
618 )}
619 </Button>
620 )}
621 <View
622 style={[
623 a.flex_col,
624 a.align_center,
625 a.gap_sm,
626 a.pb_sm,
627 a.mb_auto,
628 ]}>
629 <ProfileCard.Avatar
630 profile={profile}
631 moderationOpts={moderationOpts}
632 disabledPreview
633 size={88}
634 />
635 <View style={[a.flex_col, a.align_center, a.max_w_full]}>
636 <ProfileCard.Name
637 profile={profile}
638 moderationOpts={moderationOpts}
639 />
640 <ProfileCard.Description
641 profile={profile}
642 numberOfLines={2}
643 style={[
644 t.atoms.text_contrast_medium,
645 a.text_center,
646 a.text_xs,
647 ]}
648 />
649 </View>
650 </View>
651
652 <ProfileCard.FollowButton
653 profile={profile}
654 moderationOpts={moderationOpts}
655 logContext="FeedInterstitial"
656 withIcon={false}
657 style={[a.rounded_sm]}
658 onFollow={() => {
659 logEvent('suggestedUser:follow', {
660 logContext: isFeedContext
661 ? 'InterstitialDiscover'
662 : 'InterstitialProfile',
663 location: 'Card',
664 recId,
665 position: index,
666 suggestedDid: profile.did,
667 category: null,
668 })
669 }}
670 />
671 </ProfileCard.Outer>
672 </CardOuter>
673 )}
674 </ProfileCard.Link>
675 </Animated.View>
676 ))
677
678 // Use totalProfileCount (before dismissals) for minLength check on initial render.
679 const profileCountForMinCheck = totalProfileCount ?? profiles.length
680 if (error || (!isLoading && profileCountForMinCheck < minLength)) {
681 logger.debug(`Not enough profiles to show suggested follows`)
682 return null
683 }
684
685 return (
686 <View
687 ref={containerRef}
688 style={[
689 !isProfileHeaderContext && a.border_t,
690 t.atoms.border_contrast_low,
691 t.atoms.bg_contrast_25,
692 ]}
693 pointerEvents={isIOS ? 'auto' : 'box-none'}>
694 <View
695 style={[
696 a.px_lg,
697 a.pt_md,
698 a.flex_row,
699 a.align_center,
700 a.justify_between,
701 ]}
702 pointerEvents={isIOS ? 'auto' : 'box-none'}>
703 <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text]}>
704 {isFeedContext ? (
705 <Trans>Suggested for you</Trans>
706 ) : (
707 <Trans>Similar accounts</Trans>
708 )}
709 </Text>
710 {!isProfileHeaderContext && (
711 <Button
712 label={_(msg`See more suggested profiles`)}
713 onPress={() => {
714 followDialogControl.open()
715 logEvent('suggestedUser:seeMore', {
716 logContext: isFeedContext ? 'Explore' : 'Profile',
717 })
718 }}>
719 {({hovered}) => (
720 <Text
721 style={[
722 a.text_sm,
723 {color: t.palette.primary_500},
724 hovered &&
725 web({
726 textDecorationLine: 'underline',
727 textDecorationColor: t.palette.primary_500,
728 }),
729 ]}>
730 <Trans>See more</Trans>
731 </Text>
732 )}
733 </Button>
734 )}
735 </View>
736
737 <FollowDialogWithoutGuide control={followDialogControl} />
738
739 {gtMobile ? (
740 <View style={[a.p_lg, a.pt_md]}>
741 <View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
742 {content}
743 </View>
744 </View>
745 ) : (
746 <BlockDrawerGesture>
747 <ScrollView
748 horizontal
749 showsHorizontalScrollIndicator={false}
750 contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
751 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
752 decelerationRate="fast">
753 {content}
754
755 {!isProfileHeaderContext && (
756 <SeeMoreSuggestedProfilesCard
757 onPress={() => {
758 followDialogControl.open()
759 logger.metric('suggestedUser:seeMore', {
760 logContext: 'Explore',
761 })
762 }}
763 />
764 )}
765 </ScrollView>
766 </BlockDrawerGesture>
767 )}
768 </View>
769 )
770}
771
772function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
773 const t = useTheme()
774 const {_} = useLingui()
775
776 return (
777 <Button
778 label={_(msg`Browse more accounts`)}
779 onPress={onPress}
780 style={[
781 a.flex_col,
782 a.align_center,
783 a.justify_center,
784 a.gap_sm,
785 a.p_md,
786 a.rounded_lg,
787 t.atoms.shadow_sm,
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 />
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 return (
937 <View style={[t.atoms.border_contrast_low, a.px_lg, a.py_lg, a.pb_lg]}>
938 <ProgressGuideList />
939 </View>
940 )
941}