mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React from 'react'
2import {View} from 'react-native'
3import {ScrollView} from 'react-native-gesture-handler'
4import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
5import {msg, Trans} from '@lingui/macro'
6import {useLingui} from '@lingui/react'
7import {useNavigation} from '@react-navigation/native'
8
9import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
10import {NavigationProp} from '#/lib/routes/types'
11import {logEvent} from '#/lib/statsig/statsig'
12import {logger} from '#/logger'
13import {useModerationOpts} from '#/state/preferences/moderation-opts'
14import {useGetPopularFeedsQuery} from '#/state/queries/feed'
15import {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 {useProgressGuide} from '#/state/shell/progress-guide'
20import * as userActionHistory from '#/state/userActionHistory'
21import {SeenPost} from '#/state/userActionHistory'
22import {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf'
23import {Button} from '#/components/Button'
24import * as FeedCard from '#/components/FeedCard'
25import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
26import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
27import {PersonPlus_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
28import {InlineLinkText} from '#/components/Link'
29import * as ProfileCard from '#/components/ProfileCard'
30import {Text} from '#/components/Typography'
31import {ProgressGuideList} from './ProgressGuide/List'
32
33const MOBILE_CARD_WIDTH = 300
34
35function CardOuter({
36 children,
37 style,
38}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
39 const t = useTheme()
40 const {gtMobile} = useBreakpoints()
41 return (
42 <View
43 style={[
44 a.w_full,
45 a.p_lg,
46 a.rounded_md,
47 a.border,
48 t.atoms.bg,
49 t.atoms.border_contrast_low,
50 !gtMobile && {
51 width: MOBILE_CARD_WIDTH,
52 },
53 style,
54 ]}>
55 {children}
56 </View>
57 )
58}
59
60export function SuggestedFollowPlaceholder() {
61 const t = useTheme()
62 return (
63 <CardOuter style={[a.gap_md, t.atoms.border_contrast_low]}>
64 <ProfileCard.Header>
65 <ProfileCard.AvatarPlaceholder />
66 <ProfileCard.NameAndHandlePlaceholder />
67 </ProfileCard.Header>
68
69 <ProfileCard.DescriptionPlaceholder numberOfLines={2} />
70 </CardOuter>
71 )
72}
73
74export function SuggestedFeedsCardPlaceholder() {
75 const t = useTheme()
76 return (
77 <CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
78 <FeedCard.Header>
79 <FeedCard.AvatarPlaceholder />
80 <FeedCard.TitleAndBylinePlaceholder creator />
81 </FeedCard.Header>
82
83 <FeedCard.DescriptionPlaceholder />
84 </CardOuter>
85 )
86}
87
88function getRank(seenPost: SeenPost): string {
89 let tier: string
90 if (seenPost.feedContext === 'popfriends') {
91 tier = 'a'
92 } else if (seenPost.feedContext?.startsWith('cluster')) {
93 tier = 'b'
94 } else if (seenPost.feedContext === 'popcluster') {
95 tier = 'c'
96 } else if (seenPost.feedContext?.startsWith('ntpc')) {
97 tier = 'd'
98 } else if (seenPost.feedContext?.startsWith('t-')) {
99 tier = 'e'
100 } else if (seenPost.feedContext === 'nettop') {
101 tier = 'f'
102 } else {
103 tier = 'g'
104 }
105 let score = Math.round(
106 Math.log(
107 1 + seenPost.likeCount + seenPost.repostCount + seenPost.replyCount,
108 ),
109 )
110 if (seenPost.isFollowedBy || Math.random() > 0.9) {
111 score *= 2
112 }
113 const rank = 100 - score
114 return `${tier}-${rank}`
115}
116
117function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
118 const rankA = getRank(postA)
119 const rankB = getRank(postB)
120 // Yes, we're comparing strings here.
121 // The "larger" string means a worse rank.
122 if (rankA > rankB) {
123 return 1
124 } else if (rankA < rankB) {
125 return -1
126 } else {
127 return 0
128 }
129}
130
131function useExperimentalSuggestedUsersQuery() {
132 const {currentAccount} = useSession()
133 const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
134 const dids = React.useMemo(() => {
135 const {likes, follows, followSuggestions, seen} = userActionSnapshot
136 const likeDids = likes
137 .map(l => new AtUri(l))
138 .map(uri => uri.host)
139 .filter(did => !follows.includes(did))
140 let suggestedDids: string[] = []
141 if (followSuggestions.length > 0) {
142 suggestedDids = [
143 // It's ok if these will pick the same item (weighed by its frequency)
144 followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
145 followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
146 followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
147 followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
148 ]
149 }
150 const seenDids = seen
151 .sort(sortSeenPosts)
152 .map(l => new AtUri(l.uri))
153 .map(uri => uri.host)
154 return [...new Set([...suggestedDids, ...likeDids, ...seenDids])].filter(
155 did => did !== currentAccount?.did,
156 )
157 }, [userActionSnapshot, currentAccount])
158 const {data, isLoading, error} = useProfilesQuery({
159 handles: dids.slice(0, 16),
160 })
161
162 const profiles = data
163 ? data.profiles.filter(profile => {
164 return !profile.viewer?.following
165 })
166 : []
167
168 return {
169 isLoading,
170 error,
171 profiles: profiles.slice(0, 6),
172 }
173}
174
175export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
176 const {currentAccount} = useSession()
177 const [feedType, feedUriOrDid] = feed.split('|')
178 if (feedType === 'author') {
179 if (currentAccount?.did === feedUriOrDid) {
180 return null
181 } else {
182 return <SuggestedFollowsProfile did={feedUriOrDid} />
183 }
184 } else {
185 return <SuggestedFollowsHome />
186 }
187}
188
189export function SuggestedFollowsProfile({did}: {did: string}) {
190 const {
191 isLoading: isSuggestionsLoading,
192 data,
193 error,
194 } = useSuggestedFollowsByActorQuery({
195 did,
196 })
197 return (
198 <ProfileGrid
199 isSuggestionsLoading={isSuggestionsLoading}
200 profiles={data?.suggestions ?? []}
201 error={error}
202 viewContext="profile"
203 />
204 )
205}
206
207export function SuggestedFollowsHome() {
208 const {
209 isLoading: isSuggestionsLoading,
210 profiles,
211 error,
212 } = useExperimentalSuggestedUsersQuery()
213 return (
214 <ProfileGrid
215 isSuggestionsLoading={isSuggestionsLoading}
216 profiles={profiles}
217 error={error}
218 viewContext="feed"
219 />
220 )
221}
222
223export function ProfileGrid({
224 isSuggestionsLoading,
225 error,
226 profiles,
227 viewContext = 'feed',
228}: {
229 isSuggestionsLoading: boolean
230 profiles: AppBskyActorDefs.ProfileViewDetailed[]
231 error: Error | null
232 viewContext: 'profile' | 'feed'
233}) {
234 const t = useTheme()
235 const {_} = useLingui()
236 const moderationOpts = useModerationOpts()
237 const navigation = useNavigation<NavigationProp>()
238 const {gtMobile} = useBreakpoints()
239 const isLoading = isSuggestionsLoading || !moderationOpts
240 const maxLength = gtMobile ? 4 : 6
241
242 const content = isLoading ? (
243 Array(maxLength)
244 .fill(0)
245 .map((_, i) => (
246 <View
247 key={i}
248 style={[gtMobile && web([a.flex_0, {width: 'calc(50% - 6px)'}])]}>
249 <SuggestedFollowPlaceholder />
250 </View>
251 ))
252 ) : error || !profiles.length ? null : (
253 <>
254 {profiles.slice(0, maxLength).map(profile => (
255 <ProfileCard.Link
256 key={profile.did}
257 profile={profile}
258 onPress={() => {
259 logEvent('feed:interstitial:profileCard:press', {})
260 }}
261 style={[
262 a.flex_1,
263 gtMobile && web([a.flex_0, {width: 'calc(50% - 6px)'}]),
264 ]}>
265 {({hovered, pressed}) => (
266 <CardOuter
267 style={[
268 a.flex_1,
269 (hovered || pressed) && t.atoms.border_contrast_high,
270 ]}>
271 <ProfileCard.Outer>
272 <ProfileCard.Header>
273 <ProfileCard.Avatar
274 profile={profile}
275 moderationOpts={moderationOpts}
276 />
277 <ProfileCard.NameAndHandle
278 profile={profile}
279 moderationOpts={moderationOpts}
280 />
281 <ProfileCard.FollowButton
282 profile={profile}
283 moderationOpts={moderationOpts}
284 logContext="FeedInterstitial"
285 color="secondary_inverted"
286 shape="round"
287 />
288 </ProfileCard.Header>
289 <ProfileCard.Description profile={profile} numberOfLines={2} />
290 </ProfileCard.Outer>
291 </CardOuter>
292 )}
293 </ProfileCard.Link>
294 ))}
295 </>
296 )
297
298 if (error || (!isLoading && profiles.length < 4)) {
299 logger.debug(`Not enough profiles to show suggested follows`)
300 return null
301 }
302
303 return (
304 <View
305 style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
306 <View
307 style={[
308 a.p_lg,
309 a.pb_xs,
310 a.flex_row,
311 a.align_center,
312 a.justify_between,
313 ]}>
314 <Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}>
315 {viewContext === 'profile' ? (
316 <Trans>Similar accounts</Trans>
317 ) : (
318 <Trans>Suggested for you</Trans>
319 )}
320 </Text>
321 <Person fill={t.atoms.text_contrast_low.color} size="sm" />
322 </View>
323
324 {gtMobile ? (
325 <View style={[a.flex_1, a.px_lg, a.pt_sm, a.pb_lg, a.gap_md]}>
326 <View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_sm]}>
327 {content}
328 </View>
329
330 <View style={[a.flex_row, a.justify_end, a.align_center, a.gap_md]}>
331 <InlineLinkText
332 label={_(msg`Browse more suggestions`)}
333 to="/search"
334 style={[t.atoms.text_contrast_medium]}>
335 <Trans>Browse more suggestions</Trans>
336 </InlineLinkText>
337 <Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
338 </View>
339 </View>
340 ) : (
341 <ScrollView
342 horizontal
343 showsHorizontalScrollIndicator={false}
344 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
345 decelerationRate="fast">
346 <View style={[a.px_lg, a.pt_sm, a.pb_lg, a.flex_row, a.gap_md]}>
347 {content}
348
349 <Button
350 label={_(msg`Browse more accounts on the Explore page`)}
351 onPress={() => {
352 navigation.navigate('SearchTab')
353 }}>
354 <CardOuter style={[a.flex_1, {borderWidth: 0}]}>
355 <View style={[a.flex_1, a.justify_center]}>
356 <View style={[a.flex_row, a.px_lg]}>
357 <Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
358 <Trans>Browse more suggestions on the Explore page</Trans>
359 </Text>
360
361 <Arrow size="xl" />
362 </View>
363 </View>
364 </CardOuter>
365 </Button>
366 </View>
367 </ScrollView>
368 )}
369 </View>
370 )
371}
372
373export function SuggestedFeeds() {
374 const numFeedsToDisplay = 3
375 const t = useTheme()
376 const {_} = useLingui()
377 const {data, isLoading, error} = useGetPopularFeedsQuery({
378 limit: numFeedsToDisplay,
379 })
380 const navigation = useNavigation<NavigationProp>()
381 const {gtMobile} = useBreakpoints()
382
383 const feeds = React.useMemo(() => {
384 const items: AppBskyFeedDefs.GeneratorView[] = []
385
386 if (!data) return items
387
388 for (const page of data.pages) {
389 for (const feed of page.feeds) {
390 items.push(feed)
391 }
392 }
393
394 return items
395 }, [data])
396
397 const content = isLoading ? (
398 Array(numFeedsToDisplay)
399 .fill(0)
400 .map((_, i) => <SuggestedFeedsCardPlaceholder key={i} />)
401 ) : error || !feeds ? null : (
402 <>
403 {feeds.slice(0, numFeedsToDisplay).map(feed => (
404 <FeedCard.Link
405 key={feed.uri}
406 view={feed}
407 onPress={() => {
408 logEvent('feed:interstitial:feedCard:press', {})
409 }}>
410 {({hovered, pressed}) => (
411 <CardOuter
412 style={[
413 a.flex_1,
414 (hovered || pressed) && t.atoms.border_contrast_high,
415 ]}>
416 <FeedCard.Outer>
417 <FeedCard.Header>
418 <FeedCard.Avatar src={feed.avatar} />
419 <FeedCard.TitleAndByline
420 title={feed.displayName}
421 creator={feed.creator}
422 />
423 </FeedCard.Header>
424 <FeedCard.Description
425 description={feed.description}
426 numberOfLines={3}
427 />
428 </FeedCard.Outer>
429 </CardOuter>
430 )}
431 </FeedCard.Link>
432 ))}
433 </>
434 )
435
436 return error ? null : (
437 <View
438 style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
439 <View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
440 <Text
441 style={[
442 a.flex_1,
443 a.text_lg,
444 a.font_bold,
445 t.atoms.text_contrast_medium,
446 ]}>
447 <Trans>Some other feeds you might like</Trans>
448 </Text>
449 <Hashtag fill={t.atoms.text_contrast_low.color} />
450 </View>
451
452 {gtMobile ? (
453 <View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
454 {content}
455
456 <View
457 style={[
458 a.flex_row,
459 a.justify_end,
460 a.align_center,
461 a.pt_xs,
462 a.gap_md,
463 ]}>
464 <InlineLinkText
465 label={_(msg`Browse more suggestions`)}
466 to="/search"
467 style={[t.atoms.text_contrast_medium]}>
468 <Trans>Browse more suggestions</Trans>
469 </InlineLinkText>
470 <Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
471 </View>
472 </View>
473 ) : (
474 <ScrollView
475 horizontal
476 showsHorizontalScrollIndicator={false}
477 snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
478 decelerationRate="fast">
479 <View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
480 {content}
481
482 <Button
483 label={_(msg`Browse more feeds on the Explore page`)}
484 onPress={() => {
485 navigation.navigate('SearchTab')
486 }}
487 style={[a.flex_col]}>
488 <CardOuter style={[a.flex_1]}>
489 <View style={[a.flex_1, a.justify_center]}>
490 <View style={[a.flex_row, a.px_lg]}>
491 <Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
492 <Trans>Browse more suggestions on the Explore page</Trans>
493 </Text>
494
495 <Arrow size="xl" />
496 </View>
497 </View>
498 </CardOuter>
499 </Button>
500 </View>
501 </ScrollView>
502 )}
503 </View>
504 )
505}
506
507export function ProgressGuide() {
508 const t = useTheme()
509 const {isDesktop} = useWebMediaQueries()
510 const guide = useProgressGuide('like-10-and-follow-7')
511
512 if (isDesktop) {
513 return null
514 }
515
516 return guide ? (
517 <View
518 style={[
519 a.border_t,
520 t.atoms.border_contrast_low,
521 a.px_lg,
522 a.py_lg,
523 a.pb_lg,
524 ]}>
525 <ProgressGuideList />
526 </View>
527 ) : null
528}