mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at ruby-v 528 lines 16 kB view raw
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}