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