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