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