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 {useHideSimilarAccountsRecomm} from '#/state/preferences/hide-similar-accounts-recommendations'
15import {useModerationOpts} from '#/state/preferences/moderation-opts'
16import {useGetPopularFeedsQuery} from '#/state/queries/feed'
17import {type FeedDescriptor} from '#/state/queries/post-feed'
18import {useProfilesQuery} from '#/state/queries/profile'
19import {
20 useSuggestedFollowsByActorQuery,
21 useSuggestedFollowsQuery,
22} from '#/state/queries/suggested-follows'
23import {useSession} from '#/state/session'
24import * as userActionHistory from '#/state/userActionHistory'
25import {type SeenPost} from '#/state/userActionHistory'
26import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
27import {
28 atoms as a,
29 useBreakpoints,
30 useTheme,
31 type ViewStyleProp,
32 web,
33} from '#/alf'
34import {Button, ButtonIcon, ButtonText} from '#/components/Button'
35import {useDialogControl} from '#/components/Dialog'
36import * as FeedCard from '#/components/FeedCard'
37import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow'
38import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
39import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
40import {InlineLinkText} from '#/components/Link'
41import * as ProfileCard from '#/components/ProfileCard'
42import {Text} from '#/components/Typography'
43import type * as bsky from '#/types/bsky'
44import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog'
45import {ProgressGuideList} from './ProgressGuide/List'
46
47const DISMISS_ANIMATION_DURATION = 200
48
49const MOBILE_CARD_WIDTH = 165
50const FINAL_CARD_WIDTH = 120
51
52function CardOuter({
53 children,
54 style,
55}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
56 const t = useTheme()
57 const {gtMobile} = useBreakpoints()
58 return (
59 <View
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 // hide similar accounts
452 const hideSimilarAccountsRecomm = useHideSimilarAccountsRecomm()
453
454 // Track seen profiles
455 const seenProfilesRef = useRef<Set<string>>(new Set())
456 const containerRef = useRef<View>(null)
457 const hasTrackedRef = useRef(false)
458 const logContext: MetricEvents['suggestedUser:seen']['logContext'] =
459 isFeedContext
460 ? 'InterstitialDiscover'
461 : isProfileHeaderContext
462 ? 'Profile'
463 : 'InterstitialProfile'
464
465 // Callback to fire seen events
466 const fireSeen = useCallback(() => {
467 if (isLoading || error || !profiles.length) return
468 if (hasTrackedRef.current) return
469 hasTrackedRef.current = true
470
471 const profilesToShow = profiles.slice(0, maxLength)
472 profilesToShow.forEach((profile, index) => {
473 if (!seenProfilesRef.current.has(profile.did)) {
474 seenProfilesRef.current.add(profile.did)
475 logger.metric(
476 'suggestedUser:seen',
477 {
478 logContext,
479 recId,
480 position: index,
481 suggestedDid: profile.did,
482 category: null,
483 },
484 {statsig: true},
485 )
486 }
487 })
488 }, [isLoading, error, profiles, maxLength, logContext, recId])
489
490 // For profile header, fire when isVisible becomes true
491 useEffect(() => {
492 if (isProfileHeaderContext) {
493 if (!isVisible) {
494 hasTrackedRef.current = false
495 return
496 }
497 fireSeen()
498 }
499 }, [isVisible, isProfileHeaderContext, fireSeen])
500
501 // For feed interstitials, use IntersectionObserver to detect actual visibility
502 useEffect(() => {
503 if (isProfileHeaderContext) return // handled above
504 if (isLoading || error || !profiles.length) return
505
506 const node = containerRef.current
507 if (!node) return
508
509 // Use IntersectionObserver on web to detect when actually visible
510 if (typeof IntersectionObserver !== 'undefined') {
511 const observer = new IntersectionObserver(
512 entries => {
513 if (entries[0]?.isIntersecting) {
514 fireSeen()
515 observer.disconnect()
516 }
517 },
518 {threshold: 0.5},
519 )
520 // @ts-ignore - web only
521 observer.observe(node)
522 return () => observer.disconnect()
523 } else {
524 // On native, delay slightly to account for layout shifts during hydration
525 const timeout = setTimeout(() => {
526 fireSeen()
527 }, 500)
528 return () => clearTimeout(timeout)
529 }
530 }, [isProfileHeaderContext, isLoading, error, profiles.length, fireSeen])
531
532 const content = isLoading
533 ? Array(maxLength)
534 .fill(0)
535 .map((_, i) => (
536 <View
537 key={i}
538 style={[
539 a.flex_1,
540 gtMobile &&
541 web([
542 a.flex_0,
543 a.flex_grow,
544 {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
545 ]),
546 ]}>
547 <SuggestedFollowPlaceholder />
548 </View>
549 ))
550 : error || !profiles.length
551 ? null
552 : profiles.slice(0, maxLength).map((profile, index) => (
553 <Animated.View
554 key={profile.did}
555 layout={LinearTransition.duration(DISMISS_ANIMATION_DURATION)}
556 style={[
557 a.flex_1,
558 gtMobile &&
559 web([
560 a.flex_0,
561 a.flex_grow,
562 {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
563 ]),
564 {
565 opacity: dismissingDids?.has(profile.did) ? 0 : 1,
566 transitionProperty: 'opacity',
567 transitionDuration: `${DISMISS_ANIMATION_DURATION}ms`,
568 },
569 ]}>
570 <ProfileCard.Link
571 profile={profile}
572 onPress={() => {
573 logEvent('suggestedUser:press', {
574 logContext: isFeedContext
575 ? 'InterstitialDiscover'
576 : 'InterstitialProfile',
577 recId,
578 position: index,
579 suggestedDid: profile.did,
580 category: null,
581 })
582 }}>
583 {({hovered, pressed}) => (
584 <CardOuter
585 style={[
586 (hovered || pressed) && t.atoms.border_contrast_high,
587 ]}>
588 <ProfileCard.Outer>
589 {showDismissButton && (
590 <Button
591 label={_(msg`Dismiss this suggestion`)}
592 onPress={e => {
593 e.preventDefault()
594 onDismiss!(profile.did)
595 logEvent('suggestedUser:dismiss', {
596 logContext: isFeedContext
597 ? 'InterstitialDiscover'
598 : 'InterstitialProfile',
599 position: index,
600 suggestedDid: profile.did,
601 recId,
602 })
603 }}
604 style={[
605 a.absolute,
606 a.z_10,
607 a.p_xs,
608 {top: -4, right: -4},
609 ]}>
610 {({
611 hovered: dismissHovered,
612 pressed: dismissPressed,
613 }) => (
614 <X
615 size="xs"
616 fill={
617 dismissHovered || dismissPressed
618 ? t.atoms.text.color
619 : t.atoms.text_contrast_medium.color
620 }
621 />
622 )}
623 </Button>
624 )}
625 <View
626 style={[
627 a.flex_col,
628 a.align_center,
629 a.gap_sm,
630 a.pb_sm,
631 a.mb_auto,
632 ]}>
633 <ProfileCard.Avatar
634 profile={profile}
635 moderationOpts={moderationOpts}
636 disabledPreview
637 size={88}
638 />
639 <View style={[a.flex_col, a.align_center, a.max_w_full]}>
640 <ProfileCard.Name
641 profile={profile}
642 moderationOpts={moderationOpts}
643 />
644 <ProfileCard.Description
645 profile={profile}
646 numberOfLines={2}
647 style={[
648 t.atoms.text_contrast_medium,
649 a.text_center,
650 a.text_xs,
651 ]}
652 />
653 </View>
654 </View>
655
656 <ProfileCard.FollowButton
657 profile={profile}
658 moderationOpts={moderationOpts}
659 logContext="FeedInterstitial"
660 withIcon={false}
661 style={[a.rounded_sm]}
662 onFollow={() => {
663 logEvent('suggestedUser:follow', {
664 logContext: isFeedContext
665 ? 'InterstitialDiscover'
666 : 'InterstitialProfile',
667 location: 'Card',
668 recId,
669 position: index,
670 suggestedDid: profile.did,
671 category: null,
672 })
673 }}
674 />
675 </ProfileCard.Outer>
676 </CardOuter>
677 )}
678 </ProfileCard.Link>
679 </Animated.View>
680 ))
681
682 // Use totalProfileCount (before dismissals) for minLength check on initial render.
683 const profileCountForMinCheck = totalProfileCount ?? profiles.length
684 if (error || (!isLoading && profileCountForMinCheck < minLength)) {
685 logger.debug(`Not enough profiles to show suggested follows`)
686 return null
687 }
688
689 if (!hideSimilarAccountsRecomm) {
690 return (
691 <View
692 ref={containerRef}
693 style={[
694 !isProfileHeaderContext && a.border_t,
695 t.atoms.border_contrast_low,
696 t.atoms.bg_contrast_25,
697 ]}
698 pointerEvents={isIOS ? 'auto' : 'box-none'}>
699 <View
700 style={[
701 a.px_lg,
702 a.pt_md,
703 a.flex_row,
704 a.align_center,
705 a.justify_between,
706 ]}
707 pointerEvents={isIOS ? 'auto' : 'box-none'}>
708 <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text]}>
709 {isFeedContext ? (
710 <Trans>Suggested for you</Trans>
711 ) : (
712 <Trans>Similar accounts</Trans>
713 )}
714 </Text>
715 {!isProfileHeaderContext && (
716 <Button
717 label={_(msg`See more suggested profiles`)}
718 onPress={() => {
719 followDialogControl.open()
720 logEvent('suggestedUser:seeMore', {
721 logContext: isFeedContext ? 'Explore' : 'Profile',
722 })
723 }}>
724 {({hovered}) => (
725 <Text
726 style={[
727 a.text_sm,
728 {color: t.palette.primary_500},
729 hovered &&
730 web({
731 textDecorationLine: 'underline',
732 textDecorationColor: t.palette.primary_500,
733 }),
734 ]}>
735 <Trans>See more</Trans>
736 </Text>
737 )}
738 </Button>
739 )}
740 </View>
741
742 <FollowDialogWithoutGuide control={followDialogControl} />
743
744 {gtMobile ? (
745 <View style={[a.p_lg, a.pt_md]}>
746 <View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
747 {content}
748 </View>
749 </View>
750 ) : (
751 <BlockDrawerGesture>
752 <ScrollView
753 horizontal
754 showsHorizontalScrollIndicator={false}
755 contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
756 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
757 decelerationRate="fast">
758 {content}
759
760 {!isProfileHeaderContext && (
761 <SeeMoreSuggestedProfilesCard
762 onPress={() => {
763 followDialogControl.open()
764 logger.metric('suggestedUser:seeMore', {
765 logContext: 'Explore',
766 })
767 }}
768 />
769 )}
770 </ScrollView>
771 </BlockDrawerGesture>
772 )}
773 </View>
774 )
775 }
776}
777
778function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
779 const {_} = useLingui()
780
781 return (
782 <Button
783 label={_(msg`Browse more accounts`)}
784 onPress={onPress}
785 style={[
786 a.flex_col,
787 a.align_center,
788 a.justify_center,
789 a.gap_sm,
790 a.p_md,
791 a.rounded_lg,
792 {width: FINAL_CARD_WIDTH},
793 ]}>
794 <ButtonIcon icon={ArrowRight} size="lg" />
795 <ButtonText
796 style={[a.text_md, a.font_medium, a.leading_snug, a.text_center]}>
797 <Trans>See more</Trans>
798 </ButtonText>
799 </Button>
800 )
801}
802
803export function SuggestedFeeds() {
804 const numFeedsToDisplay = 3
805 const t = useTheme()
806 const {_} = useLingui()
807 const {data, isLoading, error} = useGetPopularFeedsQuery({
808 limit: numFeedsToDisplay,
809 })
810 const navigation = useNavigation<NavigationProp>()
811 const {gtMobile} = useBreakpoints()
812
813 const feeds = React.useMemo(() => {
814 const items: AppBskyFeedDefs.GeneratorView[] = []
815
816 if (!data) return items
817
818 for (const page of data.pages) {
819 for (const feed of page.feeds) {
820 items.push(feed)
821 }
822 }
823
824 return items
825 }, [data])
826
827 const content = isLoading ? (
828 Array(numFeedsToDisplay)
829 .fill(0)
830 .map((_, i) => <SuggestedFeedsCardPlaceholder key={i} />)
831 ) : error || !feeds ? null : (
832 <>
833 {feeds.slice(0, numFeedsToDisplay).map(feed => (
834 <FeedCard.Link
835 key={feed.uri}
836 view={feed}
837 onPress={() => {
838 logEvent('feed:interstitial:feedCard:press', {})
839 }}>
840 {({hovered, pressed}) => (
841 <CardOuter
842 style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
843 <FeedCard.Outer>
844 <FeedCard.Header>
845 <FeedCard.Avatar src={feed.avatar} />
846 <FeedCard.TitleAndByline
847 title={feed.displayName}
848 creator={feed.creator}
849 />
850 </FeedCard.Header>
851 <FeedCard.Description
852 description={feed.description}
853 numberOfLines={3}
854 />
855 </FeedCard.Outer>
856 </CardOuter>
857 )}
858 </FeedCard.Link>
859 ))}
860 </>
861 )
862
863 return error ? null : (
864 <View
865 style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
866 <View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
867 <Text
868 style={[
869 a.flex_1,
870 a.text_lg,
871 a.font_semi_bold,
872 t.atoms.text_contrast_medium,
873 ]}>
874 <Trans>Some other feeds you might like</Trans>
875 </Text>
876 <Hashtag fill={t.atoms.text_contrast_low.color} />
877 </View>
878
879 {gtMobile ? (
880 <View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
881 {content}
882
883 <View
884 style={[
885 a.flex_row,
886 a.justify_end,
887 a.align_center,
888 a.pt_xs,
889 a.gap_md,
890 ]}>
891 <InlineLinkText
892 label={_(msg`Browse more suggestions`)}
893 to="/search"
894 style={[t.atoms.text_contrast_medium]}>
895 <Trans>Browse more suggestions</Trans>
896 </InlineLinkText>
897 <ArrowRight size="sm" fill={t.atoms.text_contrast_medium.color} />
898 </View>
899 </View>
900 ) : (
901 <BlockDrawerGesture>
902 <ScrollView
903 horizontal
904 showsHorizontalScrollIndicator={false}
905 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
906 decelerationRate="fast">
907 <View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
908 {content}
909
910 <Button
911 label={_(msg`Browse more feeds on the Explore page`)}
912 onPress={() => {
913 navigation.navigate('SearchTab')
914 }}
915 style={[a.flex_col]}>
916 <CardOuter>
917 <View style={[a.flex_1, a.justify_center]}>
918 <View style={[a.flex_row, a.px_lg]}>
919 <Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
920 <Trans>
921 Browse more suggestions on the Explore page
922 </Trans>
923 </Text>
924
925 <ArrowRight size="xl" />
926 </View>
927 </View>
928 </CardOuter>
929 </Button>
930 </View>
931 </ScrollView>
932 </BlockDrawerGesture>
933 )}
934 </View>
935 )
936}
937
938export function ProgressGuide() {
939 const t = useTheme()
940 return (
941 <View style={[t.atoms.border_contrast_low, a.px_lg, a.py_lg, a.pb_lg]}>
942 <ProgressGuideList />
943 </View>
944 )
945}