Bluesky app fork with some witchin' additions 馃挮
1
fork

Configure Feed

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

at 492603d56ffc1f60394b5857a4b38e00bbcdc029 609 lines 18 kB view raw
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}