Bluesky app fork with some witchin' additions 💫

Add suggested follows experiment to onboarding (#8847)

* add new gated screen to onboarding

* add tab bar, adjust layout

* replace chevron with arrow

* get suggested accounts working on native

* tweaks for web

* add metrics to follow all

* rm non-functional link from card

* ensure selected interests are passed through to interests query

* fix logcontext

* followed all accounts! toast

* rm save interests function

* Update src/screens/Onboarding/StepSuggestedAccounts/index.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* use admonition

* rm comment

* Better interest tabs (#8879)

* make tabs draggable

* move tab component to own file

* rm focused state from tab, improve label

* add focus styles, remove focus when dragging

* rm log

* add arrows to tabs

* rename Tabs -> InterestTabs

* try and simplify approach

* rename ref

* Update InterestTabs.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/InterestTabs.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/ProgressGuide/FollowDialog.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/components/ProgressGuide/FollowDialog.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* add newline

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* fix flex problem

* Add value proposition screen experiment (#8898)

* add assets

* add value prop experiment

* add alt text

* add metrics

* add transitions

* add skip button

* tweak copy

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* add borderless variant for web

* rm pointer events

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Add slight delay, prevent layout shift

* Handle layout shift, add Let's Go! text

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>

authored by samuel.fm

surfdude29
Eric Bailey
and committed by
GitHub
eac02901 0617fca5

+1302 -291
assets/images/onboarding/value_prop_1_dark.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_1_dark_borderless.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_1_dim.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_1_dim_borderless.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_1_light.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_1_light_borderless.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_2_dark.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_2_dim.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_2_light.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_3_dark.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_3_dim.webp

This is a binary file and will not be displayed.

assets/images/onboarding/value_prop_3_light.webp

This is a binary file and will not be displayed.

+28
src/alf/atoms.ts
··· 70 70 overflow_visible: { 71 71 overflow: 'visible', 72 72 }, 73 + overflow_x_visible: { 74 + overflowX: 'visible', 75 + }, 76 + overflow_y_visible: { 77 + overflowY: 'visible', 78 + }, 73 79 overflow_hidden: { 74 80 overflow: 'hidden', 81 + }, 82 + overflow_x_hidden: { 83 + overflowX: 'hidden', 84 + }, 85 + overflow_y_hidden: { 86 + overflowY: 'hidden', 75 87 }, 76 88 /** 77 89 * @platform web ··· 363 375 border_r_0: { 364 376 borderRightWidth: 0, 365 377 }, 378 + border_x_0: { 379 + borderLeftWidth: 0, 380 + borderRightWidth: 0, 381 + }, 382 + border_y_0: { 383 + borderTopWidth: 0, 384 + borderBottomWidth: 0, 385 + }, 366 386 border: { 367 387 borderWidth: StyleSheet.hairlineWidth, 368 388 }, ··· 377 397 }, 378 398 border_r: { 379 399 borderRightWidth: StyleSheet.hairlineWidth, 400 + }, 401 + border_x: { 402 + borderLeftWidth: StyleSheet.hairlineWidth, 403 + borderRightWidth: StyleSheet.hairlineWidth, 404 + }, 405 + border_y: { 406 + borderTopWidth: StyleSheet.hairlineWidth, 407 + borderBottomWidth: StyleSheet.hairlineWidth, 380 408 }, 381 409 border_transparent: { 382 410 borderColor: 'transparent',
+390
src/components/InterestTabs.tsx
··· 1 + import {useEffect, useRef, useState} from 'react' 2 + import { 3 + type ScrollView, 4 + type StyleProp, 5 + View, 6 + type ViewStyle, 7 + } from 'react-native' 8 + import {msg} from '@lingui/macro' 9 + import {useLingui} from '@lingui/react' 10 + 11 + import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 12 + import {isWeb} from '#/platform/detection' 13 + import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView' 14 + import {atoms as a, tokens, useTheme, web} from '#/alf' 15 + import {transparentifyColor} from '#/alf/util/colorGeneration' 16 + import {Button, ButtonIcon} from '#/components/Button' 17 + import { 18 + ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft, 19 + ArrowRight_Stroke2_Corner0_Rounded as ArrowRight, 20 + } from '#/components/icons/Arrow' 21 + import {Text} from '#/components/Typography' 22 + 23 + /** 24 + * Tab component that automatically scrolls the selected tab into view - used for interests 25 + * in the Find Follows dialog, Explore screen, etc. 26 + */ 27 + export function InterestTabs({ 28 + onSelectTab, 29 + interests, 30 + selectedInterest, 31 + disabled, 32 + interestsDisplayNames, 33 + TabComponent = Tab, 34 + contentContainerStyle, 35 + gutterWidth = tokens.space.lg, 36 + }: { 37 + onSelectTab: (tab: string) => void 38 + interests: string[] 39 + selectedInterest: string 40 + interestsDisplayNames: Record<string, string> 41 + /** still allows changing tab, but removes the active state from the selected tab */ 42 + disabled?: boolean 43 + TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>> 44 + contentContainerStyle?: StyleProp<ViewStyle> 45 + gutterWidth?: number 46 + }) { 47 + const t = useTheme() 48 + const {_} = useLingui() 49 + const listRef = useRef<ScrollView>(null) 50 + const [totalWidth, setTotalWidth] = useState(0) 51 + const [scrollX, setScrollX] = useState(0) 52 + const [contentWidth, setContentWidth] = useState(0) 53 + const pendingTabOffsets = useRef<{x: number; width: number}[]>([]) 54 + const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([]) 55 + 56 + const onInitialLayout = useNonReactiveCallback(() => { 57 + const index = interests.indexOf(selectedInterest) 58 + scrollIntoViewIfNeeded(index) 59 + }) 60 + 61 + useEffect(() => { 62 + if (tabOffsets) { 63 + onInitialLayout() 64 + } 65 + }, [tabOffsets, onInitialLayout]) 66 + 67 + function scrollIntoViewIfNeeded(index: number) { 68 + const btnLayout = tabOffsets[index] 69 + if (!btnLayout) return 70 + listRef.current?.scrollTo({ 71 + // centered 72 + x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2), 73 + animated: true, 74 + }) 75 + } 76 + 77 + function handleSelectTab(index: number) { 78 + const tab = interests[index] 79 + onSelectTab(tab) 80 + scrollIntoViewIfNeeded(index) 81 + } 82 + 83 + function handleTabLayout(index: number, x: number, width: number) { 84 + if (!tabOffsets.length) { 85 + pendingTabOffsets.current[index] = {x, width} 86 + if (pendingTabOffsets.current.length === interests.length) { 87 + setTabOffsets(pendingTabOffsets.current) 88 + } 89 + } 90 + } 91 + 92 + const canScrollLeft = scrollX > 0 93 + const canScrollRight = scrollX < contentWidth - totalWidth 94 + 95 + const cleanupRef = useRef<(() => void) | null>(null) 96 + 97 + function scrollLeft() { 98 + if (isContinuouslyScrollingRef.current) { 99 + return 100 + } 101 + if (listRef.current && canScrollLeft) { 102 + const newScrollX = Math.max(0, scrollX - 200) 103 + listRef.current.scrollTo({x: newScrollX, animated: true}) 104 + } 105 + } 106 + 107 + function scrollRight() { 108 + if (isContinuouslyScrollingRef.current) { 109 + return 110 + } 111 + if (listRef.current && canScrollRight) { 112 + const maxScroll = contentWidth - totalWidth 113 + const newScrollX = Math.min(maxScroll, scrollX + 200) 114 + listRef.current.scrollTo({x: newScrollX, animated: true}) 115 + } 116 + } 117 + 118 + const isContinuouslyScrollingRef = useRef(false) 119 + 120 + function startContinuousScroll(direction: 'left' | 'right') { 121 + // Clear any existing continuous scroll 122 + if (cleanupRef.current) { 123 + cleanupRef.current() 124 + } 125 + 126 + let holdTimeout: NodeJS.Timeout | null = null 127 + let animationFrame: number | null = null 128 + let isActive = true 129 + isContinuouslyScrollingRef.current = false 130 + 131 + const cleanup = () => { 132 + isActive = false 133 + if (holdTimeout) clearTimeout(holdTimeout) 134 + if (animationFrame) cancelAnimationFrame(animationFrame) 135 + cleanupRef.current = null 136 + // Reset flag after a delay to prevent onPress from firing 137 + setTimeout(() => { 138 + isContinuouslyScrollingRef.current = false 139 + }, 100) 140 + } 141 + 142 + cleanupRef.current = cleanup 143 + 144 + // Start continuous scrolling after hold delay 145 + holdTimeout = setTimeout(() => { 146 + if (!isActive) return 147 + 148 + isContinuouslyScrollingRef.current = true 149 + let currentScrollPosition = scrollX 150 + 151 + const scroll = () => { 152 + if (!isActive || !listRef.current) return 153 + 154 + const scrollAmount = 3 155 + const maxScroll = contentWidth - totalWidth 156 + 157 + let newScrollX: number 158 + let canContinue = false 159 + 160 + if (direction === 'left' && currentScrollPosition > 0) { 161 + newScrollX = Math.max(0, currentScrollPosition - scrollAmount) 162 + canContinue = newScrollX > 0 163 + } else if (direction === 'right' && currentScrollPosition < maxScroll) { 164 + newScrollX = Math.min(maxScroll, currentScrollPosition + scrollAmount) 165 + canContinue = newScrollX < maxScroll 166 + } else { 167 + return 168 + } 169 + 170 + currentScrollPosition = newScrollX 171 + listRef.current.scrollTo({x: newScrollX, animated: false}) 172 + 173 + if (canContinue && isActive) { 174 + animationFrame = requestAnimationFrame(scroll) 175 + } 176 + } 177 + 178 + scroll() 179 + }, 500) 180 + } 181 + 182 + function stopContinuousScroll() { 183 + if (cleanupRef.current) { 184 + cleanupRef.current() 185 + } 186 + } 187 + 188 + useEffect(() => { 189 + return () => { 190 + if (cleanupRef.current) { 191 + cleanupRef.current() 192 + } 193 + } 194 + }, []) 195 + 196 + return ( 197 + <View style={[a.relative, a.flex_row]}> 198 + <DraggableScrollView 199 + ref={listRef} 200 + contentContainerStyle={[ 201 + a.gap_sm, 202 + {paddingHorizontal: gutterWidth}, 203 + contentContainerStyle, 204 + ]} 205 + showsHorizontalScrollIndicator={false} 206 + decelerationRate="fast" 207 + snapToOffsets={ 208 + tabOffsets.length === interests.length 209 + ? tabOffsets.map(o => o.x - tokens.space.xl) 210 + : undefined 211 + } 212 + onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)} 213 + onContentSizeChange={width => setContentWidth(width)} 214 + onScroll={evt => { 215 + const newScrollX = evt.nativeEvent.contentOffset.x 216 + setScrollX(newScrollX) 217 + }} 218 + scrollEventThrottle={16}> 219 + {interests.map((interest, i) => { 220 + const active = interest === selectedInterest && !disabled 221 + return ( 222 + <TabComponent 223 + key={interest} 224 + onSelectTab={handleSelectTab} 225 + active={active} 226 + index={i} 227 + interest={interest} 228 + interestsDisplayName={interestsDisplayNames[interest]} 229 + onLayout={handleTabLayout} 230 + /> 231 + ) 232 + })} 233 + </DraggableScrollView> 234 + {isWeb && canScrollLeft && ( 235 + <View 236 + style={[ 237 + a.absolute, 238 + a.top_0, 239 + a.left_0, 240 + a.bottom_0, 241 + a.justify_center, 242 + {paddingLeft: gutterWidth}, 243 + a.pr_md, 244 + a.z_10, 245 + web({ 246 + background: `linear-gradient(to right, ${t.atoms.bg.backgroundColor} 0%, ${t.atoms.bg.backgroundColor} 70%, ${transparentifyColor(t.atoms.bg.backgroundColor, 0)} 100%)`, 247 + }), 248 + ]}> 249 + <Button 250 + label={_(msg`Scroll left`)} 251 + onPress={scrollLeft} 252 + onPressIn={() => startContinuousScroll('left')} 253 + onPressOut={stopContinuousScroll} 254 + color="secondary" 255 + size="small" 256 + style={[ 257 + a.border, 258 + t.atoms.border_contrast_low, 259 + t.atoms.bg, 260 + a.h_full, 261 + {aspectRatio: 1}, 262 + a.rounded_full, 263 + ]}> 264 + <ButtonIcon icon={ArrowLeft} /> 265 + </Button> 266 + </View> 267 + )} 268 + {isWeb && canScrollRight && ( 269 + <View 270 + style={[ 271 + a.absolute, 272 + a.top_0, 273 + a.right_0, 274 + a.bottom_0, 275 + a.justify_center, 276 + {paddingRight: gutterWidth}, 277 + a.pl_md, 278 + a.z_10, 279 + web({ 280 + background: `linear-gradient(to left, ${t.atoms.bg.backgroundColor} 0%, ${t.atoms.bg.backgroundColor} 70%, ${transparentifyColor(t.atoms.bg.backgroundColor, 0)} 100%)`, 281 + }), 282 + ]}> 283 + <Button 284 + label={_(msg`Scroll right`)} 285 + onPress={scrollRight} 286 + onPressIn={() => startContinuousScroll('right')} 287 + onPressOut={stopContinuousScroll} 288 + color="secondary" 289 + size="small" 290 + style={[ 291 + a.border, 292 + t.atoms.border_contrast_low, 293 + t.atoms.bg, 294 + a.h_full, 295 + {aspectRatio: 1}, 296 + a.rounded_full, 297 + ]}> 298 + <ButtonIcon icon={ArrowRight} /> 299 + </Button> 300 + </View> 301 + )} 302 + </View> 303 + ) 304 + } 305 + 306 + function Tab({ 307 + onSelectTab, 308 + interest, 309 + active, 310 + index, 311 + interestsDisplayName, 312 + onLayout, 313 + }: { 314 + onSelectTab: (index: number) => void 315 + interest: string 316 + active: boolean 317 + index: number 318 + interestsDisplayName: string 319 + onLayout: (index: number, x: number, width: number) => void 320 + }) { 321 + const t = useTheme() 322 + const {_} = useLingui() 323 + const label = active 324 + ? _( 325 + msg({ 326 + message: `"${interestsDisplayName}" category (active)`, 327 + comment: 328 + 'Accessibility label for a category (e.g. Art, Video Games, Sports, etc.) that shows suggested accounts for the user to follow. The tab is currently selected.', 329 + }), 330 + ) 331 + : _( 332 + msg({ 333 + message: `Select "${interestsDisplayName}" category`, 334 + comment: 335 + 'Accessibility label for a category (e.g. Art, Video Games, Sports, etc.) that shows suggested accounts for the user to follow. The tab is not currently active and can be selected.', 336 + }), 337 + ) 338 + 339 + return ( 340 + <View 341 + key={interest} 342 + onLayout={e => 343 + onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) 344 + }> 345 + <Button 346 + label={label} 347 + onPress={() => onSelectTab(index)} 348 + // disable focus ring, we handle it 349 + style={web({outline: 'none'})}> 350 + {({hovered, pressed, focused}) => ( 351 + <View 352 + style={[ 353 + a.rounded_full, 354 + a.px_lg, 355 + a.py_sm, 356 + a.border, 357 + active || hovered || pressed 358 + ? [t.atoms.bg_contrast_25, t.atoms.border_contrast_medium] 359 + : focused 360 + ? { 361 + borderColor: t.palette.primary_300, 362 + backgroundColor: t.palette.primary_25, 363 + } 364 + : [t.atoms.bg, t.atoms.border_contrast_low], 365 + ]}> 366 + <Text 367 + style={[ 368 + a.font_medium, 369 + active || hovered || pressed 370 + ? t.atoms.text 371 + : t.atoms.text_contrast_medium, 372 + ]}> 373 + {interestsDisplayName} 374 + </Text> 375 + </View> 376 + )} 377 + </Button> 378 + </View> 379 + ) 380 + } 381 + 382 + export function boostInterests(boosts?: string[]) { 383 + return (_a: string, _b: string) => { 384 + const indexA = boosts?.indexOf(_a) ?? -1 385 + const indexB = boosts?.indexOf(_b) ?? -1 386 + const rankA = indexA === -1 ? Infinity : indexA 387 + const rankB = indexB === -1 ? Infinity : indexB 388 + return rankA - rankB 389 + } 390 + }
+24 -122
src/components/ProgressGuide/FollowDialog.tsx
··· 1 1 import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' 2 - import { 3 - ScrollView, 4 - type StyleProp, 5 - TextInput, 6 - useWindowDimensions, 7 - View, 8 - type ViewStyle, 9 - } from 'react-native' 2 + import {TextInput, useWindowDimensions, View} from 'react-native' 10 3 import {type ModerationOpts} from '@atproto/api' 11 4 import {msg, Trans} from '@lingui/macro' 12 5 import {useLingui} from '@lingui/react' 13 6 14 - import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 15 7 import {logEvent} from '#/lib/statsig/statsig' 16 8 import {isWeb} from '#/platform/detection' 17 9 import {useModerationOpts} from '#/state/preferences/moderation-opts' ··· 28 20 import { 29 21 atoms as a, 30 22 native, 31 - tokens, 32 23 useBreakpoints, 33 24 useTheme, 34 25 type ViewStyleProp, ··· 40 31 import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2' 41 32 import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' 42 33 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 34 + import {boostInterests, InterestTabs} from '#/components/InterestTabs' 43 35 import * as ProfileCard from '#/components/ProfileCard' 44 36 import {Text} from '#/components/Typography' 45 37 import type * as bsky from '#/types/bsky' ··· 337 329 }} 338 330 onEscape={control.close} 339 331 /> 340 - <Tabs 332 + <InterestTabs 341 333 onSelectTab={onSelectTab} 342 334 interests={interests} 343 335 selectedInterest={selectedInterest} 344 - hasSearchText={!!searchText} 336 + disabled={!!searchText} 345 337 interestsDisplayNames={interestsDisplayNames} 338 + TabComponent={Tab} 346 339 /> 347 340 </View> 348 341 </View> ··· 403 396 ) 404 397 } 405 398 406 - let Tabs = ({ 407 - onSelectTab, 408 - interests, 409 - selectedInterest, 410 - hasSearchText, 411 - interestsDisplayNames, 412 - TabComponent = Tab, 413 - contentContainerStyle, 414 - }: { 415 - onSelectTab: (tab: string) => void 416 - interests: string[] 417 - selectedInterest: string 418 - hasSearchText: boolean 419 - interestsDisplayNames: Record<string, string> 420 - TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>> 421 - contentContainerStyle?: StyleProp<ViewStyle> 422 - }): React.ReactNode => { 423 - const listRef = useRef<ScrollView>(null) 424 - const [totalWidth, setTotalWidth] = useState(0) 425 - const pendingTabOffsets = useRef<{x: number; width: number}[]>([]) 426 - const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([]) 427 - 428 - const onInitialLayout = useNonReactiveCallback(() => { 429 - const index = interests.indexOf(selectedInterest) 430 - scrollIntoViewIfNeeded(index) 431 - }) 432 - 433 - useEffect(() => { 434 - if (tabOffsets) { 435 - onInitialLayout() 436 - } 437 - }, [tabOffsets, onInitialLayout]) 438 - 439 - function scrollIntoViewIfNeeded(index: number) { 440 - const btnLayout = tabOffsets[index] 441 - if (!btnLayout) return 442 - listRef.current?.scrollTo({ 443 - // centered 444 - x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2), 445 - animated: true, 446 - }) 447 - } 448 - 449 - function handleSelectTab(index: number) { 450 - const tab = interests[index] 451 - onSelectTab(tab) 452 - scrollIntoViewIfNeeded(index) 453 - } 454 - 455 - function handleTabLayout(index: number, x: number, width: number) { 456 - if (!tabOffsets.length) { 457 - pendingTabOffsets.current[index] = {x, width} 458 - if (pendingTabOffsets.current.length === interests.length) { 459 - setTabOffsets(pendingTabOffsets.current) 460 - } 461 - } 462 - } 463 - 464 - return ( 465 - <ScrollView 466 - ref={listRef} 467 - horizontal 468 - contentContainerStyle={[a.gap_sm, a.px_lg, contentContainerStyle]} 469 - showsHorizontalScrollIndicator={false} 470 - decelerationRate="fast" 471 - snapToOffsets={ 472 - tabOffsets.length === interests.length 473 - ? tabOffsets.map(o => o.x - tokens.space.xl) 474 - : undefined 475 - } 476 - onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)} 477 - scrollEventThrottle={200} // big throttle 478 - > 479 - {interests.map((interest, i) => { 480 - const active = interest === selectedInterest && !hasSearchText 481 - return ( 482 - <TabComponent 483 - key={interest} 484 - onSelectTab={handleSelectTab} 485 - active={active} 486 - index={i} 487 - interest={interest} 488 - interestsDisplayName={interestsDisplayNames[interest]} 489 - onLayout={handleTabLayout} 490 - /> 491 - ) 492 - })} 493 - </ScrollView> 494 - ) 495 - } 496 - Tabs = memo(Tabs) 497 - export {Tabs} 498 - 499 399 let Tab = ({ 500 400 onSelectTab, 501 401 interest, ··· 513 413 }): React.ReactNode => { 514 414 const t = useTheme() 515 415 const {_} = useLingui() 516 - const activeText = active ? _(msg` (active)`) : '' 416 + const label = active 417 + ? _( 418 + msg({ 419 + message: `Search for "${interestsDisplayName}" (active)`, 420 + comment: 421 + 'Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is currently selected.', 422 + }), 423 + ) 424 + : _( 425 + msg({ 426 + message: `Search for "${interestsDisplayName}`, 427 + comment: 428 + 'Accessibility label for a tab that searches for accounts in a category (e.g. Art, Video Games, Sports, etc.) that are suggested for the user to follow. The tab is not currently active and can be selected.', 429 + }), 430 + ) 517 431 return ( 518 432 <View 519 433 key={interest} 520 434 onLayout={e => 521 435 onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) 522 436 }> 523 - <Button 524 - label={_(msg`Search for "${interestsDisplayName}"${activeText}`)} 525 - onPress={() => onSelectTab(index)}> 526 - {({hovered, pressed, focused}) => ( 437 + <Button label={label} onPress={() => onSelectTab(index)}> 438 + {({hovered, pressed}) => ( 527 439 <View 528 440 style={[ 529 441 a.rounded_full, 530 442 a.px_lg, 531 443 a.py_sm, 532 444 a.border, 533 - active || hovered || pressed || focused 445 + active || hovered || pressed 534 446 ? [ 535 447 t.atoms.bg_contrast_25, 536 448 {borderColor: t.atoms.bg_contrast_25.backgroundColor}, ··· 540 452 <Text 541 453 style={[ 542 454 a.font_medium, 543 - active || hovered || pressed || focused 455 + active || hovered || pressed 544 456 ? t.atoms.text 545 457 : t.atoms.text_contrast_medium, 546 458 ]}> ··· 759 671 </View> 760 672 ) 761 673 } 762 - 763 - export function boostInterests(boosts?: string[]) { 764 - return (_a: string, _b: string) => { 765 - const indexA = boosts?.indexOf(_a) ?? -1 766 - const indexB = boosts?.indexOf(_b) ?? -1 767 - const rankA = indexA === -1 ? Infinity : indexA 768 - const rankB = indexB === -1 ? Infinity : indexB 769 - return rankA - rankB 770 - } 771 - }
+3 -3
src/lib/hooks/useDraggableScrollView.ts
··· 20 20 return 21 21 } 22 22 const slider = ref.current as unknown as HTMLDivElement 23 - if (!slider) { 24 - return 25 - } 26 23 let isDragging = false 27 24 let isMouseDown = false 28 25 let startX = 0 ··· 61 58 e.preventDefault() 62 59 const walk = x - startX 63 60 slider.scrollLeft = scrollLeft - walk 61 + 62 + if (slider.contains(document.activeElement)) 63 + (document.activeElement as HTMLElement)?.blur?.() 64 64 } 65 65 66 66 slider.addEventListener('mousedown', mouseDown)
+2
src/lib/statsig/gates.ts
··· 9 9 | 'explore_show_suggested_feeds' 10 10 | 'old_postonboarding' 11 11 | 'onboarding_add_video_feed' 12 + | 'onboarding_suggested_accounts' 13 + | 'onboarding_value_prop' 12 14 | 'post_follow_profile_suggested_accounts' 13 15 | 'remove_show_latest_button' 14 16 | 'test_gate_1'
+18 -1
src/logger/metrics.ts
··· 90 90 selectedInterests: string[] 91 91 selectedInterestsLength: number 92 92 } 93 + 'onboarding:suggestedAccounts:tabPressed': { 94 + tab: string 95 + } 96 + 'onboarding:suggestedAccounts:followAllPressed': { 97 + tab: string 98 + numAccounts: number 99 + } 93 100 'onboarding:suggestedAccounts:nextPressed': { 94 101 selectedAccountsLength: number 95 102 skipped: boolean ··· 118 125 'onboarding:finished:avatarResult': { 119 126 avatarResult: 'default' | 'created' | 'uploaded' 120 127 } 128 + 'onboarding:valueProp:stepOne:nextPressed': {} 129 + 'onboarding:valueProp:stepTwo:nextPressed': {} 130 + 'onboarding:valueProp:skipPressed': {} 121 131 'home:feedDisplayed': { 122 132 feedUrl: string 123 133 feedType: string ··· 242 252 | 'PostOnboardingFindFollows' 243 253 | 'ImmersiveVideo' 244 254 | 'ExploreSuggestedAccounts' 255 + | 'OnboardingSuggestedAccounts' 245 256 } 246 257 'suggestedUser:follow': { 247 258 logContext: ··· 249 260 | 'InterstitialDiscover' 250 261 | 'InterstitialProfile' 251 262 | 'Profile' 263 + | 'Onboarding' 252 264 location: 'Card' | 'Profile' 253 265 recId?: number 254 266 position: number 255 267 } 256 268 'suggestedUser:press': { 257 - logContext: 'Explore' | 'InterstitialDiscover' | 'InterstitialProfile' 269 + logContext: 270 + | 'Explore' 271 + | 'InterstitialDiscover' 272 + | 'InterstitialProfile' 273 + | 'Onboarding' 258 274 recId?: number 259 275 position: number 260 276 } ··· 280 296 | 'PostOnboardingFindFollows' 281 297 | 'ImmersiveVideo' 282 298 | 'ExploreSuggestedAccounts' 299 + | 'OnboardingSuggestedAccounts' 283 300 } 284 301 'chat:create': { 285 302 logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog'
+76 -50
src/screens/Onboarding/Layout.tsx
··· 1 - import React from 'react' 1 + import React, {useState} from 'react' 2 2 import {ScrollView, View} from 'react-native' 3 3 import {useSafeAreaInsets} from 'react-native-safe-area-context' 4 4 import {msg} from '@lingui/macro' ··· 11 11 atoms as a, 12 12 flatten, 13 13 native, 14 - TextStyleProp, 14 + type TextStyleProp, 15 + tokens, 15 16 useBreakpoints, 16 17 useTheme, 17 18 web, 18 19 } from '#/alf' 19 20 import {leading} from '#/alf/typography' 20 21 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 21 - import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' 22 + import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow' 23 + import {HEADER_SLOT_SIZE} from '#/components/Layout' 22 24 import {createPortalGroup} from '#/components/Portal' 23 25 import {P, Text} from '#/components/Typography' 24 26 25 - const COL_WIDTH = 420 27 + const ONBOARDING_COL_WIDTH = 420 26 28 27 29 export const OnboardingControls = createPortalGroup() 30 + export const OnboardingHeaderSlot = createPortalGroup() 28 31 29 32 export function Layout({children}: React.PropsWithChildren<{}>) { 30 33 const {_} = useLingui() ··· 46 49 const paddingTop = gtMobile ? a.py_5xl : a.py_lg 47 50 const dialogLabel = _(msg`Set up your account`) 48 51 52 + const [footerHeight, setFooterHeight] = useState(0) 53 + 49 54 return ( 50 55 <View 51 56 aria-modal ··· 62 67 t.atoms.bg, 63 68 ]}> 64 69 {__DEV__ && ( 65 - <View style={[a.absolute, a.p_xl, a.z_10, {right: 0, top: insets.top}]}> 66 - <Button 67 - variant="ghost" 68 - color="negative" 69 - size="small" 70 - onPress={() => onboardDispatch({type: 'skip'})} 71 - // DEV ONLY 72 - label="Clear onboarding state"> 73 - <ButtonText>Clear</ButtonText> 74 - </Button> 75 - </View> 70 + <Button 71 + variant="ghost" 72 + color="negative" 73 + size="tiny" 74 + onPress={() => onboardDispatch({type: 'skip'})} 75 + // DEV ONLY 76 + label="Clear onboarding state" 77 + style={[ 78 + a.absolute, 79 + a.z_10, 80 + { 81 + left: '50%', 82 + top: insets.top + 2, 83 + transform: [{translateX: '-50%'}], 84 + }, 85 + ]}> 86 + <ButtonText>[DEV] Clear</ButtonText> 87 + </Button> 76 88 )} 77 89 78 - {!gtMobile && state.hasPrev && ( 90 + {!gtMobile && ( 79 91 <View 92 + pointerEvents="box-none" 80 93 style={[ 81 94 web(a.fixed), 82 95 native(a.absolute), 96 + a.left_0, 97 + a.right_0, 83 98 a.flex_row, 84 99 a.w_full, 85 100 a.justify_center, 86 101 a.z_20, 87 102 a.px_xl, 88 - { 89 - top: paddingTop.paddingTop + insets.top - 1, 90 - }, 103 + {top: paddingTop.paddingTop + insets.top - 1}, 91 104 ]}> 92 - <View style={[a.w_full, a.align_start, {maxWidth: COL_WIDTH}]}> 93 - <Button 94 - key={state.activeStep} // remove focus state on nav 95 - variant="ghost" 96 - color="secondary" 97 - size="small" 98 - shape="round" 99 - label={_(msg`Go back to previous step`)} 100 - style={[a.absolute]} 101 - onPress={() => dispatch({type: 'prev'})}> 102 - <ButtonIcon icon={ChevronLeft} /> 103 - </Button> 105 + <View 106 + pointerEvents="box-none" 107 + style={[ 108 + a.w_full, 109 + a.align_start, 110 + a.flex_row, 111 + a.justify_between, 112 + {maxWidth: ONBOARDING_COL_WIDTH}, 113 + ]}> 114 + {state.hasPrev ? ( 115 + <Button 116 + key={state.activeStep} // remove focus state on nav 117 + color="secondary" 118 + variant="ghost" 119 + shape="square" 120 + size="small" 121 + label={_(msg`Go back to previous step`)} 122 + onPress={() => dispatch({type: 'prev'})} 123 + style={[a.bg_transparent]}> 124 + <ButtonIcon icon={ArrowLeft} size="lg" /> 125 + </Button> 126 + ) : ( 127 + <View /> 128 + )} 129 + 130 + <OnboardingHeaderSlot.Outlet /> 104 131 </View> 105 132 </View> 106 133 )} ··· 109 136 ref={scrollview} 110 137 style={[a.h_full, a.w_full, {paddingTop: insets.top}]} 111 138 contentContainerStyle={{borderWidth: 0}} 112 - // @ts-ignore web only --prf 139 + scrollIndicatorInsets={{bottom: footerHeight - insets.bottom}} 140 + // @ts-expect-error web only --prf 113 141 dataSet={{'stable-gutters': 1}}> 114 142 <View 115 143 style={[a.flex_row, a.justify_center, gtMobile ? a.px_5xl : a.px_xl]}> 116 - <View style={[a.flex_1, {maxWidth: COL_WIDTH}]}> 144 + <View style={[a.flex_1, {maxWidth: ONBOARDING_COL_WIDTH}]}> 117 145 <View style={[a.w_full, a.align_center, paddingTop]}> 118 146 <View 119 147 style={[ 120 148 a.flex_row, 121 149 a.gap_sm, 122 150 a.w_full, 123 - {paddingTop: 17, maxWidth: '60%'}, 151 + a.align_center, 152 + {height: HEADER_SLOT_SIZE, maxWidth: '60%'}, 124 153 ]}> 125 154 {Array(state.totalSteps) 126 155 .fill(0) 127 - .map((_, i) => ( 156 + .map((__, i) => ( 128 157 <View 129 158 key={i} 130 159 style={[ ··· 144 173 </View> 145 174 </View> 146 175 147 - <View 148 - style={[a.w_full, a.mb_5xl, {paddingTop: gtMobile ? 20 : 40}]}> 149 - {children} 150 - </View> 176 + <View style={[a.w_full, a.mb_5xl, a.pt_md]}>{children}</View> 151 177 152 - <View style={{height: 400}} /> 178 + <View style={{height: 100 + footerHeight}} /> 153 179 </View> 154 180 </View> 155 181 </ScrollView> 156 182 157 183 <View 184 + onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)} 158 185 style={[ 159 - // @ts-ignore web only -prf 160 186 isWeb ? a.fixed : a.absolute, 161 187 {bottom: 0, left: 0, right: 0}, 162 188 t.atoms.bg, ··· 167 193 isWeb 168 194 ? a.py_2xl 169 195 : { 170 - paddingTop: a.pt_lg.paddingTop, 171 - paddingBottom: insets.bottom + 10, 196 + paddingTop: tokens.space.md, 197 + paddingBottom: insets.bottom + tokens.space.md, 172 198 }, 173 199 ]}> 174 200 <View 175 201 style={[ 176 202 a.w_full, 177 - {maxWidth: COL_WIDTH}, 178 - gtMobile && [a.flex_row, a.justify_between], 203 + {maxWidth: ONBOARDING_COL_WIDTH}, 204 + gtMobile && [a.flex_row, a.justify_between, a.align_center], 179 205 ]}> 180 206 {gtMobile && 181 207 (state.hasPrev ? ( 182 208 <Button 183 209 key={state.activeStep} // remove focus state on nav 184 - variant="solid" 185 210 color="secondary" 186 - size="large" 187 - shape="round" 211 + variant="ghost" 212 + shape="square" 213 + size="small" 188 214 label={_(msg`Go back to previous step`)} 189 215 onPress={() => dispatch({type: 'prev'})}> 190 - <ButtonIcon icon={ChevronLeft} /> 216 + <ButtonIcon icon={ArrowLeft} size="lg" /> 191 217 </Button> 192 218 ) : ( 193 - <View style={{height: 54}} /> 219 + <View style={{height: 33}} /> 194 220 ))} 195 221 <OnboardingControls.Outlet /> 196 222 </View>
+284 -8
src/screens/Onboarding/StepFinished.tsx
··· 1 - import React from 'react' 1 + import {useCallback, useContext, useState} from 'react' 2 2 import {View} from 'react-native' 3 + import Animated, { 4 + Easing, 5 + LayoutAnimationConfig, 6 + SlideInRight, 7 + SlideOutLeft, 8 + } from 'react-native-reanimated' 9 + import {Image} from 'expo-image' 3 10 import { 4 11 type AppBskyActorDefs, 5 12 type AppBskyActorProfile, ··· 22 29 import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' 23 30 import {logEvent, useGate} from '#/lib/statsig/statsig' 24 31 import {logger} from '#/logger' 32 + import {isNative} from '#/platform/detection' 25 33 import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' 26 34 import {getAllListMembers} from '#/state/queries/list-members' 27 35 import {preferencesQueryKey} from '#/state/queries/preferences' ··· 36 44 import { 37 45 DescriptionText, 38 46 OnboardingControls, 47 + OnboardingHeaderSlot, 39 48 TitleText, 40 49 } from '#/screens/Onboarding/Layout' 41 - import {Context} from '#/screens/Onboarding/state' 50 + import {Context, type OnboardingState} from '#/screens/Onboarding/state' 42 51 import {bulkWriteFollows} from '#/screens/Onboarding/util' 43 - import {atoms as a, useTheme} from '#/alf' 52 + import { 53 + atoms as a, 54 + native, 55 + platform, 56 + tokens, 57 + useBreakpoints, 58 + useTheme, 59 + } from '#/alf' 44 60 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 45 61 import {IconCircle} from '#/components/IconCircle' 62 + import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow' 46 63 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 47 64 import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth' 48 65 import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2' ··· 53 70 54 71 export function StepFinished() { 55 72 const {_} = useLingui() 56 - const t = useTheme() 57 - const {state, dispatch} = React.useContext(Context) 73 + const {state, dispatch} = useContext(Context) 58 74 const onboardDispatch = useOnboardingDispatch() 59 - const [saving, setSaving] = React.useState(false) 75 + const [saving, setSaving] = useState(false) 60 76 const queryClient = useQueryClient() 61 77 const agent = useAgent() 62 78 const requestNotificationsPermission = useRequestNotificationsPermission() ··· 66 82 const {startProgressGuide} = useProgressGuideControls() 67 83 const gate = useGate() 68 84 69 - const finishOnboarding = React.useCallback(async () => { 85 + const finishOnboarding = useCallback(async () => { 70 86 setSaving(true) 71 87 72 88 let starterPack: AppBskyGraphDefs.StarterPackView | undefined ··· 245 261 gate, 246 262 ]) 247 263 264 + return state.experiments?.onboarding_value_prop ? ( 265 + <ValueProposition 266 + finishOnboarding={finishOnboarding} 267 + saving={saving} 268 + state={state} 269 + /> 270 + ) : ( 271 + <LegacyFinalStep 272 + finishOnboarding={finishOnboarding} 273 + saving={saving} 274 + state={state} 275 + /> 276 + ) 277 + } 278 + 279 + const PROP_1 = { 280 + light: platform({ 281 + native: require('../../../assets/images/onboarding/value_prop_1_light.webp'), 282 + web: require('../../../assets/images/onboarding/value_prop_1_light_borderless.webp'), 283 + }), 284 + dim: platform({ 285 + native: require('../../../assets/images/onboarding/value_prop_1_dim.webp'), 286 + web: require('../../../assets/images/onboarding/value_prop_1_dim_borderless.webp'), 287 + }), 288 + dark: platform({ 289 + native: require('../../../assets/images/onboarding/value_prop_1_dark.webp'), 290 + web: require('../../../assets/images/onboarding/value_prop_1_dark_borderless.webp'), 291 + }), 292 + } as const 293 + 294 + const PROP_2 = { 295 + light: require('../../../assets/images/onboarding/value_prop_2_light.webp'), 296 + dim: require('../../../assets/images/onboarding/value_prop_2_dim.webp'), 297 + dark: require('../../../assets/images/onboarding/value_prop_2_dark.webp'), 298 + } as const 299 + 300 + const PROP_3 = { 301 + light: require('../../../assets/images/onboarding/value_prop_3_light.webp'), 302 + dim: require('../../../assets/images/onboarding/value_prop_3_dim.webp'), 303 + dark: require('../../../assets/images/onboarding/value_prop_3_dark.webp'), 304 + } as const 305 + 306 + function ValueProposition({ 307 + finishOnboarding, 308 + saving, 309 + state, 310 + }: { 311 + finishOnboarding: () => void 312 + saving: boolean 313 + state: OnboardingState 314 + }) { 315 + const [subStep, setSubStep] = useState<0 | 1 | 2>(0) 316 + const t = useTheme() 317 + const {_} = useLingui() 318 + const {gtMobile} = useBreakpoints() 319 + 320 + const image = [PROP_1[t.name], PROP_2[t.name], PROP_3[t.name]][subStep] 321 + 322 + const onPress = () => { 323 + if (subStep === 2) { 324 + finishOnboarding() // has its own metrics 325 + } else if (subStep === 1) { 326 + setSubStep(2) 327 + logger.metric('onboarding:valueProp:stepTwo:nextPressed', {}) 328 + } else if (subStep === 0) { 329 + setSubStep(1) 330 + logger.metric('onboarding:valueProp:stepOne:nextPressed', {}) 331 + } 332 + } 333 + 334 + const {title, description, alt} = [ 335 + { 336 + title: _(msg`Free your feed`), 337 + description: _( 338 + msg`No more doomscrolling junk-filled algorithms. Find feeds that work for you, not against you.`, 339 + ), 340 + alt: _( 341 + msg`A collection of popular feeds you can find on Bluesky, including News, Booksky, Game Dev, Blacksky, and Fountain Pens`, 342 + ), 343 + }, 344 + { 345 + title: _(msg`Find your people`), 346 + description: _( 347 + msg`Ditch the trolls and clickbait. Find real people and conversations that matter to you.`, 348 + ), 349 + alt: _( 350 + msg`Your profile picture surrounded by concentric circles of other users' profile pictures`, 351 + ), 352 + }, 353 + { 354 + title: _(msg`Forget the noise`), 355 + description: _( 356 + msg`No ads, no invasive tracking, no engagement traps. Bluesky respects your time and attention.`, 357 + ), 358 + alt: _( 359 + msg`An illustration of several Bluesky posts alongside repost, like, and comment icons`, 360 + ), 361 + }, 362 + ][subStep] 363 + 364 + return ( 365 + <> 366 + {!gtMobile && ( 367 + <OnboardingHeaderSlot.Portal> 368 + <Button 369 + disabled={saving} 370 + variant="ghost" 371 + color="secondary" 372 + size="small" 373 + label={_(msg`Skip introduction and start using your account`)} 374 + onPress={() => { 375 + logger.metric('onboarding:valueProp:skipPressed', {}) 376 + finishOnboarding() 377 + }} 378 + style={[a.bg_transparent]}> 379 + <ButtonText> 380 + <Trans>Skip</Trans> 381 + </ButtonText> 382 + </Button> 383 + </OnboardingHeaderSlot.Portal> 384 + )} 385 + 386 + <LayoutAnimationConfig skipEntering skipExiting> 387 + <Animated.View 388 + key={subStep} 389 + entering={native( 390 + SlideInRight.easing(Easing.out(Easing.exp)).duration(500), 391 + )} 392 + exiting={native( 393 + SlideOutLeft.easing(Easing.out(Easing.exp)).duration(500), 394 + )}> 395 + <View 396 + style={[ 397 + a.relative, 398 + a.align_center, 399 + a.justify_center, 400 + isNative && {marginHorizontal: tokens.space.xl * -1}, 401 + a.pointer_events_none, 402 + ]}> 403 + <Image 404 + source={image} 405 + style={[a.w_full, {aspectRatio: 1}]} 406 + alt={alt} 407 + accessibilityIgnoresInvertColors={false} // I guess we do need it to blend into the background 408 + /> 409 + {subStep === 1 && ( 410 + <Image 411 + source={state.profileStepResults.imageUri} 412 + style={[ 413 + a.z_10, 414 + a.absolute, 415 + a.rounded_full, 416 + { 417 + width: `${(80 / 393) * 100}%`, 418 + height: `${(80 / 393) * 100}%`, 419 + }, 420 + ]} 421 + accessibilityIgnoresInvertColors 422 + alt={_(msg`Your profile picture`)} 423 + /> 424 + )} 425 + </View> 426 + 427 + <View style={[a.mt_4xl, a.gap_2xl, a.align_center]}> 428 + <View style={[a.flex_row, a.gap_sm]}> 429 + <Dot active={subStep === 0} /> 430 + <Dot active={subStep === 1} /> 431 + <Dot active={subStep === 2} /> 432 + </View> 433 + 434 + <View style={[a.gap_sm]}> 435 + <Text style={[a.font_heavy, a.text_3xl, a.text_center]}> 436 + {title} 437 + </Text> 438 + <Text 439 + style={[ 440 + t.atoms.text_contrast_medium, 441 + a.text_md, 442 + a.leading_snug, 443 + a.text_center, 444 + ]}> 445 + {description} 446 + </Text> 447 + </View> 448 + </View> 449 + </Animated.View> 450 + </LayoutAnimationConfig> 451 + 452 + <OnboardingControls.Portal> 453 + <View style={gtMobile && [a.gap_md, a.flex_row]}> 454 + {gtMobile && ( 455 + <Button 456 + disabled={saving} 457 + color="secondary" 458 + size="large" 459 + label={_(msg`Skip introduction and start using your account`)} 460 + onPress={() => finishOnboarding()}> 461 + <ButtonText> 462 + <Trans>Skip</Trans> 463 + </ButtonText> 464 + </Button> 465 + )} 466 + <Button 467 + disabled={saving} 468 + key={state.activeStep} // remove focus state on nav 469 + color="primary" 470 + size="large" 471 + label={ 472 + subStep === 2 473 + ? _(msg`Complete onboarding and start using your account`) 474 + : _(msg`Next`) 475 + } 476 + onPress={onPress}> 477 + <ButtonText> 478 + {saving ? ( 479 + <Trans>Finalizing</Trans> 480 + ) : subStep === 2 ? ( 481 + <Trans>Let's go!</Trans> 482 + ) : ( 483 + <Trans>Next</Trans> 484 + )} 485 + </ButtonText> 486 + {subStep === 2 && ( 487 + <ButtonIcon icon={saving ? Loader : ArrowRight} /> 488 + )} 489 + </Button> 490 + </View> 491 + </OnboardingControls.Portal> 492 + </> 493 + ) 494 + } 495 + 496 + function Dot({active}: {active: boolean}) { 497 + const t = useTheme() 498 + const {_} = useLingui() 499 + 500 + return ( 501 + <View 502 + style={[ 503 + a.rounded_full, 504 + {width: 8, height: 8}, 505 + active 506 + ? {backgroundColor: t.palette.primary_500} 507 + : t.atoms.bg_contrast_50, 508 + ]} 509 + /> 510 + ) 511 + } 512 + 513 + function LegacyFinalStep({ 514 + finishOnboarding, 515 + saving, 516 + state, 517 + }: { 518 + finishOnboarding: () => void 519 + saving: boolean 520 + state: OnboardingState 521 + }) { 522 + const t = useTheme() 523 + const {_} = useLingui() 524 + 248 525 return ( 249 526 <View style={[a.align_start]}> 250 527 <IconCircle icon={Check} style={[a.mb_2xl]} /> ··· 305 582 <Button 306 583 disabled={saving} 307 584 key={state.activeStep} // remove focus state on nav 308 - variant="solid" 309 585 color="primary" 310 586 size="large" 311 587 label={_(msg`Complete onboarding and start using your account`)}
+10 -1
src/screens/Onboarding/StepInterests/index.tsx
··· 160 160 161 161 <View style={[a.w_full, a.pt_2xl]}> 162 162 {isLoading ? ( 163 - <Loader size="xl" /> 163 + <View 164 + style={[ 165 + a.flex_1, 166 + a.mt_md, 167 + a.align_center, 168 + a.justify_center, 169 + {minHeight: 400}, 170 + ]}> 171 + <Loader size="xl" /> 172 + </View> 164 173 ) : isError || !data ? ( 165 174 <View 166 175 style={[
+1 -1
src/screens/Onboarding/StepProfile/index.tsx
··· 266 266 </View> 267 267 268 268 <OnboardingControls.Portal> 269 - <View style={[a.gap_md, gtMobile && {flexDirection: 'row-reverse'}]}> 269 + <View style={[a.gap_md, gtMobile && a.flex_row_reverse]}> 270 270 <Button 271 271 variant="solid" 272 272 color="primary"
+356
src/screens/Onboarding/StepSuggestedAccounts/index.tsx
··· 1 + import {useCallback, useContext, useMemo, useState} from 'react' 2 + import {View} from 'react-native' 3 + import {type ModerationOpts} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {useMutation, useQueryClient} from '@tanstack/react-query' 7 + import * as bcp47Match from 'bcp-47-match' 8 + 9 + import {wait} from '#/lib/async/wait' 10 + import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' 11 + import {logger} from '#/logger' 12 + import {isWeb} from '#/platform/detection' 13 + import {updateProfileShadow} from '#/state/cache/profile-shadow' 14 + import {useLanguagePrefs} from '#/state/preferences' 15 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 16 + import {useAgent, useSession} from '#/state/session' 17 + import {useOnboardingDispatch} from '#/state/shell' 18 + import {OnboardingControls} from '#/screens/Onboarding/Layout' 19 + import { 20 + Context, 21 + popularInterests, 22 + useInterestsDisplayNames, 23 + } from '#/screens/Onboarding/state' 24 + import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers' 25 + import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' 26 + import {Admonition} from '#/components/Admonition' 27 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 28 + import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise' 29 + import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 30 + import {boostInterests, InterestTabs} from '#/components/InterestTabs' 31 + import {Loader} from '#/components/Loader' 32 + import * as ProfileCard from '#/components/ProfileCard' 33 + import * as toast from '#/components/Toast' 34 + import {Text} from '#/components/Typography' 35 + import type * as bsky from '#/types/bsky' 36 + import {bulkWriteFollows} from '../util' 37 + 38 + export function StepSuggestedAccounts() { 39 + const {_} = useLingui() 40 + const t = useTheme() 41 + const {gtMobile} = useBreakpoints() 42 + const moderationOpts = useModerationOpts() 43 + const agent = useAgent() 44 + const {currentAccount} = useSession() 45 + const queryClient = useQueryClient() 46 + 47 + const {state, dispatch} = useContext(Context) 48 + const onboardDispatch = useOnboardingDispatch() 49 + 50 + const [selectedInterest, setSelectedInterest] = useState<string | null>(null) 51 + // keeping track of who was followed via the follow all button 52 + // so we can enable/disable the button without having to dig through the shadow cache 53 + const [followedUsers, setFollowedUsers] = useState<string[]>([]) 54 + 55 + /* 56 + * Special language handling copied wholesale from the Explore screen 57 + */ 58 + const {contentLanguages} = useLanguagePrefs() 59 + const useFullExperience = useMemo(() => { 60 + if (contentLanguages.length === 0) return true 61 + return bcp47Match.basicFilter('en', contentLanguages).length > 0 62 + }, [contentLanguages]) 63 + const interestsDisplayNames = useInterestsDisplayNames() 64 + const interests = Object.keys(interestsDisplayNames) 65 + .sort(boostInterests(popularInterests)) 66 + .sort(boostInterests(state.interestsStepResults.selectedInterests)) 67 + const { 68 + data: suggestedUsers, 69 + isLoading, 70 + error, 71 + isRefetching, 72 + refetch, 73 + } = useSuggestedUsers({ 74 + category: selectedInterest || (useFullExperience ? null : interests[0]), 75 + search: !useFullExperience, 76 + overrideInterests: state.interestsStepResults.selectedInterests, 77 + }) 78 + 79 + const isError = !!error 80 + 81 + const skipOnboarding = useCallback(() => { 82 + onboardDispatch({type: 'finish'}) 83 + dispatch({type: 'finish'}) 84 + }, [onboardDispatch, dispatch]) 85 + 86 + const followableDids = 87 + suggestedUsers?.actors 88 + .filter( 89 + user => 90 + user.did !== currentAccount?.did && 91 + !isBlockedOrBlocking(user) && 92 + !isMuted(user) && 93 + !user.viewer?.following && 94 + !followedUsers.includes(user.did), 95 + ) 96 + .map(user => user.did) ?? [] 97 + 98 + const {mutate: followAll, isPending: isFollowingAll} = useMutation({ 99 + onMutate: () => { 100 + logger.metric('onboarding:suggestedAccounts:followAllPressed', { 101 + tab: selectedInterest ?? 'all', 102 + numAccounts: followableDids.length, 103 + }) 104 + }, 105 + mutationFn: async () => { 106 + for (const did of followableDids) { 107 + updateProfileShadow(queryClient, did, { 108 + followingUri: 'pending', 109 + }) 110 + } 111 + const uris = await wait(1e3, bulkWriteFollows(agent, followableDids)) 112 + for (const did of followableDids) { 113 + const uri = uris.get(did) 114 + updateProfileShadow(queryClient, did, { 115 + followingUri: uri, 116 + }) 117 + } 118 + return followableDids 119 + }, 120 + onSuccess: newlyFollowed => { 121 + toast.show(_(msg`Followed all accounts!`), {type: 'success'}) 122 + setFollowedUsers(followed => [...followed, ...newlyFollowed]) 123 + }, 124 + onError: () => { 125 + toast.show( 126 + _(msg`Failed to follow all suggested accounts, please try again`), 127 + {type: 'error'}, 128 + ) 129 + }, 130 + }) 131 + 132 + const canFollowAll = followableDids.length > 0 && !isFollowingAll 133 + 134 + return ( 135 + <View style={[a.align_start]} testID="onboardingInterests"> 136 + <Text style={[a.font_heavy, a.text_3xl]}> 137 + <Trans comment="Accounts suggested to the user for them to follow"> 138 + Suggested for you 139 + </Trans> 140 + </Text> 141 + 142 + <View 143 + style={[ 144 + a.overflow_hidden, 145 + a.mt_lg, 146 + isWeb ? a.max_w_full : {marginHorizontal: tokens.space.xl * -1}, 147 + a.flex_1, 148 + a.justify_start, 149 + ]}> 150 + <TabBar 151 + selectedInterest={selectedInterest} 152 + onSelectInterest={setSelectedInterest} 153 + defaultTabLabel={_( 154 + msg({ 155 + message: 'All', 156 + comment: 'the default tab in the interests tab bar', 157 + }), 158 + )} 159 + selectedInterests={state.interestsStepResults.selectedInterests} 160 + /> 161 + 162 + {isLoading || !moderationOpts ? ( 163 + <View 164 + style={[ 165 + a.flex_1, 166 + a.mt_md, 167 + a.align_center, 168 + a.justify_center, 169 + {minHeight: 400}, 170 + ]}> 171 + <Loader size="xl" /> 172 + </View> 173 + ) : isError ? ( 174 + <View style={[a.flex_1, a.px_xl, a.pt_5xl]}> 175 + <Admonition type="error"> 176 + <Trans> 177 + An error occurred while fetching suggested accounts. 178 + </Trans> 179 + </Admonition> 180 + </View> 181 + ) : ( 182 + <View 183 + style={[ 184 + a.flex_1, 185 + a.mt_md, 186 + a.border_y, 187 + t.atoms.border_contrast_low, 188 + isWeb && [a.border_x, a.rounded_sm, a.overflow_hidden], 189 + ]}> 190 + {suggestedUsers?.actors.map((user, index) => ( 191 + <SuggestedProfileCard 192 + key={user.did} 193 + profile={user} 194 + moderationOpts={moderationOpts} 195 + position={index} 196 + /> 197 + ))} 198 + </View> 199 + )} 200 + </View> 201 + 202 + <OnboardingControls.Portal> 203 + {isError ? ( 204 + <View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}> 205 + <Button 206 + disabled={isRefetching} 207 + color="secondary" 208 + size="large" 209 + label={_(msg`Retry`)} 210 + onPress={() => refetch()}> 211 + <ButtonText> 212 + <Trans>Retry</Trans> 213 + </ButtonText> 214 + <ButtonIcon icon={ArrowRotateCounterClockwiseIcon} /> 215 + </Button> 216 + <Button 217 + color="secondary" 218 + size="large" 219 + label={_(msg`Skip this flow`)} 220 + onPress={skipOnboarding}> 221 + <ButtonText> 222 + <Trans>Skip</Trans> 223 + </ButtonText> 224 + </Button> 225 + </View> 226 + ) : ( 227 + <View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}> 228 + <Button 229 + disabled={!canFollowAll} 230 + color="secondary" 231 + size="large" 232 + label={_(msg`Follow all accounts`)} 233 + onPress={() => followAll()}> 234 + <ButtonText> 235 + <Trans>Follow all</Trans> 236 + </ButtonText> 237 + <ButtonIcon icon={isFollowingAll ? Loader : PlusIcon} /> 238 + </Button> 239 + <Button 240 + disabled={isFollowingAll} 241 + color="primary" 242 + size="large" 243 + label={_(msg`Continue to next step`)} 244 + onPress={() => dispatch({type: 'next'})}> 245 + <ButtonText> 246 + <Trans>Continue</Trans> 247 + </ButtonText> 248 + </Button> 249 + </View> 250 + )} 251 + </OnboardingControls.Portal> 252 + </View> 253 + ) 254 + } 255 + 256 + function TabBar({ 257 + selectedInterest, 258 + onSelectInterest, 259 + selectedInterests, 260 + hideDefaultTab, 261 + defaultTabLabel, 262 + }: { 263 + selectedInterest: string | null 264 + onSelectInterest: (interest: string | null) => void 265 + selectedInterests: string[] 266 + hideDefaultTab?: boolean 267 + defaultTabLabel?: string 268 + }) { 269 + const {_} = useLingui() 270 + const interestsDisplayNames = useInterestsDisplayNames() 271 + const interests = Object.keys(interestsDisplayNames) 272 + .sort(boostInterests(popularInterests)) 273 + .sort(boostInterests(selectedInterests)) 274 + 275 + return ( 276 + <InterestTabs 277 + interests={hideDefaultTab ? interests : ['all', ...interests]} 278 + selectedInterest={ 279 + selectedInterest || (hideDefaultTab ? interests[0] : 'all') 280 + } 281 + onSelectTab={tab => { 282 + logger.metric( 283 + 'onboarding:suggestedAccounts:tabPressed', 284 + {tab: tab}, 285 + {statsig: true}, 286 + ) 287 + onSelectInterest(tab === 'all' ? null : tab) 288 + }} 289 + interestsDisplayNames={ 290 + hideDefaultTab 291 + ? interestsDisplayNames 292 + : { 293 + all: defaultTabLabel || _(msg`For You`), 294 + ...interestsDisplayNames, 295 + } 296 + } 297 + gutterWidth={isWeb ? 0 : tokens.space.xl} 298 + /> 299 + ) 300 + } 301 + 302 + function SuggestedProfileCard({ 303 + profile, 304 + moderationOpts, 305 + position, 306 + }: { 307 + profile: bsky.profile.AnyProfileView 308 + moderationOpts: ModerationOpts 309 + position: number 310 + }) { 311 + const t = useTheme() 312 + return ( 313 + <View 314 + style={[ 315 + a.flex_1, 316 + a.w_full, 317 + a.py_lg, 318 + a.px_xl, 319 + position !== 0 && a.border_t, 320 + t.atoms.border_contrast_low, 321 + ]}> 322 + <ProfileCard.Outer> 323 + <ProfileCard.Header> 324 + <ProfileCard.Avatar 325 + profile={profile} 326 + moderationOpts={moderationOpts} 327 + disabledPreview 328 + /> 329 + <ProfileCard.NameAndHandle 330 + profile={profile} 331 + moderationOpts={moderationOpts} 332 + /> 333 + <ProfileCard.FollowButton 334 + profile={profile} 335 + moderationOpts={moderationOpts} 336 + withIcon={false} 337 + logContext="OnboardingSuggestedAccounts" 338 + onFollow={() => { 339 + logger.metric( 340 + 'suggestedUser:follow', 341 + { 342 + logContext: 'Onboarding', 343 + location: 'Card', 344 + recId: undefined, 345 + position, 346 + }, 347 + {statsig: true}, 348 + ) 349 + }} 350 + /> 351 + </ProfileCard.Header> 352 + <ProfileCard.Description profile={profile} numberOfLines={3} /> 353 + </ProfileCard.Outer> 354 + </View> 355 + ) 356 + }
+34 -15
src/screens/Onboarding/index.tsx
··· 1 - import React from 'react' 1 + import {useMemo, useReducer} from 'react' 2 2 import {msg} from '@lingui/macro' 3 3 import {useLingui} from '@lingui/react' 4 4 5 - import {Layout, OnboardingControls} from '#/screens/Onboarding/Layout' 5 + import {useGate} from '#/lib/statsig/statsig' 6 + import { 7 + Layout, 8 + OnboardingControls, 9 + OnboardingHeaderSlot, 10 + } from '#/screens/Onboarding/Layout' 6 11 import {Context, initialState, reducer} from '#/screens/Onboarding/state' 7 12 import {StepFinished} from '#/screens/Onboarding/StepFinished' 8 13 import {StepInterests} from '#/screens/Onboarding/StepInterests' 9 14 import {StepProfile} from '#/screens/Onboarding/StepProfile' 10 15 import {Portal} from '#/components/Portal' 16 + import {StepSuggestedAccounts} from './StepSuggestedAccounts' 11 17 12 18 export function Onboarding() { 13 19 const {_} = useLingui() 14 - const [state, dispatch] = React.useReducer(reducer, { 20 + const gate = useGate() 21 + const showValueProp = gate('onboarding_value_prop') 22 + const showSuggestedAccounts = gate('onboarding_suggested_accounts') 23 + const [state, dispatch] = useReducer(reducer, { 15 24 ...initialState, 25 + totalSteps: showSuggestedAccounts ? 4 : 3, 26 + experiments: { 27 + onboarding_suggested_accounts: showSuggestedAccounts, 28 + onboarding_value_prop: showValueProp, 29 + }, 16 30 }) 17 31 18 - const interestsDisplayNames = React.useMemo(() => { 32 + const interestsDisplayNames = useMemo(() => { 19 33 return { 20 34 news: _(msg`News`), 21 35 journalism: _(msg`Journalism`), ··· 45 59 return ( 46 60 <Portal> 47 61 <OnboardingControls.Provider> 48 - <Context.Provider 49 - value={React.useMemo( 50 - () => ({state, dispatch, interestsDisplayNames}), 51 - [state, dispatch, interestsDisplayNames], 52 - )}> 53 - <Layout> 54 - {state.activeStep === 'profile' && <StepProfile />} 55 - {state.activeStep === 'interests' && <StepInterests />} 56 - {state.activeStep === 'finished' && <StepFinished />} 57 - </Layout> 58 - </Context.Provider> 62 + <OnboardingHeaderSlot.Provider> 63 + <Context.Provider 64 + value={useMemo( 65 + () => ({state, dispatch, interestsDisplayNames}), 66 + [state, dispatch, interestsDisplayNames], 67 + )}> 68 + <Layout> 69 + {state.activeStep === 'profile' && <StepProfile />} 70 + {state.activeStep === 'interests' && <StepInterests />} 71 + {state.activeStep === 'suggested-accounts' && ( 72 + <StepSuggestedAccounts /> 73 + )} 74 + {state.activeStep === 'finished' && <StepFinished />} 75 + </Layout> 76 + </Context.Provider> 77 + </OnboardingHeaderSlot.Provider> 59 78 </OnboardingControls.Provider> 60 79 </Portal> 61 80 )
+45 -13
src/screens/Onboarding/state.ts
··· 11 11 export type OnboardingState = { 12 12 hasPrev: boolean 13 13 totalSteps: number 14 - activeStep: 'profile' | 'interests' | 'finished' 14 + activeStep: 'profile' | 'interests' | 'suggested-accounts' | 'finished' 15 15 activeStepIndex: number 16 16 17 17 interestsStepResults: { ··· 33 33 emoji: Emoji 34 34 backgroundColor: AvatarColor 35 35 } 36 + } 37 + 38 + experiments?: { 39 + onboarding_suggested_accounts?: boolean 40 + onboarding_value_prop?: boolean 36 41 } 37 42 } 38 43 ··· 160 165 161 166 switch (a.type) { 162 167 case 'next': { 163 - if (s.activeStep === 'profile') { 164 - next.activeStep = 'interests' 165 - next.activeStepIndex = 2 166 - } else if (s.activeStep === 'interests') { 167 - next.activeStep = 'finished' 168 - next.activeStepIndex = 3 168 + if (s.experiments?.onboarding_suggested_accounts) { 169 + if (s.activeStep === 'profile') { 170 + next.activeStep = 'interests' 171 + next.activeStepIndex = 2 172 + } else if (s.activeStep === 'interests') { 173 + next.activeStep = 'suggested-accounts' 174 + next.activeStepIndex = 3 175 + } 176 + if (s.activeStep === 'suggested-accounts') { 177 + next.activeStep = 'finished' 178 + next.activeStepIndex = 4 179 + } 180 + } else { 181 + if (s.activeStep === 'profile') { 182 + next.activeStep = 'interests' 183 + next.activeStepIndex = 2 184 + } else if (s.activeStep === 'interests') { 185 + next.activeStep = 'finished' 186 + next.activeStepIndex = 3 187 + } 169 188 } 170 189 break 171 190 } 172 191 case 'prev': { 173 - if (s.activeStep === 'interests') { 174 - next.activeStep = 'profile' 175 - next.activeStepIndex = 1 176 - } else if (s.activeStep === 'finished') { 177 - next.activeStep = 'interests' 178 - next.activeStepIndex = 2 192 + if (s.experiments?.onboarding_suggested_accounts) { 193 + if (s.activeStep === 'interests') { 194 + next.activeStep = 'profile' 195 + next.activeStepIndex = 1 196 + } else if (s.activeStep === 'suggested-accounts') { 197 + next.activeStep = 'interests' 198 + next.activeStepIndex = 2 199 + } else if (s.activeStep === 'finished') { 200 + next.activeStep = 'suggested-accounts' 201 + next.activeStepIndex = 3 202 + } 203 + } else { 204 + if (s.activeStep === 'interests') { 205 + next.activeStep = 'profile' 206 + next.activeStepIndex = 1 207 + } else if (s.activeStep === 'finished') { 208 + next.activeStep = 'interests' 209 + next.activeStepIndex = 2 210 + } 179 211 } 180 212 break 181 213 }
+7 -7
src/screens/Onboarding/util.ts
··· 1 1 import { 2 - $Typed, 3 - AppBskyGraphFollow, 4 - AppBskyGraphGetFollows, 5 - BskyAgent, 6 - ComAtprotoRepoApplyWrites, 2 + type $Typed, 3 + type AppBskyGraphFollow, 4 + type AppBskyGraphGetFollows, 5 + type BskyAgent, 6 + type ComAtprotoRepoApplyWrites, 7 7 } from '@atproto/api' 8 8 import {TID} from '@atproto/common-web' 9 9 import chunk from 'lodash.chunk' ··· 42 42 } 43 43 await whenFollowsIndexed(agent, session.did, res => !!res.data.follows.length) 44 44 45 - const followUris = new Map() 45 + const followUris = new Map<string, string>() 46 46 for (const r of followWrites) { 47 47 followUris.set( 48 - r.value.subject, 48 + r.value.subject as string, 49 49 `at://${session.did}/app.bsky.graph.follow/${r.rkey}`, 50 50 ) 51 51 }
+1 -1
src/screens/Search/Explore.tsx
··· 66 66 import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' 67 67 import {StarterPack} from '#/components/icons/StarterPack' 68 68 import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle' 69 + import {boostInterests} from '#/components/InterestTabs' 69 70 import {Loader} from '#/components/Loader' 70 71 import * as ProfileCard from '#/components/ProfileCard' 71 - import {boostInterests} from '#/components/ProgressGuide/FollowDialog' 72 72 import {SubtleHover} from '#/components/SubtleHover' 73 73 import {Text} from '#/components/Typography' 74 74 import * as ModuleHeader from './components/ModuleHeader'
+6 -68
src/screens/Search/modules/ExploreSuggestedAccounts.tsx
··· 14 14 } from '#/screens/Onboarding/state' 15 15 import {useTheme} from '#/alf' 16 16 import {atoms as a} from '#/alf' 17 - import {Button} from '#/components/Button' 17 + import {boostInterests, InterestTabs} from '#/components/InterestTabs' 18 18 import * as ProfileCard from '#/components/ProfileCard' 19 - import {boostInterests, Tabs} from '#/components/ProgressGuide/FollowDialog' 20 19 import {SubtleHover} from '#/components/SubtleHover' 21 - import {Text} from '#/components/Typography' 22 20 import type * as bsky from '#/types/bsky' 23 21 24 22 export function useLoadEnoughProfiles({ ··· 59 57 selectedInterest, 60 58 onSelectInterest, 61 59 hideDefaultTab, 60 + defaultTabLabel, 62 61 }: { 63 62 selectedInterest: string | null 64 63 onSelectInterest: (interest: string | null) => void 65 64 hideDefaultTab?: boolean 65 + defaultTabLabel?: string 66 66 }) { 67 67 const {_} = useLingui() 68 68 const interestsDisplayNames = useInterestsDisplayNames() ··· 71 71 const interests = Object.keys(interestsDisplayNames) 72 72 .sort(boostInterests(popularInterests)) 73 73 .sort(boostInterests(personalizedInterests)) 74 + 74 75 return ( 75 76 <BlockDrawerGesture> 76 - <Tabs 77 + <InterestTabs 77 78 interests={hideDefaultTab ? interests : ['all', ...interests]} 78 79 selectedInterest={ 79 80 selectedInterest || (hideDefaultTab ? interests[0] : 'all') ··· 86 87 ) 87 88 onSelectInterest(tab === 'all' ? null : tab) 88 89 }} 89 - hasSearchText={false} 90 90 interestsDisplayNames={ 91 91 hideDefaultTab 92 92 ? interestsDisplayNames 93 93 : { 94 - all: _(msg`For You`), 94 + all: defaultTabLabel || _(msg`For You`), 95 95 ...interestsDisplayNames, 96 96 } 97 97 } 98 - TabComponent={Tab} 99 - contentContainerStyle={[ 100 - { 101 - // visual alignment 102 - paddingLeft: a.px_md.paddingLeft, 103 - }, 104 - ]} 105 98 /> 106 99 </BlockDrawerGesture> 107 100 ) 108 101 } 109 - 110 - let Tab = ({ 111 - onSelectTab, 112 - interest, 113 - active, 114 - index, 115 - interestsDisplayName, 116 - onLayout, 117 - }: { 118 - onSelectTab: (index: number) => void 119 - interest: string 120 - active: boolean 121 - index: number 122 - interestsDisplayName: string 123 - onLayout: (index: number, x: number, width: number) => void 124 - }): React.ReactNode => { 125 - const t = useTheme() 126 - const {_} = useLingui() 127 - const activeText = active ? _(msg` (active)`) : '' 128 - return ( 129 - <View 130 - key={interest} 131 - onLayout={e => 132 - onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) 133 - }> 134 - <Button 135 - label={_(msg`Search for "${interestsDisplayName}"${activeText}`)} 136 - onPress={() => onSelectTab(index)}> 137 - {({hovered, pressed, focused}) => ( 138 - <View 139 - style={[ 140 - a.rounded_full, 141 - a.px_lg, 142 - a.py_sm, 143 - a.border, 144 - active || hovered || pressed || focused 145 - ? [t.atoms.bg_contrast_25, t.atoms.border_contrast_medium] 146 - : [t.atoms.bg, t.atoms.border_contrast_low], 147 - ]}> 148 - <Text 149 - style={[ 150 - a.font_medium, 151 - active || hovered || pressed || focused 152 - ? t.atoms.text 153 - : t.atoms.text_contrast_medium, 154 - ]}> 155 - {interestsDisplayName} 156 - </Text> 157 - </View> 158 - )} 159 - </Button> 160 - </View> 161 - ) 162 - } 163 - Tab = memo(Tab) 164 102 165 103 /** 166 104 * Profile card for suggested accounts. Note: border is on the bottom edge
+9
src/screens/Search/util/useSuggestedUsers.ts
··· 11 11 export function useSuggestedUsers({ 12 12 category = null, 13 13 search = false, 14 + overrideInterests, 14 15 }: { 15 16 category?: string | null 16 17 /** ··· 18 19 * based on the user's "app language setting 19 20 */ 20 21 search?: boolean 22 + /** 23 + * In onboarding, interests haven't been saved to prefs yet, so we need to 24 + * pass them down through here 25 + */ 26 + overrideInterests?: string[] 21 27 }) { 22 28 const interestsDisplayNames = useInterestsDisplayNames() 23 29 const curated = useGetSuggestedUsersQuery({ 24 30 enabled: !search, 25 31 category, 32 + overrideInterests, 26 33 }) 27 34 const searched = useActorSearchPaginated({ 28 35 enabled: !!search, ··· 43 50 isLoading: searched.isLoading, 44 51 error: searched.error, 45 52 isRefetching: searched.isRefetching, 53 + refetch: searched.refetch, 46 54 } 47 55 } else { 48 56 return { ··· 50 58 isLoading: curated.isLoading, 51 59 error: curated.error, 52 60 isRefetching: curated.isRefetching, 61 + refetch: curated.refetch, 53 62 } 54 63 } 55 64 }, [curated, searched, search])
+8 -1
src/state/queries/trending/useGetSuggestedUsersQuery.ts
··· 17 17 category?: string | null 18 18 limit?: number 19 19 enabled?: boolean 20 + overrideInterests?: string[] 20 21 } 21 22 22 23 export const getSuggestedUsersQueryKeyRoot = 'unspecced-suggested-users' ··· 24 25 getSuggestedUsersQueryKeyRoot, 25 26 props.category, 26 27 props.limit, 28 + props.overrideInterests?.join(','), 27 29 ] 28 30 29 31 export function useGetSuggestedUsersQuery(props: QueryProps) { ··· 36 38 queryKey: createGetSuggestedUsersQueryKey(props), 37 39 queryFn: async () => { 38 40 const contentLangs = getContentLanguages().join(',') 41 + const interests = aggregateUserInterests(preferences) 39 42 const {data} = await agent.app.bsky.unspecced.getSuggestedUsers( 40 43 { 41 44 category: props.category ?? undefined, ··· 43 46 }, 44 47 { 45 48 headers: { 46 - ...createBskyTopicsHeader(aggregateUserInterests(preferences)), 49 + ...createBskyTopicsHeader( 50 + props.overrideInterests && props.overrideInterests.length > 0 51 + ? props.overrideInterests.join(',') 52 + : interests, 53 + ), 47 54 'Accept-Language': contentLangs, 48 55 }, 49 56 },