forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}