diff options
author | Samuel Newman <mozzius@protonmail.com> | 2025-08-27 14:17:45 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-08-27 04:17:45 -0700 |
commit | eac02901435d7bc79a28e0bff665352b814f9508 (patch) | |
tree | 8d770830a0c7081c5e4ed941b192da34ecb538a8 | |
parent | 0617fca5ef1d30e7db49eb7dc8e66f6b586cc207 (diff) | |
download | voidsky-eac02901435d7bc79a28e0bff665352b814f9508.tar.zst |
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>
30 files changed, 1302 insertions, 291 deletions
diff --git a/assets/images/onboarding/value_prop_1_dark.webp b/assets/images/onboarding/value_prop_1_dark.webp new file mode 100644 index 000000000..259e6155b --- /dev/null +++ b/assets/images/onboarding/value_prop_1_dark.webp Binary files differdiff --git a/assets/images/onboarding/value_prop_1_dark_borderless.webp b/assets/images/onboarding/value_prop_1_dark_borderless.webp new file mode 100644 index 000000000..5c44735f9 --- /dev/null +++ b/assets/images/onboarding/value_prop_1_dark_borderless.webp Binary files differdiff --git a/assets/images/onboarding/value_prop_1_dim.webp b/assets/images/onboarding/value_prop_1_dim.webp new file mode 100644 index 000000000..2fea1b991 --- /dev/null +++ b/assets/images/onboarding/value_prop_1_dim.webp Binary files differdiff --git a/assets/images/onboarding/value_prop_1_dim_borderless.webp b/assets/images/onboarding/value_prop_1_dim_borderless.webp new file mode 100644 index 000000000..98c7542b4 --- /dev/null +++ b/assets/images/onboarding/value_prop_1_dim_borderless.webp Binary files differdiff --git a/assets/images/onboarding/value_prop_1_light.webp b/assets/images/onboarding/value_prop_1_light.webp new file mode 100644 index 000000000..13ffbf02b --- /dev/null +++ b/assets/images/onboarding/value_prop_1_light.webp Binary files differdiff --git a/assets/images/onboarding/value_prop_1_light_borderless.webp b/assets/images/onboarding/value_prop_1_light_borderless.webp new file mode 100644 index 000000000..1cf0cd032 --- /dev/null +++ b/assets/images/onboarding/value_prop_1_light_borderless.webp Binary files differdiff --git a/assets/images/onboarding/value_prop_2_dark.webp b/assets/images/onboarding/value_prop_2_dark.webp new file mode 100644 index 000000000..7a60d554e --- /dev/null +++ b/assets/images/onboarding/value_prop_2_dark.webp Binary files differdiff --git a/assets/images/onboarding/value_prop_2_dim.webp b/assets/images/onboarding/value_prop_2_dim.webp new file mode 100644 index 000000000..86f2c1f89 --- /dev/null +++ b/assets/images/onboarding/value_prop_2_dim.webp Binary files differdiff --git a/assets/images/onboarding/value_prop_2_light.webp b/assets/images/onboarding/value_prop_2_light.webp new file mode 100644 index 000000000..2b385a3f2 --- /dev/null +++ b/assets/images/onboarding/value_prop_2_light.webp Binary files differdiff --git a/assets/images/onboarding/value_prop_3_dark.webp b/assets/images/onboarding/value_prop_3_dark.webp new file mode 100644 index 000000000..0ea0dba1c --- /dev/null +++ b/assets/images/onboarding/value_prop_3_dark.webp Binary files differdiff --git a/assets/images/onboarding/value_prop_3_dim.webp b/assets/images/onboarding/value_prop_3_dim.webp new file mode 100644 index 000000000..a0986e94b --- /dev/null +++ b/assets/images/onboarding/value_prop_3_dim.webp Binary files differdiff --git a/assets/images/onboarding/value_prop_3_light.webp b/assets/images/onboarding/value_prop_3_light.webp new file mode 100644 index 000000000..cd8bcbb45 --- /dev/null +++ b/assets/images/onboarding/value_prop_3_light.webp Binary files differdiff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 5b7c5c87e..c0f959ec8 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -70,9 +70,21 @@ export const atoms = { overflow_visible: { overflow: 'visible', }, + overflow_x_visible: { + overflowX: 'visible', + }, + overflow_y_visible: { + overflowY: 'visible', + }, overflow_hidden: { overflow: 'hidden', }, + overflow_x_hidden: { + overflowX: 'hidden', + }, + overflow_y_hidden: { + overflowY: 'hidden', + }, /** * @platform web */ @@ -363,6 +375,14 @@ export const atoms = { border_r_0: { borderRightWidth: 0, }, + border_x_0: { + borderLeftWidth: 0, + borderRightWidth: 0, + }, + border_y_0: { + borderTopWidth: 0, + borderBottomWidth: 0, + }, border: { borderWidth: StyleSheet.hairlineWidth, }, @@ -378,6 +398,14 @@ export const atoms = { border_r: { borderRightWidth: StyleSheet.hairlineWidth, }, + border_x: { + borderLeftWidth: StyleSheet.hairlineWidth, + borderRightWidth: StyleSheet.hairlineWidth, + }, + border_y: { + borderTopWidth: StyleSheet.hairlineWidth, + borderBottomWidth: StyleSheet.hairlineWidth, + }, border_transparent: { borderColor: 'transparent', }, diff --git a/src/components/InterestTabs.tsx b/src/components/InterestTabs.tsx new file mode 100644 index 000000000..b61157ed8 --- /dev/null +++ b/src/components/InterestTabs.tsx @@ -0,0 +1,390 @@ +import {useEffect, useRef, useState} from 'react' +import { + type ScrollView, + type StyleProp, + View, + type ViewStyle, +} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {isWeb} from '#/platform/detection' +import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView' +import {atoms as a, tokens, useTheme, web} from '#/alf' +import {transparentifyColor} from '#/alf/util/colorGeneration' +import {Button, ButtonIcon} from '#/components/Button' +import { + ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft, + ArrowRight_Stroke2_Corner0_Rounded as ArrowRight, +} from '#/components/icons/Arrow' +import {Text} from '#/components/Typography' + +/** + * Tab component that automatically scrolls the selected tab into view - used for interests + * in the Find Follows dialog, Explore screen, etc. + */ +export function InterestTabs({ + onSelectTab, + interests, + selectedInterest, + disabled, + interestsDisplayNames, + TabComponent = Tab, + contentContainerStyle, + gutterWidth = tokens.space.lg, +}: { + onSelectTab: (tab: string) => void + interests: string[] + selectedInterest: string + interestsDisplayNames: Record<string, string> + /** still allows changing tab, but removes the active state from the selected tab */ + disabled?: boolean + TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>> + contentContainerStyle?: StyleProp<ViewStyle> + gutterWidth?: number +}) { + const t = useTheme() + const {_} = useLingui() + const listRef = useRef<ScrollView>(null) + const [totalWidth, setTotalWidth] = useState(0) + const [scrollX, setScrollX] = useState(0) + const [contentWidth, setContentWidth] = useState(0) + const pendingTabOffsets = useRef<{x: number; width: number}[]>([]) + const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([]) + + const onInitialLayout = useNonReactiveCallback(() => { + const index = interests.indexOf(selectedInterest) + scrollIntoViewIfNeeded(index) + }) + + useEffect(() => { + if (tabOffsets) { + onInitialLayout() + } + }, [tabOffsets, onInitialLayout]) + + function scrollIntoViewIfNeeded(index: number) { + const btnLayout = tabOffsets[index] + if (!btnLayout) return + listRef.current?.scrollTo({ + // centered + x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2), + animated: true, + }) + } + + function handleSelectTab(index: number) { + const tab = interests[index] + onSelectTab(tab) + scrollIntoViewIfNeeded(index) + } + + function handleTabLayout(index: number, x: number, width: number) { + if (!tabOffsets.length) { + pendingTabOffsets.current[index] = {x, width} + if (pendingTabOffsets.current.length === interests.length) { + setTabOffsets(pendingTabOffsets.current) + } + } + } + + const canScrollLeft = scrollX > 0 + const canScrollRight = scrollX < contentWidth - totalWidth + + const cleanupRef = useRef<(() => void) | null>(null) + + function scrollLeft() { + if (isContinuouslyScrollingRef.current) { + return + } + if (listRef.current && canScrollLeft) { + const newScrollX = Math.max(0, scrollX - 200) + listRef.current.scrollTo({x: newScrollX, animated: true}) + } + } + + function scrollRight() { + if (isContinuouslyScrollingRef.current) { + return + } + if (listRef.current && canScrollRight) { + const maxScroll = contentWidth - totalWidth + const newScrollX = Math.min(maxScroll, scrollX + 200) + listRef.current.scrollTo({x: newScrollX, animated: true}) + } + } + + const isContinuouslyScrollingRef = useRef(false) + + function startContinuousScroll(direction: 'left' | 'right') { + // Clear any existing continuous scroll + if (cleanupRef.current) { + cleanupRef.current() + } + + let holdTimeout: NodeJS.Timeout | null = null + let animationFrame: number | null = null + let isActive = true + isContinuouslyScrollingRef.current = false + + const cleanup = () => { + isActive = false + if (holdTimeout) clearTimeout(holdTimeout) + if (animationFrame) cancelAnimationFrame(animationFrame) + cleanupRef.current = null + // Reset flag after a delay to prevent onPress from firing + setTimeout(() => { + isContinuouslyScrollingRef.current = false + }, 100) + } + + cleanupRef.current = cleanup + + // Start continuous scrolling after hold delay + holdTimeout = setTimeout(() => { + if (!isActive) return + + isContinuouslyScrollingRef.current = true + let currentScrollPosition = scrollX + + const scroll = () => { + if (!isActive || !listRef.current) return + + const scrollAmount = 3 + const maxScroll = contentWidth - totalWidth + + let newScrollX: number + let canContinue = false + + if (direction === 'left' && currentScrollPosition > 0) { + newScrollX = Math.max(0, currentScrollPosition - scrollAmount) + canContinue = newScrollX > 0 + } else if (direction === 'right' && currentScrollPosition < maxScroll) { + newScrollX = Math.min(maxScroll, currentScrollPosition + scrollAmount) + canContinue = newScrollX < maxScroll + } else { + return + } + + currentScrollPosition = newScrollX + listRef.current.scrollTo({x: newScrollX, animated: false}) + + if (canContinue && isActive) { + animationFrame = requestAnimationFrame(scroll) + } + } + + scroll() + }, 500) + } + + function stopContinuousScroll() { + if (cleanupRef.current) { + cleanupRef.current() + } + } + + useEffect(() => { + return () => { + if (cleanupRef.current) { + cleanupRef.current() + } + } + }, []) + + return ( + <View style={[a.relative, a.flex_row]}> + <DraggableScrollView + ref={listRef} + contentContainerStyle={[ + a.gap_sm, + {paddingHorizontal: gutterWidth}, + contentContainerStyle, + ]} + showsHorizontalScrollIndicator={false} + decelerationRate="fast" + snapToOffsets={ + tabOffsets.length === interests.length + ? tabOffsets.map(o => o.x - tokens.space.xl) + : undefined + } + onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)} + onContentSizeChange={width => setContentWidth(width)} + onScroll={evt => { + const newScrollX = evt.nativeEvent.contentOffset.x + setScrollX(newScrollX) + }} + scrollEventThrottle={16}> + {interests.map((interest, i) => { + const active = interest === selectedInterest && !disabled + return ( + <TabComponent + key={interest} + onSelectTab={handleSelectTab} + active={active} + index={i} + interest={interest} + interestsDisplayName={interestsDisplayNames[interest]} + onLayout={handleTabLayout} + /> + ) + })} + </DraggableScrollView> + {isWeb && canScrollLeft && ( + <View + style={[ + a.absolute, + a.top_0, + a.left_0, + a.bottom_0, + a.justify_center, + {paddingLeft: gutterWidth}, + a.pr_md, + a.z_10, + web({ + background: `linear-gradient(to right, ${t.atoms.bg.backgroundColor} 0%, ${t.atoms.bg.backgroundColor} 70%, ${transparentifyColor(t.atoms.bg.backgroundColor, 0)} 100%)`, + }), + ]}> + <Button + label={_(msg`Scroll left`)} + onPress={scrollLeft} + onPressIn={() => startContinuousScroll('left')} + onPressOut={stopContinuousScroll} + color="secondary" + size="small" + style={[ + a.border, + t.atoms.border_contrast_low, + t.atoms.bg, + a.h_full, + {aspectRatio: 1}, + a.rounded_full, + ]}> + <ButtonIcon icon={ArrowLeft} /> + </Button> + </View> + )} + {isWeb && canScrollRight && ( + <View + style={[ + a.absolute, + a.top_0, + a.right_0, + a.bottom_0, + a.justify_center, + {paddingRight: gutterWidth}, + a.pl_md, + a.z_10, + web({ + background: `linear-gradient(to left, ${t.atoms.bg.backgroundColor} 0%, ${t.atoms.bg.backgroundColor} 70%, ${transparentifyColor(t.atoms.bg.backgroundColor, 0)} 100%)`, + }), + ]}> + <Button + label={_(msg`Scroll right`)} + onPress={scrollRight} + onPressIn={() => startContinuousScroll('right')} + onPressOut={stopContinuousScroll} + color="secondary" + size="small" + style={[ + a.border, + t.atoms.border_contrast_low, + t.atoms.bg, + a.h_full, + {aspectRatio: 1}, + a.rounded_full, + ]}> + <ButtonIcon icon={ArrowRight} /> + </Button> + </View> + )} + </View> + ) +} + +function Tab({ + onSelectTab, + interest, + active, + index, + interestsDisplayName, + onLayout, +}: { + onSelectTab: (index: number) => void + interest: string + active: boolean + index: number + interestsDisplayName: string + onLayout: (index: number, x: number, width: number) => void +}) { + const t = useTheme() + const {_} = useLingui() + const label = active + ? _( + msg({ + message: `"${interestsDisplayName}" category (active)`, + comment: + '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.', + }), + ) + : _( + msg({ + message: `Select "${interestsDisplayName}" category`, + comment: + '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.', + }), + ) + + return ( + <View + key={interest} + onLayout={e => + onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) + }> + <Button + label={label} + onPress={() => onSelectTab(index)} + // disable focus ring, we handle it + style={web({outline: 'none'})}> + {({hovered, pressed, focused}) => ( + <View + style={[ + a.rounded_full, + a.px_lg, + a.py_sm, + a.border, + active || hovered || pressed + ? [t.atoms.bg_contrast_25, t.atoms.border_contrast_medium] + : focused + ? { + borderColor: t.palette.primary_300, + backgroundColor: t.palette.primary_25, + } + : [t.atoms.bg, t.atoms.border_contrast_low], + ]}> + <Text + style={[ + a.font_medium, + active || hovered || pressed + ? t.atoms.text + : t.atoms.text_contrast_medium, + ]}> + {interestsDisplayName} + </Text> + </View> + )} + </Button> + </View> + ) +} + +export function boostInterests(boosts?: string[]) { + return (_a: string, _b: string) => { + const indexA = boosts?.indexOf(_a) ?? -1 + const indexB = boosts?.indexOf(_b) ?? -1 + const rankA = indexA === -1 ? Infinity : indexA + const rankB = indexB === -1 ? Infinity : indexB + return rankA - rankB + } +} diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx index 20ebb0abf..a2ec4df13 100644 --- a/src/components/ProgressGuide/FollowDialog.tsx +++ b/src/components/ProgressGuide/FollowDialog.tsx @@ -1,17 +1,9 @@ import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' -import { - ScrollView, - type StyleProp, - TextInput, - useWindowDimensions, - View, - type ViewStyle, -} from 'react-native' +import {TextInput, useWindowDimensions, View} from 'react-native' import {type ModerationOpts} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {logEvent} from '#/lib/statsig/statsig' import {isWeb} from '#/platform/detection' import {useModerationOpts} from '#/state/preferences/moderation-opts' @@ -28,7 +20,6 @@ import { import { atoms as a, native, - tokens, useBreakpoints, useTheme, type ViewStyleProp, @@ -40,6 +31,7 @@ import {useInteractionState} from '#/components/hooks/useInteractionState' import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2' import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {boostInterests, InterestTabs} from '#/components/InterestTabs' import * as ProfileCard from '#/components/ProfileCard' import {Text} from '#/components/Typography' import type * as bsky from '#/types/bsky' @@ -337,12 +329,13 @@ let Header = ({ }} onEscape={control.close} /> - <Tabs + <InterestTabs onSelectTab={onSelectTab} interests={interests} selectedInterest={selectedInterest} - hasSearchText={!!searchText} + disabled={!!searchText} interestsDisplayNames={interestsDisplayNames} + TabComponent={Tab} /> </View> </View> @@ -403,99 +396,6 @@ function HeaderTop({guide}: {guide: Follow10ProgressGuide}) { ) } -let Tabs = ({ - onSelectTab, - interests, - selectedInterest, - hasSearchText, - interestsDisplayNames, - TabComponent = Tab, - contentContainerStyle, -}: { - onSelectTab: (tab: string) => void - interests: string[] - selectedInterest: string - hasSearchText: boolean - interestsDisplayNames: Record<string, string> - TabComponent?: React.ComponentType<React.ComponentProps<typeof Tab>> - contentContainerStyle?: StyleProp<ViewStyle> -}): React.ReactNode => { - const listRef = useRef<ScrollView>(null) - const [totalWidth, setTotalWidth] = useState(0) - const pendingTabOffsets = useRef<{x: number; width: number}[]>([]) - const [tabOffsets, setTabOffsets] = useState<{x: number; width: number}[]>([]) - - const onInitialLayout = useNonReactiveCallback(() => { - const index = interests.indexOf(selectedInterest) - scrollIntoViewIfNeeded(index) - }) - - useEffect(() => { - if (tabOffsets) { - onInitialLayout() - } - }, [tabOffsets, onInitialLayout]) - - function scrollIntoViewIfNeeded(index: number) { - const btnLayout = tabOffsets[index] - if (!btnLayout) return - listRef.current?.scrollTo({ - // centered - x: btnLayout.x - (totalWidth / 2 - btnLayout.width / 2), - animated: true, - }) - } - - function handleSelectTab(index: number) { - const tab = interests[index] - onSelectTab(tab) - scrollIntoViewIfNeeded(index) - } - - function handleTabLayout(index: number, x: number, width: number) { - if (!tabOffsets.length) { - pendingTabOffsets.current[index] = {x, width} - if (pendingTabOffsets.current.length === interests.length) { - setTabOffsets(pendingTabOffsets.current) - } - } - } - - return ( - <ScrollView - ref={listRef} - horizontal - contentContainerStyle={[a.gap_sm, a.px_lg, contentContainerStyle]} - showsHorizontalScrollIndicator={false} - decelerationRate="fast" - snapToOffsets={ - tabOffsets.length === interests.length - ? tabOffsets.map(o => o.x - tokens.space.xl) - : undefined - } - onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)} - scrollEventThrottle={200} // big throttle - > - {interests.map((interest, i) => { - const active = interest === selectedInterest && !hasSearchText - return ( - <TabComponent - key={interest} - onSelectTab={handleSelectTab} - active={active} - index={i} - interest={interest} - interestsDisplayName={interestsDisplayNames[interest]} - onLayout={handleTabLayout} - /> - ) - })} - </ScrollView> - ) -} -Tabs = memo(Tabs) -export {Tabs} - let Tab = ({ onSelectTab, interest, @@ -513,24 +413,36 @@ let Tab = ({ }): React.ReactNode => { const t = useTheme() const {_} = useLingui() - const activeText = active ? _(msg` (active)`) : '' + const label = active + ? _( + msg({ + message: `Search for "${interestsDisplayName}" (active)`, + comment: + '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.', + }), + ) + : _( + msg({ + message: `Search for "${interestsDisplayName}`, + comment: + '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.', + }), + ) return ( <View key={interest} onLayout={e => onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) }> - <Button - label={_(msg`Search for "${interestsDisplayName}"${activeText}`)} - onPress={() => onSelectTab(index)}> - {({hovered, pressed, focused}) => ( + <Button label={label} onPress={() => onSelectTab(index)}> + {({hovered, pressed}) => ( <View style={[ a.rounded_full, a.px_lg, a.py_sm, a.border, - active || hovered || pressed || focused + active || hovered || pressed ? [ t.atoms.bg_contrast_25, {borderColor: t.atoms.bg_contrast_25.backgroundColor}, @@ -540,7 +452,7 @@ let Tab = ({ <Text style={[ a.font_medium, - active || hovered || pressed || focused + active || hovered || pressed ? t.atoms.text : t.atoms.text_contrast_medium, ]}> @@ -759,13 +671,3 @@ function Empty({message}: {message: string}) { </View> ) } - -export function boostInterests(boosts?: string[]) { - return (_a: string, _b: string) => { - const indexA = boosts?.indexOf(_a) ?? -1 - const indexB = boosts?.indexOf(_b) ?? -1 - const rankA = indexA === -1 ? Infinity : indexA - const rankB = indexB === -1 ? Infinity : indexB - return rankA - rankB - } -} diff --git a/src/lib/hooks/useDraggableScrollView.ts b/src/lib/hooks/useDraggableScrollView.ts index 05fda9a9f..d4d35ccda 100644 --- a/src/lib/hooks/useDraggableScrollView.ts +++ b/src/lib/hooks/useDraggableScrollView.ts @@ -20,9 +20,6 @@ export function useDraggableScroll<Scrollable extends ScrollView = ScrollView>({ return } const slider = ref.current as unknown as HTMLDivElement - if (!slider) { - return - } let isDragging = false let isMouseDown = false let startX = 0 @@ -61,6 +58,9 @@ export function useDraggableScroll<Scrollable extends ScrollView = ScrollView>({ e.preventDefault() const walk = x - startX slider.scrollLeft = scrollLeft - walk + + if (slider.contains(document.activeElement)) + (document.activeElement as HTMLElement)?.blur?.() } slider.addEventListener('mousedown', mouseDown) diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 114048ab7..391314162 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -9,6 +9,8 @@ export type Gate = | 'explore_show_suggested_feeds' | 'old_postonboarding' | 'onboarding_add_video_feed' + | 'onboarding_suggested_accounts' + | 'onboarding_value_prop' | 'post_follow_profile_suggested_accounts' | 'remove_show_latest_button' | 'test_gate_1' diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index e51905f84..1cb4eb9d3 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -90,6 +90,13 @@ export type MetricEvents = { selectedInterests: string[] selectedInterestsLength: number } + 'onboarding:suggestedAccounts:tabPressed': { + tab: string + } + 'onboarding:suggestedAccounts:followAllPressed': { + tab: string + numAccounts: number + } 'onboarding:suggestedAccounts:nextPressed': { selectedAccountsLength: number skipped: boolean @@ -118,6 +125,9 @@ export type MetricEvents = { 'onboarding:finished:avatarResult': { avatarResult: 'default' | 'created' | 'uploaded' } + 'onboarding:valueProp:stepOne:nextPressed': {} + 'onboarding:valueProp:stepTwo:nextPressed': {} + 'onboarding:valueProp:skipPressed': {} 'home:feedDisplayed': { feedUrl: string feedType: string @@ -242,6 +252,7 @@ export type MetricEvents = { | 'PostOnboardingFindFollows' | 'ImmersiveVideo' | 'ExploreSuggestedAccounts' + | 'OnboardingSuggestedAccounts' } 'suggestedUser:follow': { logContext: @@ -249,12 +260,17 @@ export type MetricEvents = { | 'InterstitialDiscover' | 'InterstitialProfile' | 'Profile' + | 'Onboarding' location: 'Card' | 'Profile' recId?: number position: number } 'suggestedUser:press': { - logContext: 'Explore' | 'InterstitialDiscover' | 'InterstitialProfile' + logContext: + | 'Explore' + | 'InterstitialDiscover' + | 'InterstitialProfile' + | 'Onboarding' recId?: number position: number } @@ -280,6 +296,7 @@ export type MetricEvents = { | 'PostOnboardingFindFollows' | 'ImmersiveVideo' | 'ExploreSuggestedAccounts' + | 'OnboardingSuggestedAccounts' } 'chat:create': { logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' diff --git a/src/screens/Onboarding/Layout.tsx b/src/screens/Onboarding/Layout.tsx index 16c37358f..6394d9c96 100644 --- a/src/screens/Onboarding/Layout.tsx +++ b/src/screens/Onboarding/Layout.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, {useState} from 'react' import {ScrollView, View} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {msg} from '@lingui/macro' @@ -11,20 +11,23 @@ import { atoms as a, flatten, native, - TextStyleProp, + type TextStyleProp, + tokens, useBreakpoints, useTheme, web, } from '#/alf' import {leading} from '#/alf/typography' import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' +import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow' +import {HEADER_SLOT_SIZE} from '#/components/Layout' import {createPortalGroup} from '#/components/Portal' import {P, Text} from '#/components/Typography' -const COL_WIDTH = 420 +const ONBOARDING_COL_WIDTH = 420 export const OnboardingControls = createPortalGroup() +export const OnboardingHeaderSlot = createPortalGroup() export function Layout({children}: React.PropsWithChildren<{}>) { const {_} = useLingui() @@ -46,6 +49,8 @@ export function Layout({children}: React.PropsWithChildren<{}>) { const paddingTop = gtMobile ? a.py_5xl : a.py_lg const dialogLabel = _(msg`Set up your account`) + const [footerHeight, setFooterHeight] = useState(0) + return ( <View aria-modal @@ -62,45 +67,67 @@ export function Layout({children}: React.PropsWithChildren<{}>) { t.atoms.bg, ]}> {__DEV__ && ( - <View style={[a.absolute, a.p_xl, a.z_10, {right: 0, top: insets.top}]}> - <Button - variant="ghost" - color="negative" - size="small" - onPress={() => onboardDispatch({type: 'skip'})} - // DEV ONLY - label="Clear onboarding state"> - <ButtonText>Clear</ButtonText> - </Button> - </View> + <Button + variant="ghost" + color="negative" + size="tiny" + onPress={() => onboardDispatch({type: 'skip'})} + // DEV ONLY + label="Clear onboarding state" + style={[ + a.absolute, + a.z_10, + { + left: '50%', + top: insets.top + 2, + transform: [{translateX: '-50%'}], + }, + ]}> + <ButtonText>[DEV] Clear</ButtonText> + </Button> )} - {!gtMobile && state.hasPrev && ( + {!gtMobile && ( <View + pointerEvents="box-none" style={[ web(a.fixed), native(a.absolute), + a.left_0, + a.right_0, a.flex_row, a.w_full, a.justify_center, a.z_20, a.px_xl, - { - top: paddingTop.paddingTop + insets.top - 1, - }, + {top: paddingTop.paddingTop + insets.top - 1}, ]}> - <View style={[a.w_full, a.align_start, {maxWidth: COL_WIDTH}]}> - <Button - key={state.activeStep} // remove focus state on nav - variant="ghost" - color="secondary" - size="small" - shape="round" - label={_(msg`Go back to previous step`)} - style={[a.absolute]} - onPress={() => dispatch({type: 'prev'})}> - <ButtonIcon icon={ChevronLeft} /> - </Button> + <View + pointerEvents="box-none" + style={[ + a.w_full, + a.align_start, + a.flex_row, + a.justify_between, + {maxWidth: ONBOARDING_COL_WIDTH}, + ]}> + {state.hasPrev ? ( + <Button + key={state.activeStep} // remove focus state on nav + color="secondary" + variant="ghost" + shape="square" + size="small" + label={_(msg`Go back to previous step`)} + onPress={() => dispatch({type: 'prev'})} + style={[a.bg_transparent]}> + <ButtonIcon icon={ArrowLeft} size="lg" /> + </Button> + ) : ( + <View /> + )} + + <OnboardingHeaderSlot.Outlet /> </View> </View> )} @@ -109,22 +136,24 @@ export function Layout({children}: React.PropsWithChildren<{}>) { ref={scrollview} style={[a.h_full, a.w_full, {paddingTop: insets.top}]} contentContainerStyle={{borderWidth: 0}} - // @ts-ignore web only --prf + scrollIndicatorInsets={{bottom: footerHeight - insets.bottom}} + // @ts-expect-error web only --prf dataSet={{'stable-gutters': 1}}> <View style={[a.flex_row, a.justify_center, gtMobile ? a.px_5xl : a.px_xl]}> - <View style={[a.flex_1, {maxWidth: COL_WIDTH}]}> + <View style={[a.flex_1, {maxWidth: ONBOARDING_COL_WIDTH}]}> <View style={[a.w_full, a.align_center, paddingTop]}> <View style={[ a.flex_row, a.gap_sm, a.w_full, - {paddingTop: 17, maxWidth: '60%'}, + a.align_center, + {height: HEADER_SLOT_SIZE, maxWidth: '60%'}, ]}> {Array(state.totalSteps) .fill(0) - .map((_, i) => ( + .map((__, i) => ( <View key={i} style={[ @@ -144,19 +173,16 @@ export function Layout({children}: React.PropsWithChildren<{}>) { </View> </View> - <View - style={[a.w_full, a.mb_5xl, {paddingTop: gtMobile ? 20 : 40}]}> - {children} - </View> + <View style={[a.w_full, a.mb_5xl, a.pt_md]}>{children}</View> - <View style={{height: 400}} /> + <View style={{height: 100 + footerHeight}} /> </View> </View> </ScrollView> <View + onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)} style={[ - // @ts-ignore web only -prf isWeb ? a.fixed : a.absolute, {bottom: 0, left: 0, right: 0}, t.atoms.bg, @@ -167,30 +193,30 @@ export function Layout({children}: React.PropsWithChildren<{}>) { isWeb ? a.py_2xl : { - paddingTop: a.pt_lg.paddingTop, - paddingBottom: insets.bottom + 10, + paddingTop: tokens.space.md, + paddingBottom: insets.bottom + tokens.space.md, }, ]}> <View style={[ a.w_full, - {maxWidth: COL_WIDTH}, - gtMobile && [a.flex_row, a.justify_between], + {maxWidth: ONBOARDING_COL_WIDTH}, + gtMobile && [a.flex_row, a.justify_between, a.align_center], ]}> {gtMobile && (state.hasPrev ? ( <Button key={state.activeStep} // remove focus state on nav - variant="solid" color="secondary" - size="large" - shape="round" + variant="ghost" + shape="square" + size="small" label={_(msg`Go back to previous step`)} onPress={() => dispatch({type: 'prev'})}> - <ButtonIcon icon={ChevronLeft} /> + <ButtonIcon icon={ArrowLeft} size="lg" /> </Button> ) : ( - <View style={{height: 54}} /> + <View style={{height: 33}} /> ))} <OnboardingControls.Outlet /> </View> diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx index 54d282a5e..f8040f3a5 100644 --- a/src/screens/Onboarding/StepFinished.tsx +++ b/src/screens/Onboarding/StepFinished.tsx @@ -1,5 +1,12 @@ -import React from 'react' +import {useCallback, useContext, useState} from 'react' import {View} from 'react-native' +import Animated, { + Easing, + LayoutAnimationConfig, + SlideInRight, + SlideOutLeft, +} from 'react-native-reanimated' +import {Image} from 'expo-image' import { type AppBskyActorDefs, type AppBskyActorProfile, @@ -22,6 +29,7 @@ import { import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' import {logEvent, useGate} from '#/lib/statsig/statsig' import {logger} from '#/logger' +import {isNative} from '#/platform/detection' import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' import {getAllListMembers} from '#/state/queries/list-members' import {preferencesQueryKey} from '#/state/queries/preferences' @@ -36,13 +44,22 @@ import { import { DescriptionText, OnboardingControls, + OnboardingHeaderSlot, TitleText, } from '#/screens/Onboarding/Layout' -import {Context} from '#/screens/Onboarding/state' +import {Context, type OnboardingState} from '#/screens/Onboarding/state' import {bulkWriteFollows} from '#/screens/Onboarding/util' -import {atoms as a, useTheme} from '#/alf' +import { + atoms as a, + native, + platform, + tokens, + useBreakpoints, + useTheme, +} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {IconCircle} from '#/components/IconCircle' +import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth' import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2' @@ -53,10 +70,9 @@ import * as bsky from '#/types/bsky' export function StepFinished() { const {_} = useLingui() - const t = useTheme() - const {state, dispatch} = React.useContext(Context) + const {state, dispatch} = useContext(Context) const onboardDispatch = useOnboardingDispatch() - const [saving, setSaving] = React.useState(false) + const [saving, setSaving] = useState(false) const queryClient = useQueryClient() const agent = useAgent() const requestNotificationsPermission = useRequestNotificationsPermission() @@ -66,7 +82,7 @@ export function StepFinished() { const {startProgressGuide} = useProgressGuideControls() const gate = useGate() - const finishOnboarding = React.useCallback(async () => { + const finishOnboarding = useCallback(async () => { setSaving(true) let starterPack: AppBskyGraphDefs.StarterPackView | undefined @@ -245,6 +261,267 @@ export function StepFinished() { gate, ]) + return state.experiments?.onboarding_value_prop ? ( + <ValueProposition + finishOnboarding={finishOnboarding} + saving={saving} + state={state} + /> + ) : ( + <LegacyFinalStep + finishOnboarding={finishOnboarding} + saving={saving} + state={state} + /> + ) +} + +const PROP_1 = { + light: platform({ + native: require('../../../assets/images/onboarding/value_prop_1_light.webp'), + web: require('../../../assets/images/onboarding/value_prop_1_light_borderless.webp'), + }), + dim: platform({ + native: require('../../../assets/images/onboarding/value_prop_1_dim.webp'), + web: require('../../../assets/images/onboarding/value_prop_1_dim_borderless.webp'), + }), + dark: platform({ + native: require('../../../assets/images/onboarding/value_prop_1_dark.webp'), + web: require('../../../assets/images/onboarding/value_prop_1_dark_borderless.webp'), + }), +} as const + +const PROP_2 = { + light: require('../../../assets/images/onboarding/value_prop_2_light.webp'), + dim: require('../../../assets/images/onboarding/value_prop_2_dim.webp'), + dark: require('../../../assets/images/onboarding/value_prop_2_dark.webp'), +} as const + +const PROP_3 = { + light: require('../../../assets/images/onboarding/value_prop_3_light.webp'), + dim: require('../../../assets/images/onboarding/value_prop_3_dim.webp'), + dark: require('../../../assets/images/onboarding/value_prop_3_dark.webp'), +} as const + +function ValueProposition({ + finishOnboarding, + saving, + state, +}: { + finishOnboarding: () => void + saving: boolean + state: OnboardingState +}) { + const [subStep, setSubStep] = useState<0 | 1 | 2>(0) + const t = useTheme() + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + + const image = [PROP_1[t.name], PROP_2[t.name], PROP_3[t.name]][subStep] + + const onPress = () => { + if (subStep === 2) { + finishOnboarding() // has its own metrics + } else if (subStep === 1) { + setSubStep(2) + logger.metric('onboarding:valueProp:stepTwo:nextPressed', {}) + } else if (subStep === 0) { + setSubStep(1) + logger.metric('onboarding:valueProp:stepOne:nextPressed', {}) + } + } + + const {title, description, alt} = [ + { + title: _(msg`Free your feed`), + description: _( + msg`No more doomscrolling junk-filled algorithms. Find feeds that work for you, not against you.`, + ), + alt: _( + msg`A collection of popular feeds you can find on Bluesky, including News, Booksky, Game Dev, Blacksky, and Fountain Pens`, + ), + }, + { + title: _(msg`Find your people`), + description: _( + msg`Ditch the trolls and clickbait. Find real people and conversations that matter to you.`, + ), + alt: _( + msg`Your profile picture surrounded by concentric circles of other users' profile pictures`, + ), + }, + { + title: _(msg`Forget the noise`), + description: _( + msg`No ads, no invasive tracking, no engagement traps. Bluesky respects your time and attention.`, + ), + alt: _( + msg`An illustration of several Bluesky posts alongside repost, like, and comment icons`, + ), + }, + ][subStep] + + return ( + <> + {!gtMobile && ( + <OnboardingHeaderSlot.Portal> + <Button + disabled={saving} + variant="ghost" + color="secondary" + size="small" + label={_(msg`Skip introduction and start using your account`)} + onPress={() => { + logger.metric('onboarding:valueProp:skipPressed', {}) + finishOnboarding() + }} + style={[a.bg_transparent]}> + <ButtonText> + <Trans>Skip</Trans> + </ButtonText> + </Button> + </OnboardingHeaderSlot.Portal> + )} + + <LayoutAnimationConfig skipEntering skipExiting> + <Animated.View + key={subStep} + entering={native( + SlideInRight.easing(Easing.out(Easing.exp)).duration(500), + )} + exiting={native( + SlideOutLeft.easing(Easing.out(Easing.exp)).duration(500), + )}> + <View + style={[ + a.relative, + a.align_center, + a.justify_center, + isNative && {marginHorizontal: tokens.space.xl * -1}, + a.pointer_events_none, + ]}> + <Image + source={image} + style={[a.w_full, {aspectRatio: 1}]} + alt={alt} + accessibilityIgnoresInvertColors={false} // I guess we do need it to blend into the background + /> + {subStep === 1 && ( + <Image + source={state.profileStepResults.imageUri} + style={[ + a.z_10, + a.absolute, + a.rounded_full, + { + width: `${(80 / 393) * 100}%`, + height: `${(80 / 393) * 100}%`, + }, + ]} + accessibilityIgnoresInvertColors + alt={_(msg`Your profile picture`)} + /> + )} + </View> + + <View style={[a.mt_4xl, a.gap_2xl, a.align_center]}> + <View style={[a.flex_row, a.gap_sm]}> + <Dot active={subStep === 0} /> + <Dot active={subStep === 1} /> + <Dot active={subStep === 2} /> + </View> + + <View style={[a.gap_sm]}> + <Text style={[a.font_heavy, a.text_3xl, a.text_center]}> + {title} + </Text> + <Text + style={[ + t.atoms.text_contrast_medium, + a.text_md, + a.leading_snug, + a.text_center, + ]}> + {description} + </Text> + </View> + </View> + </Animated.View> + </LayoutAnimationConfig> + + <OnboardingControls.Portal> + <View style={gtMobile && [a.gap_md, a.flex_row]}> + {gtMobile && ( + <Button + disabled={saving} + color="secondary" + size="large" + label={_(msg`Skip introduction and start using your account`)} + onPress={() => finishOnboarding()}> + <ButtonText> + <Trans>Skip</Trans> + </ButtonText> + </Button> + )} + <Button + disabled={saving} + key={state.activeStep} // remove focus state on nav + color="primary" + size="large" + label={ + subStep === 2 + ? _(msg`Complete onboarding and start using your account`) + : _(msg`Next`) + } + onPress={onPress}> + <ButtonText> + {saving ? ( + <Trans>Finalizing</Trans> + ) : subStep === 2 ? ( + <Trans>Let's go!</Trans> + ) : ( + <Trans>Next</Trans> + )} + </ButtonText> + {subStep === 2 && ( + <ButtonIcon icon={saving ? Loader : ArrowRight} /> + )} + </Button> + </View> + </OnboardingControls.Portal> + </> + ) +} + +function Dot({active}: {active: boolean}) { + const t = useTheme() + const {_} = useLingui() + + return ( + <View + style={[ + a.rounded_full, + {width: 8, height: 8}, + active + ? {backgroundColor: t.palette.primary_500} + : t.atoms.bg_contrast_50, + ]} + /> + ) +} + +function LegacyFinalStep({ + finishOnboarding, + saving, + state, +}: { + finishOnboarding: () => void + saving: boolean + state: OnboardingState +}) { + const t = useTheme() + const {_} = useLingui() + return ( <View style={[a.align_start]}> <IconCircle icon={Check} style={[a.mb_2xl]} /> @@ -305,7 +582,6 @@ export function StepFinished() { <Button disabled={saving} key={state.activeStep} // remove focus state on nav - variant="solid" color="primary" size="large" label={_(msg`Complete onboarding and start using your account`)} diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx index 2a121cac6..3bde22136 100644 --- a/src/screens/Onboarding/StepInterests/index.tsx +++ b/src/screens/Onboarding/StepInterests/index.tsx @@ -160,7 +160,16 @@ export function StepInterests() { <View style={[a.w_full, a.pt_2xl]}> {isLoading ? ( - <Loader size="xl" /> + <View + style={[ + a.flex_1, + a.mt_md, + a.align_center, + a.justify_center, + {minHeight: 400}, + ]}> + <Loader size="xl" /> + </View> ) : isError || !data ? ( <View style={[ diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx index 30da5cbb5..fd5f9b6fb 100644 --- a/src/screens/Onboarding/StepProfile/index.tsx +++ b/src/screens/Onboarding/StepProfile/index.tsx @@ -266,7 +266,7 @@ export function StepProfile() { </View> <OnboardingControls.Portal> - <View style={[a.gap_md, gtMobile && {flexDirection: 'row-reverse'}]}> + <View style={[a.gap_md, gtMobile && a.flex_row_reverse]}> <Button variant="solid" color="primary" diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx new file mode 100644 index 000000000..5a9d3464c --- /dev/null +++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx @@ -0,0 +1,356 @@ +import {useCallback, useContext, useMemo, useState} from 'react' +import {View} from 'react-native' +import {type ModerationOpts} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMutation, useQueryClient} from '@tanstack/react-query' +import * as bcp47Match from 'bcp-47-match' + +import {wait} from '#/lib/async/wait' +import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {updateProfileShadow} from '#/state/cache/profile-shadow' +import {useLanguagePrefs} from '#/state/preferences' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useAgent, useSession} from '#/state/session' +import {useOnboardingDispatch} from '#/state/shell' +import {OnboardingControls} from '#/screens/Onboarding/Layout' +import { + Context, + popularInterests, + useInterestsDisplayNames, +} from '#/screens/Onboarding/state' +import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers' +import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' +import {Admonition} from '#/components/Admonition' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise' +import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' +import {boostInterests, InterestTabs} from '#/components/InterestTabs' +import {Loader} from '#/components/Loader' +import * as ProfileCard from '#/components/ProfileCard' +import * as toast from '#/components/Toast' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' +import {bulkWriteFollows} from '../util' + +export function StepSuggestedAccounts() { + const {_} = useLingui() + const t = useTheme() + const {gtMobile} = useBreakpoints() + const moderationOpts = useModerationOpts() + const agent = useAgent() + const {currentAccount} = useSession() + const queryClient = useQueryClient() + + const {state, dispatch} = useContext(Context) + const onboardDispatch = useOnboardingDispatch() + + const [selectedInterest, setSelectedInterest] = useState<string | null>(null) + // keeping track of who was followed via the follow all button + // so we can enable/disable the button without having to dig through the shadow cache + const [followedUsers, setFollowedUsers] = useState<string[]>([]) + + /* + * Special language handling copied wholesale from the Explore screen + */ + const {contentLanguages} = useLanguagePrefs() + const useFullExperience = useMemo(() => { + if (contentLanguages.length === 0) return true + return bcp47Match.basicFilter('en', contentLanguages).length > 0 + }, [contentLanguages]) + const interestsDisplayNames = useInterestsDisplayNames() + const interests = Object.keys(interestsDisplayNames) + .sort(boostInterests(popularInterests)) + .sort(boostInterests(state.interestsStepResults.selectedInterests)) + const { + data: suggestedUsers, + isLoading, + error, + isRefetching, + refetch, + } = useSuggestedUsers({ + category: selectedInterest || (useFullExperience ? null : interests[0]), + search: !useFullExperience, + overrideInterests: state.interestsStepResults.selectedInterests, + }) + + const isError = !!error + + const skipOnboarding = useCallback(() => { + onboardDispatch({type: 'finish'}) + dispatch({type: 'finish'}) + }, [onboardDispatch, dispatch]) + + const followableDids = + suggestedUsers?.actors + .filter( + user => + user.did !== currentAccount?.did && + !isBlockedOrBlocking(user) && + !isMuted(user) && + !user.viewer?.following && + !followedUsers.includes(user.did), + ) + .map(user => user.did) ?? [] + + const {mutate: followAll, isPending: isFollowingAll} = useMutation({ + onMutate: () => { + logger.metric('onboarding:suggestedAccounts:followAllPressed', { + tab: selectedInterest ?? 'all', + numAccounts: followableDids.length, + }) + }, + mutationFn: async () => { + for (const did of followableDids) { + updateProfileShadow(queryClient, did, { + followingUri: 'pending', + }) + } + const uris = await wait(1e3, bulkWriteFollows(agent, followableDids)) + for (const did of followableDids) { + const uri = uris.get(did) + updateProfileShadow(queryClient, did, { + followingUri: uri, + }) + } + return followableDids + }, + onSuccess: newlyFollowed => { + toast.show(_(msg`Followed all accounts!`), {type: 'success'}) + setFollowedUsers(followed => [...followed, ...newlyFollowed]) + }, + onError: () => { + toast.show( + _(msg`Failed to follow all suggested accounts, please try again`), + {type: 'error'}, + ) + }, + }) + + const canFollowAll = followableDids.length > 0 && !isFollowingAll + + return ( + <View style={[a.align_start]} testID="onboardingInterests"> + <Text style={[a.font_heavy, a.text_3xl]}> + <Trans comment="Accounts suggested to the user for them to follow"> + Suggested for you + </Trans> + </Text> + + <View + style={[ + a.overflow_hidden, + a.mt_lg, + isWeb ? a.max_w_full : {marginHorizontal: tokens.space.xl * -1}, + a.flex_1, + a.justify_start, + ]}> + <TabBar + selectedInterest={selectedInterest} + onSelectInterest={setSelectedInterest} + defaultTabLabel={_( + msg({ + message: 'All', + comment: 'the default tab in the interests tab bar', + }), + )} + selectedInterests={state.interestsStepResults.selectedInterests} + /> + + {isLoading || !moderationOpts ? ( + <View + style={[ + a.flex_1, + a.mt_md, + a.align_center, + a.justify_center, + {minHeight: 400}, + ]}> + <Loader size="xl" /> + </View> + ) : isError ? ( + <View style={[a.flex_1, a.px_xl, a.pt_5xl]}> + <Admonition type="error"> + <Trans> + An error occurred while fetching suggested accounts. + </Trans> + </Admonition> + </View> + ) : ( + <View + style={[ + a.flex_1, + a.mt_md, + a.border_y, + t.atoms.border_contrast_low, + isWeb && [a.border_x, a.rounded_sm, a.overflow_hidden], + ]}> + {suggestedUsers?.actors.map((user, index) => ( + <SuggestedProfileCard + key={user.did} + profile={user} + moderationOpts={moderationOpts} + position={index} + /> + ))} + </View> + )} + </View> + + <OnboardingControls.Portal> + {isError ? ( + <View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}> + <Button + disabled={isRefetching} + color="secondary" + size="large" + label={_(msg`Retry`)} + onPress={() => refetch()}> + <ButtonText> + <Trans>Retry</Trans> + </ButtonText> + <ButtonIcon icon={ArrowRotateCounterClockwiseIcon} /> + </Button> + <Button + color="secondary" + size="large" + label={_(msg`Skip this flow`)} + onPress={skipOnboarding}> + <ButtonText> + <Trans>Skip</Trans> + </ButtonText> + </Button> + </View> + ) : ( + <View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}> + <Button + disabled={!canFollowAll} + color="secondary" + size="large" + label={_(msg`Follow all accounts`)} + onPress={() => followAll()}> + <ButtonText> + <Trans>Follow all</Trans> + </ButtonText> + <ButtonIcon icon={isFollowingAll ? Loader : PlusIcon} /> + </Button> + <Button + disabled={isFollowingAll} + color="primary" + size="large" + label={_(msg`Continue to next step`)} + onPress={() => dispatch({type: 'next'})}> + <ButtonText> + <Trans>Continue</Trans> + </ButtonText> + </Button> + </View> + )} + </OnboardingControls.Portal> + </View> + ) +} + +function TabBar({ + selectedInterest, + onSelectInterest, + selectedInterests, + hideDefaultTab, + defaultTabLabel, +}: { + selectedInterest: string | null + onSelectInterest: (interest: string | null) => void + selectedInterests: string[] + hideDefaultTab?: boolean + defaultTabLabel?: string +}) { + const {_} = useLingui() + const interestsDisplayNames = useInterestsDisplayNames() + const interests = Object.keys(interestsDisplayNames) + .sort(boostInterests(popularInterests)) + .sort(boostInterests(selectedInterests)) + + return ( + <InterestTabs + interests={hideDefaultTab ? interests : ['all', ...interests]} + selectedInterest={ + selectedInterest || (hideDefaultTab ? interests[0] : 'all') + } + onSelectTab={tab => { + logger.metric( + 'onboarding:suggestedAccounts:tabPressed', + {tab: tab}, + {statsig: true}, + ) + onSelectInterest(tab === 'all' ? null : tab) + }} + interestsDisplayNames={ + hideDefaultTab + ? interestsDisplayNames + : { + all: defaultTabLabel || _(msg`For You`), + ...interestsDisplayNames, + } + } + gutterWidth={isWeb ? 0 : tokens.space.xl} + /> + ) +} + +function SuggestedProfileCard({ + profile, + moderationOpts, + position, +}: { + profile: bsky.profile.AnyProfileView + moderationOpts: ModerationOpts + position: number +}) { + const t = useTheme() + return ( + <View + style={[ + a.flex_1, + a.w_full, + a.py_lg, + a.px_xl, + position !== 0 && a.border_t, + t.atoms.border_contrast_low, + ]}> + <ProfileCard.Outer> + <ProfileCard.Header> + <ProfileCard.Avatar + profile={profile} + moderationOpts={moderationOpts} + disabledPreview + /> + <ProfileCard.NameAndHandle + profile={profile} + moderationOpts={moderationOpts} + /> + <ProfileCard.FollowButton + profile={profile} + moderationOpts={moderationOpts} + withIcon={false} + logContext="OnboardingSuggestedAccounts" + onFollow={() => { + logger.metric( + 'suggestedUser:follow', + { + logContext: 'Onboarding', + location: 'Card', + recId: undefined, + position, + }, + {statsig: true}, + ) + }} + /> + </ProfileCard.Header> + <ProfileCard.Description profile={profile} numberOfLines={3} /> + </ProfileCard.Outer> + </View> + ) +} diff --git a/src/screens/Onboarding/index.tsx b/src/screens/Onboarding/index.tsx index a5c423ca1..2291e5e4f 100644 --- a/src/screens/Onboarding/index.tsx +++ b/src/screens/Onboarding/index.tsx @@ -1,21 +1,35 @@ -import React from 'react' +import {useMemo, useReducer} from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {Layout, OnboardingControls} from '#/screens/Onboarding/Layout' +import {useGate} from '#/lib/statsig/statsig' +import { + Layout, + OnboardingControls, + OnboardingHeaderSlot, +} from '#/screens/Onboarding/Layout' import {Context, initialState, reducer} from '#/screens/Onboarding/state' import {StepFinished} from '#/screens/Onboarding/StepFinished' import {StepInterests} from '#/screens/Onboarding/StepInterests' import {StepProfile} from '#/screens/Onboarding/StepProfile' import {Portal} from '#/components/Portal' +import {StepSuggestedAccounts} from './StepSuggestedAccounts' export function Onboarding() { const {_} = useLingui() - const [state, dispatch] = React.useReducer(reducer, { + const gate = useGate() + const showValueProp = gate('onboarding_value_prop') + const showSuggestedAccounts = gate('onboarding_suggested_accounts') + const [state, dispatch] = useReducer(reducer, { ...initialState, + totalSteps: showSuggestedAccounts ? 4 : 3, + experiments: { + onboarding_suggested_accounts: showSuggestedAccounts, + onboarding_value_prop: showValueProp, + }, }) - const interestsDisplayNames = React.useMemo(() => { + const interestsDisplayNames = useMemo(() => { return { news: _(msg`News`), journalism: _(msg`Journalism`), @@ -45,17 +59,22 @@ export function Onboarding() { return ( <Portal> <OnboardingControls.Provider> - <Context.Provider - value={React.useMemo( - () => ({state, dispatch, interestsDisplayNames}), - [state, dispatch, interestsDisplayNames], - )}> - <Layout> - {state.activeStep === 'profile' && <StepProfile />} - {state.activeStep === 'interests' && <StepInterests />} - {state.activeStep === 'finished' && <StepFinished />} - </Layout> - </Context.Provider> + <OnboardingHeaderSlot.Provider> + <Context.Provider + value={useMemo( + () => ({state, dispatch, interestsDisplayNames}), + [state, dispatch, interestsDisplayNames], + )}> + <Layout> + {state.activeStep === 'profile' && <StepProfile />} + {state.activeStep === 'interests' && <StepInterests />} + {state.activeStep === 'suggested-accounts' && ( + <StepSuggestedAccounts /> + )} + {state.activeStep === 'finished' && <StepFinished />} + </Layout> + </Context.Provider> + </OnboardingHeaderSlot.Provider> </OnboardingControls.Provider> </Portal> ) diff --git a/src/screens/Onboarding/state.ts b/src/screens/Onboarding/state.ts index cbb466245..31f6eb039 100644 --- a/src/screens/Onboarding/state.ts +++ b/src/screens/Onboarding/state.ts @@ -11,7 +11,7 @@ import { export type OnboardingState = { hasPrev: boolean totalSteps: number - activeStep: 'profile' | 'interests' | 'finished' + activeStep: 'profile' | 'interests' | 'suggested-accounts' | 'finished' activeStepIndex: number interestsStepResults: { @@ -34,6 +34,11 @@ export type OnboardingState = { backgroundColor: AvatarColor } } + + experiments?: { + onboarding_suggested_accounts?: boolean + onboarding_value_prop?: boolean + } } export type OnboardingAction = @@ -160,22 +165,49 @@ export function reducer( switch (a.type) { case 'next': { - if (s.activeStep === 'profile') { - next.activeStep = 'interests' - next.activeStepIndex = 2 - } else if (s.activeStep === 'interests') { - next.activeStep = 'finished' - next.activeStepIndex = 3 + if (s.experiments?.onboarding_suggested_accounts) { + if (s.activeStep === 'profile') { + next.activeStep = 'interests' + next.activeStepIndex = 2 + } else if (s.activeStep === 'interests') { + next.activeStep = 'suggested-accounts' + next.activeStepIndex = 3 + } + if (s.activeStep === 'suggested-accounts') { + next.activeStep = 'finished' + next.activeStepIndex = 4 + } + } else { + if (s.activeStep === 'profile') { + next.activeStep = 'interests' + next.activeStepIndex = 2 + } else if (s.activeStep === 'interests') { + next.activeStep = 'finished' + next.activeStepIndex = 3 + } } break } case 'prev': { - if (s.activeStep === 'interests') { - next.activeStep = 'profile' - next.activeStepIndex = 1 - } else if (s.activeStep === 'finished') { - next.activeStep = 'interests' - next.activeStepIndex = 2 + if (s.experiments?.onboarding_suggested_accounts) { + if (s.activeStep === 'interests') { + next.activeStep = 'profile' + next.activeStepIndex = 1 + } else if (s.activeStep === 'suggested-accounts') { + next.activeStep = 'interests' + next.activeStepIndex = 2 + } else if (s.activeStep === 'finished') { + next.activeStep = 'suggested-accounts' + next.activeStepIndex = 3 + } + } else { + if (s.activeStep === 'interests') { + next.activeStep = 'profile' + next.activeStepIndex = 1 + } else if (s.activeStep === 'finished') { + next.activeStep = 'interests' + next.activeStepIndex = 2 + } } break } diff --git a/src/screens/Onboarding/util.ts b/src/screens/Onboarding/util.ts index d14c9562e..b08f0408e 100644 --- a/src/screens/Onboarding/util.ts +++ b/src/screens/Onboarding/util.ts @@ -1,9 +1,9 @@ import { - $Typed, - AppBskyGraphFollow, - AppBskyGraphGetFollows, - BskyAgent, - ComAtprotoRepoApplyWrites, + type $Typed, + type AppBskyGraphFollow, + type AppBskyGraphGetFollows, + type BskyAgent, + type ComAtprotoRepoApplyWrites, } from '@atproto/api' import {TID} from '@atproto/common-web' import chunk from 'lodash.chunk' @@ -42,10 +42,10 @@ export async function bulkWriteFollows(agent: BskyAgent, dids: string[]) { } await whenFollowsIndexed(agent, session.did, res => !!res.data.follows.length) - const followUris = new Map() + const followUris = new Map<string, string>() for (const r of followWrites) { followUris.set( - r.value.subject, + r.value.subject as string, `at://${session.did}/app.bsky.graph.follow/${r.rkey}`, ) } diff --git a/src/screens/Search/Explore.tsx b/src/screens/Search/Explore.tsx index baf69cd7f..cefe68b01 100644 --- a/src/screens/Search/Explore.tsx +++ b/src/screens/Search/Explore.tsx @@ -66,9 +66,9 @@ import { import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' import {StarterPack} from '#/components/icons/StarterPack' import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle' +import {boostInterests} from '#/components/InterestTabs' import {Loader} from '#/components/Loader' import * as ProfileCard from '#/components/ProfileCard' -import {boostInterests} from '#/components/ProgressGuide/FollowDialog' import {SubtleHover} from '#/components/SubtleHover' import {Text} from '#/components/Typography' import * as ModuleHeader from './components/ModuleHeader' diff --git a/src/screens/Search/modules/ExploreSuggestedAccounts.tsx b/src/screens/Search/modules/ExploreSuggestedAccounts.tsx index fd37544f4..71bfd6547 100644 --- a/src/screens/Search/modules/ExploreSuggestedAccounts.tsx +++ b/src/screens/Search/modules/ExploreSuggestedAccounts.tsx @@ -14,11 +14,9 @@ import { } from '#/screens/Onboarding/state' import {useTheme} from '#/alf' import {atoms as a} from '#/alf' -import {Button} from '#/components/Button' +import {boostInterests, InterestTabs} from '#/components/InterestTabs' import * as ProfileCard from '#/components/ProfileCard' -import {boostInterests, Tabs} from '#/components/ProgressGuide/FollowDialog' import {SubtleHover} from '#/components/SubtleHover' -import {Text} from '#/components/Typography' import type * as bsky from '#/types/bsky' export function useLoadEnoughProfiles({ @@ -59,10 +57,12 @@ export function SuggestedAccountsTabBar({ selectedInterest, onSelectInterest, hideDefaultTab, + defaultTabLabel, }: { selectedInterest: string | null onSelectInterest: (interest: string | null) => void hideDefaultTab?: boolean + defaultTabLabel?: string }) { const {_} = useLingui() const interestsDisplayNames = useInterestsDisplayNames() @@ -71,9 +71,10 @@ export function SuggestedAccountsTabBar({ const interests = Object.keys(interestsDisplayNames) .sort(boostInterests(popularInterests)) .sort(boostInterests(personalizedInterests)) + return ( <BlockDrawerGesture> - <Tabs + <InterestTabs interests={hideDefaultTab ? interests : ['all', ...interests]} selectedInterest={ selectedInterest || (hideDefaultTab ? interests[0] : 'all') @@ -86,82 +87,19 @@ export function SuggestedAccountsTabBar({ ) onSelectInterest(tab === 'all' ? null : tab) }} - hasSearchText={false} interestsDisplayNames={ hideDefaultTab ? interestsDisplayNames : { - all: _(msg`For You`), + all: defaultTabLabel || _(msg`For You`), ...interestsDisplayNames, } } - TabComponent={Tab} - contentContainerStyle={[ - { - // visual alignment - paddingLeft: a.px_md.paddingLeft, - }, - ]} /> </BlockDrawerGesture> ) } -let Tab = ({ - onSelectTab, - interest, - active, - index, - interestsDisplayName, - onLayout, -}: { - onSelectTab: (index: number) => void - interest: string - active: boolean - index: number - interestsDisplayName: string - onLayout: (index: number, x: number, width: number) => void -}): React.ReactNode => { - const t = useTheme() - const {_} = useLingui() - const activeText = active ? _(msg` (active)`) : '' - return ( - <View - key={interest} - onLayout={e => - onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width) - }> - <Button - label={_(msg`Search for "${interestsDisplayName}"${activeText}`)} - onPress={() => onSelectTab(index)}> - {({hovered, pressed, focused}) => ( - <View - style={[ - a.rounded_full, - a.px_lg, - a.py_sm, - a.border, - active || hovered || pressed || focused - ? [t.atoms.bg_contrast_25, t.atoms.border_contrast_medium] - : [t.atoms.bg, t.atoms.border_contrast_low], - ]}> - <Text - style={[ - a.font_medium, - active || hovered || pressed || focused - ? t.atoms.text - : t.atoms.text_contrast_medium, - ]}> - {interestsDisplayName} - </Text> - </View> - )} - </Button> - </View> - ) -} -Tab = memo(Tab) - /** * Profile card for suggested accounts. Note: border is on the bottom edge */ diff --git a/src/screens/Search/util/useSuggestedUsers.ts b/src/screens/Search/util/useSuggestedUsers.ts index aa29dad8c..9ca2c558a 100644 --- a/src/screens/Search/util/useSuggestedUsers.ts +++ b/src/screens/Search/util/useSuggestedUsers.ts @@ -11,6 +11,7 @@ import {useInterestsDisplayNames} from '#/screens/Onboarding/state' export function useSuggestedUsers({ category = null, search = false, + overrideInterests, }: { category?: string | null /** @@ -18,11 +19,17 @@ export function useSuggestedUsers({ * based on the user's "app language setting */ search?: boolean + /** + * In onboarding, interests haven't been saved to prefs yet, so we need to + * pass them down through here + */ + overrideInterests?: string[] }) { const interestsDisplayNames = useInterestsDisplayNames() const curated = useGetSuggestedUsersQuery({ enabled: !search, category, + overrideInterests, }) const searched = useActorSearchPaginated({ enabled: !!search, @@ -43,6 +50,7 @@ export function useSuggestedUsers({ isLoading: searched.isLoading, error: searched.error, isRefetching: searched.isRefetching, + refetch: searched.refetch, } } else { return { @@ -50,6 +58,7 @@ export function useSuggestedUsers({ isLoading: curated.isLoading, error: curated.error, isRefetching: curated.isRefetching, + refetch: curated.refetch, } } }, [curated, searched, search]) diff --git a/src/state/queries/trending/useGetSuggestedUsersQuery.ts b/src/state/queries/trending/useGetSuggestedUsersQuery.ts index 05cc4d74d..898029398 100644 --- a/src/state/queries/trending/useGetSuggestedUsersQuery.ts +++ b/src/state/queries/trending/useGetSuggestedUsersQuery.ts @@ -17,6 +17,7 @@ export type QueryProps = { category?: string | null limit?: number enabled?: boolean + overrideInterests?: string[] } export const getSuggestedUsersQueryKeyRoot = 'unspecced-suggested-users' @@ -24,6 +25,7 @@ export const createGetSuggestedUsersQueryKey = (props: QueryProps) => [ getSuggestedUsersQueryKeyRoot, props.category, props.limit, + props.overrideInterests?.join(','), ] export function useGetSuggestedUsersQuery(props: QueryProps) { @@ -36,6 +38,7 @@ export function useGetSuggestedUsersQuery(props: QueryProps) { queryKey: createGetSuggestedUsersQueryKey(props), queryFn: async () => { const contentLangs = getContentLanguages().join(',') + const interests = aggregateUserInterests(preferences) const {data} = await agent.app.bsky.unspecced.getSuggestedUsers( { category: props.category ?? undefined, @@ -43,7 +46,11 @@ export function useGetSuggestedUsersQuery(props: QueryProps) { }, { headers: { - ...createBskyTopicsHeader(aggregateUserInterests(preferences)), + ...createBskyTopicsHeader( + props.overrideInterests && props.overrideInterests.length > 0 + ? props.overrideInterests.join(',') + : interests, + ), 'Accept-Language': contentLangs, }, }, |