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

Configure Feed

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

at main 447 lines 14 kB view raw
1import {useCallback, useEffect, useMemo, useRef, useState} from 'react' 2import {View} from 'react-native' 3import {type ModerationOpts} from '@atproto/api' 4import {msg} from '@lingui/core/macro' 5import {useLingui} from '@lingui/react' 6import {Trans} from '@lingui/react/macro' 7import {useMutation, useQueryClient} from '@tanstack/react-query' 8import * as bcp47Match from 'bcp-47-match' 9 10import {wait} from '#/lib/async/wait' 11import {popularInterests, useInterestsDisplayNames} from '#/lib/interests' 12import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' 13import {logger} from '#/logger' 14import {updateProfileShadow} from '#/state/cache/profile-shadow' 15import {useLanguagePrefs} from '#/state/preferences' 16import {useModerationOpts} from '#/state/preferences/moderation-opts' 17import {useAgent, useSession} from '#/state/session' 18import { 19 OnboardingControls, 20 OnboardingPosition, 21 OnboardingTitleText, 22} from '#/screens/Onboarding/Layout' 23import {useOnboardingInternalState} from '#/screens/Onboarding/state' 24import {useSuggestedOnboardingUsers} from '#/screens/Search/util/useSuggestedOnboardingUsers' 25import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf' 26import {Admonition} from '#/components/Admonition' 27import {Button, ButtonIcon, ButtonText} from '#/components/Button' 28import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotate' 29import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 30import {boostInterests, InterestTabs} from '#/components/InterestTabs' 31import {Loader} from '#/components/Loader' 32import * as ProfileCard from '#/components/ProfileCard' 33import * as toast from '#/components/Toast' 34import {useAnalytics} from '#/analytics' 35import {IS_WEB} from '#/env' 36import type * as bsky from '#/types/bsky' 37import {bulkWriteFollows} from '../util' 38 39export function StepSuggestedAccounts() { 40 const {_} = useLingui() 41 const ax = useAnalytics() 42 const t = useTheme() 43 const {gtMobile} = useBreakpoints() 44 const moderationOpts = useModerationOpts() 45 const agent = useAgent() 46 const {currentAccount} = useSession() 47 const queryClient = useQueryClient() 48 49 const {state, dispatch} = useOnboardingInternalState() 50 51 const [selectedInterest, setSelectedInterest] = useState<string | null>(null) 52 // keeping track of who was followed via the follow all button 53 // so we can enable/disable the button without having to dig through the shadow cache 54 const [followedUsers, setFollowedUsers] = useState<string[]>([]) 55 56 /* 57 * Special language handling copied wholesale from the Explore screen 58 */ 59 const {contentLanguages} = useLanguagePrefs() 60 const useFullExperience = useMemo(() => { 61 if (contentLanguages.length === 0) return true 62 return bcp47Match.basicFilter('en', contentLanguages).length > 0 63 }, [contentLanguages]) 64 const interestsDisplayNames = useInterestsDisplayNames() 65 const interests = Object.keys(interestsDisplayNames) 66 .sort(boostInterests(popularInterests)) 67 .sort(boostInterests(state.interestsStepResults.selectedInterests)) 68 69 const { 70 data: suggestedUsers, 71 isLoading, 72 error, 73 isRefetching, 74 refetch, 75 } = useSuggestedOnboardingUsers({ 76 category: selectedInterest || (useFullExperience ? null : interests[0]), 77 search: !useFullExperience, 78 overrideInterests: state.interestsStepResults.selectedInterests, 79 }) 80 81 const isError = !!error 82 const isEmpty = 83 !isLoading && suggestedUsers && suggestedUsers.actors.length === 0 84 85 const followableDids = 86 suggestedUsers?.actors 87 .filter( 88 user => 89 user.did !== currentAccount?.did && 90 !isBlockedOrBlocking(user) && 91 !isMuted(user) && 92 !user.viewer?.following && 93 !followedUsers.includes(user.did), 94 ) 95 .map(user => user.did) ?? [] 96 97 const {mutate: followAll, isPending: isFollowingAll} = useMutation({ 98 onMutate: () => { 99 ax.metric('onboarding:suggestedAccounts:followAllPressed', { 100 tab: selectedInterest ?? 'all', 101 numAccounts: followableDids.length, 102 }) 103 for (let i = 0; i < followableDids.length; i++) { 104 const did = followableDids[i] 105 ax.metric('suggestedUser:follow', { 106 logContext: 'Onboarding', 107 location: 'FollowAll', 108 recId: suggestedUsers?.recId, 109 position: i, 110 suggestedDid: did, 111 category: selectedInterest, 112 }) 113 } 114 }, 115 mutationFn: async () => { 116 for (const did of followableDids) { 117 updateProfileShadow(queryClient, did, { 118 followingUri: 'pending', 119 }) 120 } 121 const uris = await wait(1e3, bulkWriteFollows(agent, followableDids)) 122 for (const did of followableDids) { 123 const uri = uris.get(did) 124 updateProfileShadow(queryClient, did, { 125 followingUri: uri, 126 }) 127 } 128 return followableDids 129 }, 130 onSuccess: newlyFollowed => { 131 toast.show(_(msg`Followed all accounts!`), {type: 'success'}) 132 setFollowedUsers(followed => [...followed, ...newlyFollowed]) 133 }, 134 onError: e => { 135 logger.error( 136 'Failed to follow all suggested accounts during onboarding', 137 { 138 safeMessage: e, 139 }, 140 ) 141 toast.show( 142 _(msg`Failed to follow all suggested accounts, please try again`), 143 {type: 'error'}, 144 ) 145 }, 146 }) 147 148 const canFollowAll = followableDids.length > 0 && !isFollowingAll 149 150 // Track seen profiles - shared ref across all cards 151 const seenProfilesRef = useRef<Set<string>>(new Set()) 152 const onProfileSeen = useCallback( 153 (did: string, position: number) => { 154 if (!seenProfilesRef.current.has(did)) { 155 seenProfilesRef.current.add(did) 156 ax.metric('suggestedUser:seen', { 157 logContext: 'Onboarding', 158 recId: suggestedUsers?.recId, 159 position, 160 suggestedDid: did, 161 category: selectedInterest, 162 }) 163 } 164 }, 165 [ax, selectedInterest, suggestedUsers?.recId], 166 ) 167 168 useEffect(() => { 169 if (error) { 170 logger.error('Failed to fetch suggested accounts during onboarding', { 171 safeMessage: error, 172 }) 173 } 174 }, [error]) 175 176 return ( 177 <View style={[a.align_start, a.gap_sm]} testID="onboardingInterests"> 178 <OnboardingPosition /> 179 <OnboardingTitleText> 180 <Trans comment="Accounts suggested to the user for them to follow"> 181 Suggested for you 182 </Trans> 183 </OnboardingTitleText> 184 185 <View 186 style={[ 187 a.overflow_hidden, 188 a.mt_sm, 189 IS_WEB 190 ? [a.max_w_full, web({minHeight: '100vh'})] 191 : {marginHorizontal: tokens.space.xl * -1}, 192 a.flex_1, 193 a.justify_start, 194 ]}> 195 <TabBar 196 selectedInterest={selectedInterest} 197 onSelectInterest={setSelectedInterest} 198 defaultTabLabel={_( 199 msg({ 200 message: 'All', 201 comment: 'the default tab in the interests tab bar', 202 }), 203 )} 204 selectedInterests={state.interestsStepResults.selectedInterests} 205 /> 206 207 {isLoading || !moderationOpts ? ( 208 <View 209 style={[ 210 a.flex_1, 211 a.mt_md, 212 a.align_center, 213 a.justify_center, 214 {minHeight: 400}, 215 ]}> 216 <Loader size="xl" /> 217 </View> 218 ) : isError ? ( 219 <View style={[a.flex_1, a.px_xl, a.pt_2xl]}> 220 <Admonition type="error"> 221 <Trans> 222 An error occurred while fetching suggested accounts. 223 </Trans> 224 </Admonition> 225 </View> 226 ) : isEmpty ? ( 227 <View style={[a.flex_1, a.px_xl, a.pt_2xl]}> 228 <Admonition type="apology"> 229 <Trans> 230 Sorry, we're unable to load account suggestions at this time. 231 </Trans> 232 </Admonition> 233 </View> 234 ) : ( 235 <View 236 style={[ 237 a.flex_1, 238 a.mt_md, 239 a.border_y, 240 t.atoms.border_contrast_low, 241 IS_WEB && [a.border_x, a.rounded_sm, a.overflow_hidden], 242 ]}> 243 {suggestedUsers?.actors.map((user, index) => ( 244 <SuggestedProfileCard 245 key={user.did} 246 profile={user} 247 moderationOpts={moderationOpts} 248 position={index} 249 category={selectedInterest} 250 onSeen={onProfileSeen} 251 recId={suggestedUsers.recId} 252 /> 253 ))} 254 </View> 255 )} 256 </View> 257 258 <OnboardingControls.Portal> 259 {isError ? ( 260 <View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}> 261 <Button 262 disabled={isRefetching} 263 color="secondary" 264 size="large" 265 label={_(msg`Retry`)} 266 onPress={() => void refetch()}> 267 <ButtonText> 268 <Trans>Retry</Trans> 269 </ButtonText> 270 <ButtonIcon icon={ArrowRotateCounterClockwiseIcon} /> 271 </Button> 272 <Button 273 color="secondary" 274 size="large" 275 label={_(msg`Skip to next step`)} 276 onPress={() => dispatch({type: 'next'})}> 277 <ButtonText> 278 <Trans>Skip</Trans> 279 </ButtonText> 280 </Button> 281 </View> 282 ) : ( 283 <View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}> 284 <Button 285 disabled={!canFollowAll} 286 color="secondary" 287 size="large" 288 label={_(msg`Follow all accounts`)} 289 onPress={() => followAll()}> 290 <ButtonText> 291 <Trans>Follow all</Trans> 292 </ButtonText> 293 <ButtonIcon icon={isFollowingAll ? Loader : PlusIcon} /> 294 </Button> 295 <Button 296 disabled={isFollowingAll} 297 color="primary" 298 size="large" 299 label={_(msg`Continue to next step`)} 300 onPress={() => dispatch({type: 'next'})}> 301 <ButtonText> 302 <Trans>Continue</Trans> 303 </ButtonText> 304 </Button> 305 </View> 306 )} 307 </OnboardingControls.Portal> 308 </View> 309 ) 310} 311 312function TabBar({ 313 selectedInterest, 314 onSelectInterest, 315 selectedInterests, 316 hideDefaultTab, 317 defaultTabLabel, 318}: { 319 selectedInterest: string | null 320 onSelectInterest: (interest: string | null) => void 321 selectedInterests: string[] 322 hideDefaultTab?: boolean 323 defaultTabLabel?: string 324}) { 325 const {_} = useLingui() 326 const ax = useAnalytics() 327 const interestsDisplayNames = useInterestsDisplayNames() 328 const interests = Object.keys(interestsDisplayNames) 329 .sort(boostInterests(popularInterests)) 330 .sort(boostInterests(selectedInterests)) 331 332 return ( 333 <InterestTabs 334 interests={hideDefaultTab ? interests : ['all', ...interests]} 335 selectedInterest={ 336 selectedInterest || (hideDefaultTab ? interests[0] : 'all') 337 } 338 onSelectTab={tab => { 339 ax.metric('onboarding:suggestedAccounts:tabPressed', {tab: tab}) 340 onSelectInterest(tab === 'all' ? null : tab) 341 }} 342 interestsDisplayNames={ 343 hideDefaultTab 344 ? interestsDisplayNames 345 : { 346 all: defaultTabLabel || _(msg`For You`), 347 ...interestsDisplayNames, 348 } 349 } 350 gutterWidth={IS_WEB ? 0 : tokens.space.xl} 351 /> 352 ) 353} 354 355function SuggestedProfileCard({ 356 profile, 357 moderationOpts, 358 position, 359 category, 360 onSeen, 361 recId, 362}: { 363 profile: bsky.profile.AnyProfileView 364 moderationOpts: ModerationOpts 365 position: number 366 category: string | null 367 onSeen: (did: string, position: number) => void 368 recId?: number | string 369}) { 370 const t = useTheme() 371 const ax = useAnalytics() 372 const cardRef = useRef<View>(null) 373 const hasTrackedRef = useRef(false) 374 375 useEffect(() => { 376 const node = cardRef.current 377 if (!node || hasTrackedRef.current) return 378 379 if (IS_WEB && typeof IntersectionObserver !== 'undefined') { 380 const observer = new IntersectionObserver( 381 entries => { 382 if (entries[0]?.isIntersecting && !hasTrackedRef.current) { 383 hasTrackedRef.current = true 384 onSeen(profile.did, position) 385 observer.disconnect() 386 } 387 }, 388 {threshold: 0.5}, 389 ) 390 // @ts-ignore - web only 391 observer.observe(node) 392 return () => observer.disconnect() 393 } else { 394 // Native: use a short delay to account for initial layout 395 const timeout = setTimeout(() => { 396 if (!hasTrackedRef.current) { 397 hasTrackedRef.current = true 398 onSeen(profile.did, position) 399 } 400 }, 500) 401 return () => clearTimeout(timeout) 402 } 403 }, [onSeen, profile.did, position]) 404 405 return ( 406 <View 407 ref={cardRef} 408 style={[ 409 a.w_full, 410 a.py_lg, 411 a.px_xl, 412 position !== 0 && a.border_t, 413 t.atoms.border_contrast_low, 414 ]}> 415 <ProfileCard.Outer> 416 <ProfileCard.Header> 417 <ProfileCard.Avatar 418 profile={profile} 419 moderationOpts={moderationOpts} 420 disabledPreview 421 /> 422 <ProfileCard.NameAndHandle 423 profile={profile} 424 moderationOpts={moderationOpts} 425 /> 426 <ProfileCard.FollowButton 427 profile={profile} 428 moderationOpts={moderationOpts} 429 withIcon={false} 430 logContext="OnboardingSuggestedAccounts" 431 onFollow={() => { 432 ax.metric('suggestedUser:follow', { 433 logContext: 'Onboarding', 434 location: 'Card', 435 recId, 436 position, 437 suggestedDid: profile.did, 438 category, 439 }) 440 }} 441 /> 442 </ProfileCard.Header> 443 <ProfileCard.Description profile={profile} numberOfLines={3} /> 444 </ProfileCard.Outer> 445 </View> 446 ) 447}