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 {type AppBskyFeedDefs, AtUri} from '@atproto/api'
4import {msg, Trans} from '@lingui/macro'
5import {useLingui} from '@lingui/react'
6import {useNavigation} from '@react-navigation/native'
7
8import {type NavigationProp} from '#/lib/routes/types'
9import {logEvent} from '#/lib/statsig/statsig'
10import {logger} from '#/logger'
11import {type MetricEvents} from '#/logger/metrics'
12import {isIOS} from '#/platform/detection'
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 {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
19import {useSession} from '#/state/session'
20import * as userActionHistory from '#/state/userActionHistory'
21import {type SeenPost} from '#/state/userActionHistory'
22import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
23import {
24 atoms as a,
25 useBreakpoints,
26 useTheme,
27 type ViewStyleProp,
28 web,
29} from '#/alf'
30import {Button, ButtonIcon, ButtonText} from '#/components/Button'
31import {useDialogControl} from '#/components/Dialog'
32import * as FeedCard from '#/components/FeedCard'
33import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow'
34import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
35import {InlineLinkText} from '#/components/Link'
36import * as ProfileCard from '#/components/ProfileCard'
37import {Text} from '#/components/Typography'
38import type * as bsky from '#/types/bsky'
39import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog'
40import {ProgressGuideList} from './ProgressGuide/List'
41
42const MOBILE_CARD_WIDTH = 165
43const FINAL_CARD_WIDTH = 120
44
45function CardOuter({
46 children,
47 style,
48}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
49 const t = useTheme()
50 const {gtMobile} = useBreakpoints()
51 return (
52 <View
53 style={[
54 a.flex_1,
55 a.w_full,
56 a.p_md,
57 a.rounded_lg,
58 a.border,
59 t.atoms.bg,
60 t.atoms.shadow_sm,
61 t.atoms.border_contrast_low,
62 !gtMobile && {
63 width: MOBILE_CARD_WIDTH,
64 },
65 style,
66 ]}>
67 {children}
68 </View>
69 )
70}
71
72export function SuggestedFollowPlaceholder() {
73 return (
74 <CardOuter>
75 <ProfileCard.Outer>
76 <View
77 style={[a.flex_col, a.align_center, a.gap_sm, a.pb_sm, a.mb_auto]}>
78 <ProfileCard.AvatarPlaceholder size={88} />
79 <ProfileCard.NamePlaceholder />
80 <View style={[a.w_full]}>
81 <ProfileCard.DescriptionPlaceholder numberOfLines={2} />
82 </View>
83 </View>
84
85 <ProfileCard.FollowButtonPlaceholder />
86 </ProfileCard.Outer>
87 </CardOuter>
88 )
89}
90
91export function SuggestedFeedsCardPlaceholder() {
92 return (
93 <CardOuter style={[a.gap_sm]}>
94 <FeedCard.Header>
95 <FeedCard.AvatarPlaceholder />
96 <FeedCard.TitleAndBylinePlaceholder creator />
97 </FeedCard.Header>
98
99 <FeedCard.DescriptionPlaceholder />
100 </CardOuter>
101 )
102}
103
104function getRank(seenPost: SeenPost): string {
105 let tier: string
106 if (seenPost.feedContext === 'popfriends') {
107 tier = 'a'
108 } else if (seenPost.feedContext?.startsWith('cluster')) {
109 tier = 'b'
110 } else if (seenPost.feedContext === 'popcluster') {
111 tier = 'c'
112 } else if (seenPost.feedContext?.startsWith('ntpc')) {
113 tier = 'd'
114 } else if (seenPost.feedContext?.startsWith('t-')) {
115 tier = 'e'
116 } else if (seenPost.feedContext === 'nettop') {
117 tier = 'f'
118 } else {
119 tier = 'g'
120 }
121 let score = Math.round(
122 Math.log(
123 1 + seenPost.likeCount + seenPost.repostCount + seenPost.replyCount,
124 ),
125 )
126 if (seenPost.isFollowedBy || Math.random() > 0.9) {
127 score *= 2
128 }
129 const rank = 100 - score
130 return `${tier}-${rank}`
131}
132
133function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
134 const rankA = getRank(postA)
135 const rankB = getRank(postB)
136 // Yes, we're comparing strings here.
137 // The "larger" string means a worse rank.
138 if (rankA > rankB) {
139 return 1
140 } else if (rankA < rankB) {
141 return -1
142 } else {
143 return 0
144 }
145}
146
147function useExperimentalSuggestedUsersQuery() {
148 const {currentAccount} = useSession()
149 const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
150 const dids = React.useMemo(() => {
151 const {likes, follows, followSuggestions, seen} = userActionSnapshot
152 const likeDids = likes
153 .map(l => new AtUri(l))
154 .map(uri => uri.host)
155 .filter(did => !follows.includes(did))
156 let suggestedDids: string[] = []
157 if (followSuggestions.length > 0) {
158 suggestedDids = [
159 // It's ok if these will pick the same item (weighed by its frequency)
160 followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
161 followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
162 followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
163 followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
164 ]
165 }
166 const seenDids = seen
167 .sort(sortSeenPosts)
168 .map(l => new AtUri(l.uri))
169 .map(uri => uri.host)
170 return [...new Set([...suggestedDids, ...likeDids, ...seenDids])].filter(
171 did => did !== currentAccount?.did,
172 )
173 }, [userActionSnapshot, currentAccount])
174 const {data, isLoading, error} = useProfilesQuery({
175 handles: dids.slice(0, 16),
176 })
177
178 const profiles = data
179 ? data.profiles.filter(profile => {
180 return !profile.viewer?.following
181 })
182 : []
183
184 return {
185 isLoading,
186 error,
187 profiles: profiles.slice(0, 6),
188 }
189}
190
191export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
192 const {currentAccount} = useSession()
193 const [feedType, feedUriOrDid] = feed.split('|')
194 if (feedType === 'author') {
195 if (currentAccount?.did === feedUriOrDid) {
196 return null
197 } else {
198 return <SuggestedFollowsProfile did={feedUriOrDid} />
199 }
200 } else {
201 return <SuggestedFollowsHome />
202 }
203}
204
205export function SuggestedFollowsProfile({did}: {did: string}) {
206 const {
207 isLoading: isSuggestionsLoading,
208 data,
209 error,
210 } = useSuggestedFollowsByActorQuery({
211 did,
212 })
213 return (
214 <ProfileGrid
215 isSuggestionsLoading={isSuggestionsLoading}
216 profiles={data?.suggestions ?? []}
217 recId={data?.recId}
218 error={error}
219 viewContext="profile"
220 />
221 )
222}
223
224export function SuggestedFollowsHome() {
225 const {
226 isLoading: isSuggestionsLoading,
227 profiles,
228 error,
229 } = useExperimentalSuggestedUsersQuery()
230 return (
231 <ProfileGrid
232 isSuggestionsLoading={isSuggestionsLoading}
233 profiles={profiles}
234 error={error}
235 viewContext="feed"
236 />
237 )
238}
239
240export function ProfileGrid({
241 isSuggestionsLoading,
242 error,
243 profiles,
244 recId,
245 viewContext = 'feed',
246 isVisible = true,
247}: {
248 isSuggestionsLoading: boolean
249 profiles: bsky.profile.AnyProfileView[]
250 recId?: number
251 error: Error | null
252 viewContext: 'profile' | 'profileHeader' | 'feed'
253 isVisible?: boolean
254}) {
255 const t = useTheme()
256 const {_} = useLingui()
257 const moderationOpts = useModerationOpts()
258 const {gtMobile} = useBreakpoints()
259 const followDialogControl = useDialogControl()
260
261 const isLoading = isSuggestionsLoading || !moderationOpts
262 const isProfileHeaderContext = viewContext === 'profileHeader'
263 const isFeedContext = viewContext === 'feed'
264
265 const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6
266 const minLength = gtMobile ? 3 : 4
267
268 // hide similar accounts
269 const hideSimilarAccountsRecomm = useHideSimilarAccountsRecomm()
270
271 // Track seen profiles
272 const seenProfilesRef = useRef<Set<string>>(new Set())
273 const containerRef = useRef<View>(null)
274 const hasTrackedRef = useRef(false)
275 const logContext: MetricEvents['suggestedUser:seen']['logContext'] =
276 isFeedContext
277 ? 'InterstitialDiscover'
278 : isProfileHeaderContext
279 ? 'Profile'
280 : 'InterstitialProfile'
281
282 // Callback to fire seen events
283 const fireSeen = useCallback(() => {
284 if (isLoading || error || !profiles.length) return
285 if (hasTrackedRef.current) return
286 hasTrackedRef.current = true
287
288 const profilesToShow = profiles.slice(0, maxLength)
289 profilesToShow.forEach((profile, index) => {
290 if (!seenProfilesRef.current.has(profile.did)) {
291 seenProfilesRef.current.add(profile.did)
292 logger.metric(
293 'suggestedUser:seen',
294 {
295 logContext,
296 recId,
297 position: index,
298 suggestedDid: profile.did,
299 category: null,
300 },
301 {statsig: true},
302 )
303 }
304 })
305 }, [isLoading, error, profiles, maxLength, logContext, recId])
306
307 // For profile header, fire when isVisible becomes true
308 useEffect(() => {
309 if (isProfileHeaderContext) {
310 if (!isVisible) {
311 hasTrackedRef.current = false
312 return
313 }
314 fireSeen()
315 }
316 }, [isVisible, isProfileHeaderContext, fireSeen])
317
318 // For feed interstitials, use IntersectionObserver to detect actual visibility
319 useEffect(() => {
320 if (isProfileHeaderContext) return // handled above
321 if (isLoading || error || !profiles.length) return
322
323 const node = containerRef.current
324 if (!node) return
325
326 // Use IntersectionObserver on web to detect when actually visible
327 if (typeof IntersectionObserver !== 'undefined') {
328 const observer = new IntersectionObserver(
329 entries => {
330 if (entries[0]?.isIntersecting) {
331 fireSeen()
332 observer.disconnect()
333 }
334 },
335 {threshold: 0.5},
336 )
337 // @ts-ignore - web only
338 observer.observe(node)
339 return () => observer.disconnect()
340 } else {
341 // On native, delay slightly to account for layout shifts during hydration
342 const timeout = setTimeout(() => {
343 fireSeen()
344 }, 500)
345 return () => clearTimeout(timeout)
346 }
347 }, [isProfileHeaderContext, isLoading, error, profiles.length, fireSeen])
348
349 const content = isLoading
350 ? Array(maxLength)
351 .fill(0)
352 .map((_, i) => (
353 <View
354 key={i}
355 style={[
356 a.flex_1,
357 gtMobile &&
358 web([
359 a.flex_0,
360 a.flex_grow,
361 {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
362 ]),
363 ]}>
364 <SuggestedFollowPlaceholder />
365 </View>
366 ))
367 : error || !profiles.length
368 ? null
369 : profiles.slice(0, maxLength).map((profile, index) => (
370 <ProfileCard.Link
371 key={profile.did}
372 profile={profile}
373 onPress={() => {
374 logEvent('suggestedUser:press', {
375 logContext: isFeedContext
376 ? 'InterstitialDiscover'
377 : 'InterstitialProfile',
378 recId,
379 position: index,
380 suggestedDid: profile.did,
381 category: null,
382 })
383 }}
384 style={[
385 a.flex_1,
386 gtMobile &&
387 web([
388 a.flex_0,
389 a.flex_grow,
390 {width: `calc(30% - ${a.gap_md.gap / 2}px)`},
391 ]),
392 ]}>
393 {({hovered, pressed}) => (
394 <CardOuter
395 style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
396 <ProfileCard.Outer>
397 <View
398 style={[
399 a.flex_col,
400 a.align_center,
401 a.gap_sm,
402 a.pb_sm,
403 a.mb_auto,
404 ]}>
405 <ProfileCard.Avatar
406 profile={profile}
407 moderationOpts={moderationOpts}
408 disabledPreview
409 size={88}
410 />
411 <View style={[a.flex_col, a.align_center, a.max_w_full]}>
412 <ProfileCard.Name
413 profile={profile}
414 moderationOpts={moderationOpts}
415 />
416 <ProfileCard.Description
417 profile={profile}
418 numberOfLines={2}
419 style={[
420 t.atoms.text_contrast_medium,
421 a.text_center,
422 a.text_xs,
423 ]}
424 />
425 </View>
426 </View>
427
428 <ProfileCard.FollowButton
429 profile={profile}
430 moderationOpts={moderationOpts}
431 logContext="FeedInterstitial"
432 withIcon={false}
433 style={[a.rounded_sm]}
434 onFollow={() => {
435 logEvent('suggestedUser:follow', {
436 logContext: isFeedContext
437 ? 'InterstitialDiscover'
438 : 'InterstitialProfile',
439 location: 'Card',
440 recId,
441 position: index,
442 suggestedDid: profile.did,
443 category: null,
444 })
445 }}
446 />
447 </ProfileCard.Outer>
448 </CardOuter>
449 )}
450 </ProfileCard.Link>
451 ))
452
453 if (error || (!isLoading && profiles.length < minLength)) {
454 logger.debug(`Not enough profiles to show suggested follows`)
455 return null
456 }
457
458 if (!hideSimilarAccountsRecomm) {
459 return (
460 <View
461 ref={containerRef}
462 style={[
463 !isProfileHeaderContext && a.border_t,
464 t.atoms.border_contrast_low,
465 t.atoms.bg_contrast_25,
466 ]}
467 pointerEvents={isIOS ? 'auto' : 'box-none'}>
468 <View
469 style={[
470 a.px_lg,
471 a.pt_md,
472 a.flex_row,
473 a.align_center,
474 a.justify_between,
475 ]}
476 pointerEvents={isIOS ? 'auto' : 'box-none'}>
477 <Text style={[a.text_sm, a.font_semi_bold, t.atoms.text]}>
478 {isFeedContext ? (
479 <Trans>Suggested for you</Trans>
480 ) : (
481 <Trans>Similar accounts</Trans>
482 )}
483 </Text>
484 {!isProfileHeaderContext && (
485 <Button
486 label={_(msg`See more suggested profiles`)}
487 onPress={() => {
488 followDialogControl.open()
489 logEvent('suggestedUser:seeMore', {
490 logContext: isFeedContext ? 'Explore' : 'Profile',
491 })
492 }}>
493 {({hovered}) => (
494 <Text
495 style={[
496 a.text_sm,
497 {color: t.palette.primary_500},
498 hovered &&
499 web({
500 textDecorationLine: 'underline',
501 textDecorationColor: t.palette.primary_500,
502 }),
503 ]}>
504 <Trans>See more</Trans>
505 </Text>
506 )}
507 </Button>
508 )}
509 </View>
510
511 <FollowDialogWithoutGuide control={followDialogControl} />
512
513 {gtMobile ? (
514 <View style={[a.p_lg, a.pt_md]}>
515 <View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
516 {content}
517 </View>
518 </View>
519 ) : (
520 <BlockDrawerGesture>
521 <ScrollView
522 horizontal
523 showsHorizontalScrollIndicator={false}
524 contentContainerStyle={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}
525 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
526 decelerationRate="fast">
527 {content}
528
529 {!isProfileHeaderContext && (
530 <SeeMoreSuggestedProfilesCard
531 onPress={() => {
532 followDialogControl.open()
533 logger.metric('suggestedUser:seeMore', {
534 logContext: 'Explore',
535 })
536 }}
537 />
538 )}
539 </ScrollView>
540 </BlockDrawerGesture>
541 )}
542 </View>
543 )
544 }
545}
546
547function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
548 const t = useTheme()
549 const {_} = useLingui()
550
551 return (
552 <Button
553 label={_(msg`Browse more accounts`)}
554 onPress={onPress}
555 style={[
556 a.flex_col,
557 a.align_center,
558 a.justify_center,
559 a.gap_sm,
560 a.p_md,
561 a.rounded_lg,
562 t.atoms.shadow_sm,
563 {width: FINAL_CARD_WIDTH},
564 ]}>
565 <ButtonIcon icon={ArrowRight} size="lg" />
566 <ButtonText
567 style={[a.text_md, a.font_medium, a.leading_snug, a.text_center]}>
568 <Trans>See more</Trans>
569 </ButtonText>
570 </Button>
571 )
572}
573
574export function SuggestedFeeds() {
575 const numFeedsToDisplay = 3
576 const t = useTheme()
577 const {_} = useLingui()
578 const {data, isLoading, error} = useGetPopularFeedsQuery({
579 limit: numFeedsToDisplay,
580 })
581 const navigation = useNavigation<NavigationProp>()
582 const {gtMobile} = useBreakpoints()
583
584 const feeds = React.useMemo(() => {
585 const items: AppBskyFeedDefs.GeneratorView[] = []
586
587 if (!data) return items
588
589 for (const page of data.pages) {
590 for (const feed of page.feeds) {
591 items.push(feed)
592 }
593 }
594
595 return items
596 }, [data])
597
598 const content = isLoading ? (
599 Array(numFeedsToDisplay)
600 .fill(0)
601 .map((_, i) => <SuggestedFeedsCardPlaceholder key={i} />)
602 ) : error || !feeds ? null : (
603 <>
604 {feeds.slice(0, numFeedsToDisplay).map(feed => (
605 <FeedCard.Link
606 key={feed.uri}
607 view={feed}
608 onPress={() => {
609 logEvent('feed:interstitial:feedCard:press', {})
610 }}>
611 {({hovered, pressed}) => (
612 <CardOuter
613 style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
614 <FeedCard.Outer>
615 <FeedCard.Header>
616 <FeedCard.Avatar src={feed.avatar} />
617 <FeedCard.TitleAndByline
618 title={feed.displayName}
619 creator={feed.creator}
620 />
621 </FeedCard.Header>
622 <FeedCard.Description
623 description={feed.description}
624 numberOfLines={3}
625 />
626 </FeedCard.Outer>
627 </CardOuter>
628 )}
629 </FeedCard.Link>
630 ))}
631 </>
632 )
633
634 return error ? null : (
635 <View
636 style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
637 <View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
638 <Text
639 style={[
640 a.flex_1,
641 a.text_lg,
642 a.font_semi_bold,
643 t.atoms.text_contrast_medium,
644 ]}>
645 <Trans>Some other feeds you might like</Trans>
646 </Text>
647 <Hashtag fill={t.atoms.text_contrast_low.color} />
648 </View>
649
650 {gtMobile ? (
651 <View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
652 {content}
653
654 <View
655 style={[
656 a.flex_row,
657 a.justify_end,
658 a.align_center,
659 a.pt_xs,
660 a.gap_md,
661 ]}>
662 <InlineLinkText
663 label={_(msg`Browse more suggestions`)}
664 to="/search"
665 style={[t.atoms.text_contrast_medium]}>
666 <Trans>Browse more suggestions</Trans>
667 </InlineLinkText>
668 <ArrowRight size="sm" fill={t.atoms.text_contrast_medium.color} />
669 </View>
670 </View>
671 ) : (
672 <BlockDrawerGesture>
673 <ScrollView
674 horizontal
675 showsHorizontalScrollIndicator={false}
676 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
677 decelerationRate="fast">
678 <View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
679 {content}
680
681 <Button
682 label={_(msg`Browse more feeds on the Explore page`)}
683 onPress={() => {
684 navigation.navigate('SearchTab')
685 }}
686 style={[a.flex_col]}>
687 <CardOuter>
688 <View style={[a.flex_1, a.justify_center]}>
689 <View style={[a.flex_row, a.px_lg]}>
690 <Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
691 <Trans>
692 Browse more suggestions on the Explore page
693 </Trans>
694 </Text>
695
696 <ArrowRight size="xl" />
697 </View>
698 </View>
699 </CardOuter>
700 </Button>
701 </View>
702 </ScrollView>
703 </BlockDrawerGesture>
704 )}
705 </View>
706 )
707}
708
709export function ProgressGuide() {
710 const t = useTheme()
711 return (
712 <View style={[t.atoms.border_contrast_low, a.px_lg, a.py_lg, a.pb_lg]}>
713 <ProgressGuideList />
714 </View>
715 )
716}