From 3371038f7d8b740f2415d3a54dc99b69e0598042 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 25 Jan 2024 22:22:40 -0600 Subject: [PATCH] New Onboarding (#2596) * Add round and square buttons * Allow some style for buttons, add icons * Change text selection color * Center button text, whoops * Outer layout, some primitive updates * WIP * onboarding feed prefs (#2590) * add `style` to toggle label to modify text style * Revert "add `style` to toggle label to modify text style" This reverts commit 8f4b517b8585ca64a4bf44f6cb40ac070ece8932. * following feed prefs * remove unnecessary memo * reusable divider component * org imports * add finished screen * Theme SelectedAccountCard * Require at least 3 interests * Placeholder save logic * WIP algo feeds * Improve lineHeight handling, add RichText, improve Link by adding InlineLink * Inherit lineHeight in heading comps * Algo feeds mostly good * Topical feeds ish * Layout cleanup * Improve button styles * moderation prefs for onboarding (#2594) * WIP algo feeds * modify controlalbelgroup typing for easy .map() * adjust padding on button * add moderation screen * add moderation screen * add moderation screen --------- Co-authored-by: Eric Bailey * Fix toggle button styles * A11y props on outer portal * Put it all on red * New data shape * Handle mock data * Bulk write (not yet) * Remove interests validation * Clean up interests * i18n layout and first step * Clean up suggested follows screen * Clean up following step * Clean up algo feeds step * Clean up topical feeds * Add skeleton for feed card * WIP moderation step * cleanup moderation styles (#2605) * cleanup moderation styles * fix(?) toggle button group styles * adjust toggle to fit any screen * Some more cleanup * Icons * ToggleButton tweaks * Reset * Hook up data * Better suggestions * Bulk write * Some logging * Use new api * Concat topical feeds * Metrics * Disable links in RichText, feedcards * Tweak primary feed cards * Update metrics * Fix layout shift * Fix ToggleButton again, whoops * Error state * Bump api package, ensure interests are saved * Better fix for autofill * i18n, button positions * Remove unused export * Add default prefs object * Fix overflow in user cards * Add 2 lines of bios to suggested accounts cards * Nits * Don't resolve facets by default * Update storybook * Disable flag for now * Remove age dialog from moderations step * Improvements and tweaks to new onboarding --------- Co-authored-by: Hailey <153161762+haileyok@users.noreply.github.com> Co-authored-by: Paul Frazee --- ...unterClockwise_stroke2_corner0_rounded.svg | 1 + assets/icons/at_stroke2_corner0_rounded.svg | 1 + .../icons/check_stroke2_corner0_rounded.svg | 1 + .../chevronLeft_stroke2_corner0_rounded.svg | 1 + .../chevronRight_stroke2_corner0_rounded.svg | 1 + .../circleInfo_stroke2_corner0_rounded.svg | 1 + .../emojiSad_stroke2_corner0_rounded.svg | 1 + .../eyeSlash_stroke2_corner0_rounded.svg | 1 + ...filterTimeline_stroke2_corner0_rounded.svg | 1 + .../icons/growth_stroke2_corner0_rounded.svg | 1 + .../icons/hashtag_stroke2_corner0_rounded.svg | 1 + ...agnifyingGlass_stroke2_corner0_rounded.svg | 1 + .../listSparkle_stroke2_corner0_rounded.svg | 1 + .../icons/loader_stroke2_corner0_rounded.svg | 1 + .../icons/news2_stroke2_corner0_rounded.svg | 1 + .../plusLarge_stroke2_corner0_rounded.svg | 1 + .../trending2_stroke2_corner2_rounded.svg | 1 + bskyweb/templates/base.html | 41 +- src/alf/atoms.ts | 16 +- src/alf/index.tsx | 1 + src/alf/tokens.ts | 8 + src/alf/types.ts | 10 + src/components/Button.tsx | 75 +++- src/components/Divider.tsx | 10 + src/components/Link.tsx | 182 ++++++--- src/components/Portal.tsx | 81 ++-- src/components/RichText.tsx | 131 ++++++ src/components/Typography.tsx | 96 ++++- src/components/forms/TextField.tsx | 2 +- src/components/forms/Toggle.tsx | 8 +- src/components/forms/ToggleButton.tsx | 8 +- .../icons/ArrowRotateCounterClockwise.tsx | 6 + src/components/icons/At.tsx | 5 + src/components/icons/Check.tsx | 5 + src/components/icons/Chevron.tsx | 9 + src/components/icons/CircleInfo.tsx | 5 + src/components/icons/Emoji.tsx | 5 + src/components/icons/EyeSlash.tsx | 5 + src/components/icons/FilterTimeline.tsx | 5 + src/components/icons/Growth.tsx | 5 + src/components/icons/Hashtag.tsx | 5 + src/components/icons/ListMagnifyingGlass.tsx | 5 + src/components/icons/ListSparkle.tsx | 5 + src/components/icons/News2.tsx | 5 + src/components/icons/Plus.tsx | 5 + src/components/icons/Trending2.tsx | 5 + src/lib/analytics/types.ts | 32 ++ src/lib/build-flags.ts | 1 + src/screens/Onboarding/IconCircle.tsx | 51 +++ src/screens/Onboarding/Layout.tsx | 231 +++++++++++ .../Onboarding/StepAlgoFeeds/FeedCard.tsx | 378 ++++++++++++++++++ .../Onboarding/StepAlgoFeeds/index.tsx | 160 ++++++++ src/screens/Onboarding/StepFinished.tsx | 158 ++++++++ src/screens/Onboarding/StepFollowingFeed.tsx | 160 ++++++++ .../StepInterests/InterestButton.tsx | 79 ++++ src/screens/Onboarding/StepInterests/data.ts | 36 ++ .../Onboarding/StepInterests/index.tsx | 260 ++++++++++++ .../AdultContentEnabledPref.tsx | 135 +++++++ .../StepModeration/ModerationOption.tsx | 85 ++++ .../Onboarding/StepModeration/index.tsx | 91 +++++ .../SuggestedAccountCard.tsx | 188 +++++++++ .../StepSuggestedAccounts/index.tsx | 198 +++++++++ src/screens/Onboarding/StepTopicalFeeds.tsx | 113 ++++++ src/screens/Onboarding/index.tsx | 38 ++ src/screens/Onboarding/state.ts | 201 ++++++++++ src/screens/Onboarding/util.ts | 112 ++++++ src/state/queries/preferences/const.ts | 1 + src/state/queries/preferences/types.ts | 19 +- src/state/queries/profile.ts | 12 + src/view/screens/Storybook/Buttons.tsx | 89 ++++- src/view/screens/Storybook/Forms.tsx | 17 + src/view/screens/Storybook/Links.tsx | 46 ++- src/view/screens/Storybook/Typography.tsx | 11 + .../createNativeStackNavigatorWithAuth.tsx | 9 +- web/index.html | 47 ++- 75 files changed, 3514 insertions(+), 210 deletions(-) create mode 100644 assets/icons/arrowRotateCounterClockwise_stroke2_corner0_rounded.svg create mode 100644 assets/icons/at_stroke2_corner0_rounded.svg create mode 100644 assets/icons/check_stroke2_corner0_rounded.svg create mode 100644 assets/icons/chevronLeft_stroke2_corner0_rounded.svg create mode 100644 assets/icons/chevronRight_stroke2_corner0_rounded.svg create mode 100644 assets/icons/circleInfo_stroke2_corner0_rounded.svg create mode 100644 assets/icons/emojiSad_stroke2_corner0_rounded.svg create mode 100644 assets/icons/eyeSlash_stroke2_corner0_rounded.svg create mode 100644 assets/icons/filterTimeline_stroke2_corner0_rounded.svg create mode 100644 assets/icons/growth_stroke2_corner0_rounded.svg create mode 100644 assets/icons/hashtag_stroke2_corner0_rounded.svg create mode 100644 assets/icons/listMagnifyingGlass_stroke2_corner0_rounded.svg create mode 100644 assets/icons/listSparkle_stroke2_corner0_rounded.svg create mode 100644 assets/icons/loader_stroke2_corner0_rounded.svg create mode 100644 assets/icons/news2_stroke2_corner0_rounded.svg create mode 100644 assets/icons/plusLarge_stroke2_corner0_rounded.svg create mode 100644 assets/icons/trending2_stroke2_corner2_rounded.svg create mode 100644 src/components/Divider.tsx create mode 100644 src/components/RichText.tsx create mode 100644 src/components/icons/ArrowRotateCounterClockwise.tsx create mode 100644 src/components/icons/At.tsx create mode 100644 src/components/icons/Check.tsx create mode 100644 src/components/icons/Chevron.tsx create mode 100644 src/components/icons/CircleInfo.tsx create mode 100644 src/components/icons/Emoji.tsx create mode 100644 src/components/icons/EyeSlash.tsx create mode 100644 src/components/icons/FilterTimeline.tsx create mode 100644 src/components/icons/Growth.tsx create mode 100644 src/components/icons/Hashtag.tsx create mode 100644 src/components/icons/ListMagnifyingGlass.tsx create mode 100644 src/components/icons/ListSparkle.tsx create mode 100644 src/components/icons/News2.tsx create mode 100644 src/components/icons/Plus.tsx create mode 100644 src/components/icons/Trending2.tsx create mode 100644 src/screens/Onboarding/IconCircle.tsx create mode 100644 src/screens/Onboarding/Layout.tsx create mode 100644 src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx create mode 100644 src/screens/Onboarding/StepAlgoFeeds/index.tsx create mode 100644 src/screens/Onboarding/StepFinished.tsx create mode 100644 src/screens/Onboarding/StepFollowingFeed.tsx create mode 100644 src/screens/Onboarding/StepInterests/InterestButton.tsx create mode 100644 src/screens/Onboarding/StepInterests/data.ts create mode 100644 src/screens/Onboarding/StepInterests/index.tsx create mode 100644 src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx create mode 100644 src/screens/Onboarding/StepModeration/ModerationOption.tsx create mode 100644 src/screens/Onboarding/StepModeration/index.tsx create mode 100644 src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx create mode 100644 src/screens/Onboarding/StepSuggestedAccounts/index.tsx create mode 100644 src/screens/Onboarding/StepTopicalFeeds.tsx create mode 100644 src/screens/Onboarding/index.tsx create mode 100644 src/screens/Onboarding/state.ts create mode 100644 src/screens/Onboarding/util.ts diff --git a/assets/icons/arrowRotateCounterClockwise_stroke2_corner0_rounded.svg b/assets/icons/arrowRotateCounterClockwise_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..955b3dbc --- /dev/null +++ b/assets/icons/arrowRotateCounterClockwise_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/at_stroke2_corner0_rounded.svg b/assets/icons/at_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..8d30d7c8 --- /dev/null +++ b/assets/icons/at_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/check_stroke2_corner0_rounded.svg b/assets/icons/check_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..b336a518 --- /dev/null +++ b/assets/icons/check_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/chevronLeft_stroke2_corner0_rounded.svg b/assets/icons/chevronLeft_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..d9a8660f --- /dev/null +++ b/assets/icons/chevronLeft_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/chevronRight_stroke2_corner0_rounded.svg b/assets/icons/chevronRight_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..b57fd039 --- /dev/null +++ b/assets/icons/chevronRight_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/circleInfo_stroke2_corner0_rounded.svg b/assets/icons/circleInfo_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..926d4b39 --- /dev/null +++ b/assets/icons/circleInfo_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/emojiSad_stroke2_corner0_rounded.svg b/assets/icons/emojiSad_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..0a5a43cd --- /dev/null +++ b/assets/icons/emojiSad_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/eyeSlash_stroke2_corner0_rounded.svg b/assets/icons/eyeSlash_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..f11bdd93 --- /dev/null +++ b/assets/icons/eyeSlash_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/filterTimeline_stroke2_corner0_rounded.svg b/assets/icons/filterTimeline_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..459b9212 --- /dev/null +++ b/assets/icons/filterTimeline_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/growth_stroke2_corner0_rounded.svg b/assets/icons/growth_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..ec9083fb --- /dev/null +++ b/assets/icons/growth_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/hashtag_stroke2_corner0_rounded.svg b/assets/icons/hashtag_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..05b2353d --- /dev/null +++ b/assets/icons/hashtag_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/listMagnifyingGlass_stroke2_corner0_rounded.svg b/assets/icons/listMagnifyingGlass_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..1b1857ef --- /dev/null +++ b/assets/icons/listMagnifyingGlass_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/listSparkle_stroke2_corner0_rounded.svg b/assets/icons/listSparkle_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..702e1895 --- /dev/null +++ b/assets/icons/listSparkle_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/loader_stroke2_corner0_rounded.svg b/assets/icons/loader_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..9dbc0137 --- /dev/null +++ b/assets/icons/loader_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/news2_stroke2_corner0_rounded.svg b/assets/icons/news2_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..66e4c373 --- /dev/null +++ b/assets/icons/news2_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/plusLarge_stroke2_corner0_rounded.svg b/assets/icons/plusLarge_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..8d568437 --- /dev/null +++ b/assets/icons/plusLarge_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/trending2_stroke2_corner2_rounded.svg b/assets/icons/trending2_stroke2_corner2_rounded.svg new file mode 100644 index 00000000..cc806b0e --- /dev/null +++ b/assets/icons/trending2_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html index 228c3d89..4fb3a209 100644 --- a/bskyweb/templates/base.html +++ b/bskyweb/templates/base.html @@ -39,25 +39,6 @@ scrollbar-gutter: stable both-edges; } - /* Remove autofill styles on Webkit */ - input:-webkit-autofill, - input:-webkit-autofill:hover, - input:-webkit-autofill:focus, - textarea:-webkit-autofill, - textarea:-webkit-autofill:hover, - textarea:-webkit-autofill:focus, - select:-webkit-autofill, - select:-webkit-autofill:hover, - select:-webkit-autofill:focus { - border: 0; - -webkit-text-fill-color: transparent; - -webkit-box-shadow: none; - } - /* Force left-align date/time inputs on iOS mobile */ - input::-webkit-date-and-time-value { - text-align: left; - } - /* Color theming */ :root { --text: black; @@ -86,6 +67,28 @@ } } + ::selection { + background-color: var(--backgroundLight); + } + + /* Remove autofill styles on Webkit */ + input:autofill, + input:-webkit-autofill, + input:-webkit-autofill:hover, + input:-webkit-autofill:focus, + input:-webkit-autofill:active{ + -webkit-background-clip: text; + -webkit-text-fill-color: var(--text); + transition: background-color 5000s ease-in-out 0s; + box-shadow: inset 0 0 20px 20px var(--background); + background: var(--background); + color: var(--text); + } + /* Force left-align date/time inputs on iOS mobile */ + input::-webkit-date-and-time-value { + text-align: left; + } + body { display: flex; /* Allows you to scroll below the viewport; default value is visible */ diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 203c2f28..bbf7e324 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -104,6 +104,9 @@ export const atoms = { flex: { display: 'flex', }, + flex_col: { + flexDirection: 'column', + }, flex_row: { flexDirection: 'row', }, @@ -149,45 +152,38 @@ export const atoms = { }, text_2xs: { fontSize: tokens.fontSize._2xs, - lineHeight: tokens.fontSize._2xs, }, text_xs: { fontSize: tokens.fontSize.xs, - lineHeight: tokens.fontSize.xs, }, text_sm: { fontSize: tokens.fontSize.sm, - lineHeight: tokens.fontSize.sm, }, text_md: { fontSize: tokens.fontSize.md, - lineHeight: tokens.fontSize.md, }, text_lg: { fontSize: tokens.fontSize.lg, - lineHeight: tokens.fontSize.lg, }, text_xl: { fontSize: tokens.fontSize.xl, - lineHeight: tokens.fontSize.xl, }, text_2xl: { fontSize: tokens.fontSize._2xl, - lineHeight: tokens.fontSize._2xl, }, text_3xl: { fontSize: tokens.fontSize._3xl, - lineHeight: tokens.fontSize._3xl, }, text_4xl: { fontSize: tokens.fontSize._4xl, - lineHeight: tokens.fontSize._4xl, }, text_5xl: { fontSize: tokens.fontSize._5xl, - lineHeight: tokens.fontSize._5xl, }, leading_tight: { + lineHeight: 1.15, + }, + leading_snug: { lineHeight: 1.25, }, leading_normal: { diff --git a/src/alf/index.tsx b/src/alf/index.tsx index 69a87985..06d6ebf0 100644 --- a/src/alf/index.tsx +++ b/src/alf/index.tsx @@ -2,6 +2,7 @@ import React from 'react' import {Dimensions} from 'react-native' import * as themes from '#/alf/themes' +export * from '#/alf/types' export * as tokens from '#/alf/tokens' export {atoms} from '#/alf/atoms' export * from '#/alf/util/platform' diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts index 0e370cdc..f3ae8027 100644 --- a/src/alf/tokens.ts +++ b/src/alf/tokens.ts @@ -142,6 +142,14 @@ export const gradients = { ], hover_value: '#B88BB6', }, + summer: { + values: [ + [0, '#FF6A56'], + [0.3, '#FF9156'], + [1, '#FFDD87'], + ], + hover_value: '#FF9156', + }, nordic: { values: [ [0, '#083367'], diff --git a/src/alf/types.ts b/src/alf/types.ts index 76ac05d4..dd8d816d 100644 --- a/src/alf/types.ts +++ b/src/alf/types.ts @@ -1,3 +1,5 @@ +import {StyleProp, ViewStyle, TextStyle} from 'react-native' + type LiteralToCommon = T extends number ? number : T extends string @@ -14,3 +16,11 @@ export type Mutable = { ? LiteralToCommon : Mutable } + +export type TextStyleProp = { + style?: StyleProp +} + +export type ViewStyleProp = { + style?: StyleProp +} diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 7c682ac1..f88fbcbd 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -9,10 +9,11 @@ import { View, TextStyle, StyleSheet, + StyleProp, } from 'react-native' import LinearGradient from 'react-native-linear-gradient' -import {useTheme, atoms as a, tokens, web, native} from '#/alf' +import {useTheme, atoms as a, tokens, android, flatten} from '#/alf' import {Props as SVGIconProps} from '#/components/icons/common' export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient' @@ -27,6 +28,7 @@ export type ButtonColor = | 'gradient_nordic' | 'gradient_bonfire' export type ButtonSize = 'small' | 'large' +export type ButtonShape = 'round' | 'square' | 'default' export type VariantProps = { /** * The style variation of the button @@ -40,6 +42,10 @@ export type VariantProps = { * The size of the button */ size?: ButtonSize + /** + * The shape of the button + */ + shape?: ButtonShape } export type ButtonProps = React.PropsWithChildren< @@ -47,6 +53,7 @@ export type ButtonProps = React.PropsWithChildren< AccessibilityProps & VariantProps & { label: string + style?: StyleProp } > export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean} @@ -74,8 +81,10 @@ export function Button({ variant, color, size, + shape = 'default', label, disabled = false, + style, ...rest }: ButtonProps) { const t = useTheme() @@ -175,18 +184,18 @@ export function Button({ if (!disabled) { baseStyles.push({ backgroundColor: light - ? tokens.color.gray_100 + ? tokens.color.gray_50 : tokens.color.gray_900, }) hoverStyles.push({ backgroundColor: light - ? tokens.color.gray_200 + ? tokens.color.gray_100 : tokens.color.gray_950, }) } else { baseStyles.push({ backgroundColor: light - ? tokens.color.gray_300 + ? tokens.color.gray_200 : tokens.color.gray_950, }) } @@ -197,7 +206,7 @@ export function Button({ if (!disabled) { baseStyles.push(a.border, { - borderColor: light ? tokens.color.gray_500 : tokens.color.gray_500, + borderColor: light ? tokens.color.gray_300 : tokens.color.gray_700, }) hoverStyles.push(a.border, t.atoms.bg_contrast_50) } else { @@ -262,10 +271,28 @@ export function Button({ } } - if (size === 'large') { - baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_sm) - } else if (size === 'small') { - baseStyles.push({paddingVertical: 9}, a.px_md, a.rounded_sm, a.gap_sm) + if (shape === 'default') { + if (size === 'large') { + baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md) + } else if (size === 'small') { + baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm) + } + } else if (shape === 'round' || shape === 'square') { + if (size === 'large') { + if (shape === 'round') { + baseStyles.push({height: 54, width: 54}) + } else { + baseStyles.push({height: 50, width: 50}) + } + } else if (size === 'small') { + baseStyles.push({height: 40, width: 40}) + } + + if (shape === 'round') { + baseStyles.push(a.rounded_full) + } else if (shape === 'square') { + baseStyles.push(a.rounded_sm) + } } return { @@ -278,7 +305,7 @@ export function Button({ } as ViewStyle, ], } - }, [t, variant, color, size, disabled]) + }, [t, variant, color, size, shape, disabled]) const {gradientColors, gradientHoverColors, gradientLocations} = React.useMemo(() => { @@ -334,8 +361,10 @@ export function Button({ disabled: disabled || false, }} style={[ + flatten(style), a.flex_row, a.align_center, + a.justify_center, a.overflow_hidden, a.justify_center, ...baseStyles, @@ -462,17 +491,9 @@ export function useSharedButtonTextStyles() { } if (size === 'large') { - baseStyles.push( - a.text_md, - web({paddingBottom: 1}), - native({marginTop: 2}), - ) + baseStyles.push(a.text_md, android({paddingBottom: 1})) } else { - baseStyles.push( - a.text_md, - web({paddingBottom: 1}), - native({marginTop: 2}), - ) + baseStyles.push(a.text_sm, android({paddingBottom: 1})) } return StyleSheet.flatten(baseStyles) @@ -491,14 +512,24 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) { export function ButtonIcon({ icon: Comp, + position, }: { icon: React.ComponentType + position?: 'left' | 'right' }) { - const {size} = useButtonContext() + const {size, disabled} = useButtonContext() const textStyles = useSharedButtonTextStyles() return ( - + +} diff --git a/src/components/Link.tsx b/src/components/Link.tsx index 8f686f3c..63b0c73f 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -1,10 +1,8 @@ import React from 'react' import { - Text, - TextStyle, - StyleProp, GestureResponderEvent, Linking, + TouchableWithoutFeedback, } from 'react-native' import { useLinkProps, @@ -13,9 +11,10 @@ import { } from '@react-navigation/native' import {sanitizeUrl} from '@braintree/sanitize-url' +import {useInteractionState} from '#/components/hooks/useInteractionState' import {isWeb} from '#/platform/detection' -import {useTheme, web, flatten} from '#/alf' -import {Button, ButtonProps, useButtonContext} from '#/components/Button' +import {useTheme, web, flatten, TextStyleProp} from '#/alf' +import {Button, ButtonProps} from '#/components/Button' import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types' import { convertBskyAppUrlIfNeeded, @@ -24,43 +23,39 @@ import { } from '#/lib/strings/url-helpers' import {useModalControls} from '#/state/modals' import {router} from '#/routes' +import {Text} from '#/components/Typography' -export type LinkProps = Omit< - ButtonProps, - 'style' | 'onPress' | 'disabled' | 'label' +/** + * Only available within a `Link`, since that inherits from `Button`. + * `InlineLink` provides no context. + */ +export {useButtonContext as useLinkContext} from '#/components/Button' + +type BaseLinkProps = Pick< + Parameters>[0], + 'to' > & { - /** - * `TextStyle` to apply to the anchor element itself. Does not apply to any children. - */ - style?: StyleProp /** * The React Navigation `StackAction` to perform when the link is pressed. */ action?: 'push' | 'replace' | 'navigate' + /** - * If true, will warn the user if the link text does not match the href. Only - * works for Links with children that are strings i.e. text links. + * If true, will warn the user if the link text does not match the href. + * + * Note: atm this only works for `InlineLink`s with a string child. */ warnOnMismatchingTextChild?: boolean - label?: ButtonProps['label'] -} & Pick>[0], 'to'> +} -/** - * A interactive element that renders as a `` tag on the web. On mobile it - * will translate the `href` to navigator screens and params and dispatch a - * navigation action. - * - * Intended to behave as a web anchor tag. For more complex routing, use a - * `Button`. - */ -export function Link({ - children, +export function useLink({ to, + displayText, action = 'push', warnOnMismatchingTextChild, - style, - ...rest -}: LinkProps) { +}: BaseLinkProps & { + displayText: string +}) { const navigation = useNavigation() const {href} = useLinkProps({ to: @@ -68,14 +63,14 @@ export function Link({ }) const isExternal = isExternalUrl(href) const {openModal, closeModal} = useModalControls() + const onPress = React.useCallback( (e: GestureResponderEvent) => { - const stringChildren = typeof children === 'string' ? children : '' const requiresWarning = Boolean( warnOnMismatchingTextChild && - stringChildren && + displayText && isExternal && - linkRequiresWarning(href, stringChildren), + linkRequiresWarning(href, displayText), ) if (requiresWarning) { @@ -83,7 +78,7 @@ export function Link({ openModal({ name: 'link-warning', - text: stringChildren, + text: displayText, href: href, }) } else { @@ -134,12 +129,42 @@ export function Link({ warnOnMismatchingTextChild, navigation, action, - children, + displayText, closeModal, openModal, ], ) + return { + isExternal, + href, + onPress, + } +} + +export type LinkProps = Omit & + Omit & { + /** + * Label for a11y. Defaults to the href. + */ + label?: string + } + +/** + * A interactive element that renders as a `` tag on the web. On mobile it + * will translate the `href` to navigator screens and params and dispatch a + * navigation action. + * + * Intended to behave as a web anchor tag. For more complex routing, use a + * `Button`. + */ +export function Link({children, to, action = 'push', ...rest}: LinkProps) { + const {href, isExternal, onPress} = useLink({ + to, + displayText: typeof children === 'string' ? children : '', + action, + }) + return ( ) } -function LinkText({ +export type InlineLinkProps = React.PropsWithChildren< + BaseLinkProps & + TextStyleProp & { + /** + * Label for a11y. Defaults to the href. + */ + label?: string + } +> + +export function InlineLink({ children, + to, + action = 'push', + warnOnMismatchingTextChild, style, -}: React.PropsWithChildren<{ - style?: StyleProp -}>) { + ...rest +}: InlineLinkProps) { const t = useTheme() - const {hovered} = useButtonContext() + const stringChildren = typeof children === 'string' + const {href, isExternal, onPress} = useLink({ + to, + displayText: stringChildren ? children : '', + action, + warnOnMismatchingTextChild, + }) + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const { + state: pressed, + onIn: onPressIn, + onOut: onPressOut, + } = useInteractionState() + return ( - - {children as string} - + + + {children} + + ) } diff --git a/src/components/Portal.tsx b/src/components/Portal.tsx index 1813d9e0..d696f986 100644 --- a/src/components/Portal.tsx +++ b/src/components/Portal.tsx @@ -12,45 +12,54 @@ type ComponentMap = { [id: string]: Component } -export const Context = React.createContext({ - outlet: null, - append: () => {}, - remove: () => {}, -}) +export function createPortalGroup() { + const Context = React.createContext({ + outlet: null, + append: () => {}, + remove: () => {}, + }) -export function Provider(props: React.PropsWithChildren<{}>) { - const map = React.useRef({}) - const [outlet, setOutlet] = React.useState(null) + function Provider(props: React.PropsWithChildren<{}>) { + const map = React.useRef({}) + const [outlet, setOutlet] = React.useState(null) - const append = React.useCallback((id, component) => { - if (map.current[id]) return - map.current[id] = {component} - setOutlet(<>{Object.values(map.current)}) - }, []) + const append = React.useCallback((id, component) => { + if (map.current[id]) return + map.current[id] = {component} + setOutlet(<>{Object.values(map.current)}) + }, []) - const remove = React.useCallback(id => { - delete map.current[id] - setOutlet(<>{Object.values(map.current)}) - }, []) + const remove = React.useCallback(id => { + delete map.current[id] + setOutlet(<>{Object.values(map.current)}) + }, []) - return ( - - {props.children} - - ) + return ( + + {props.children} + + ) + } + + function Outlet() { + const ctx = React.useContext(Context) + return ctx.outlet + } + + function Portal({children}: React.PropsWithChildren<{}>) { + const {append, remove} = React.useContext(Context) + const id = React.useId() + React.useEffect(() => { + append(id, children as Component) + return () => remove(id) + }, [id, children, append, remove]) + return null + } + + return {Provider, Outlet, Portal} } -export function Outlet() { - const ctx = React.useContext(Context) - return ctx.outlet -} - -export function Portal({children}: React.PropsWithChildren<{}>) { - const {append, remove} = React.useContext(Context) - const id = React.useId() - React.useEffect(() => { - append(id, children as Component) - return () => remove(id) - }, [id, children, append, remove]) - return null -} +const DefaultPortal = createPortalGroup() +export const Provider = DefaultPortal.Provider +export const Outlet = DefaultPortal.Outlet +export const Portal = DefaultPortal.Portal diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx new file mode 100644 index 00000000..068ee99e --- /dev/null +++ b/src/components/RichText.tsx @@ -0,0 +1,131 @@ +import React from 'react' +import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api' + +import {atoms as a, TextStyleProp} from '#/alf' +import {InlineLink} from '#/components/Link' +import {Text} from '#/components/Typography' +import {toShortUrl} from 'lib/strings/url-helpers' +import {getAgent} from '#/state/session' + +const WORD_WRAP = {wordWrap: 1} + +export function RichText({ + testID, + value, + style, + numberOfLines, + disableLinks, + resolveFacets = false, +}: TextStyleProp & { + value: RichTextAPI | string + testID?: string + numberOfLines?: number + disableLinks?: boolean + resolveFacets?: boolean +}) { + const detected = React.useRef(false) + const [richText, setRichText] = React.useState(() => + value instanceof RichTextAPI ? value : new RichTextAPI({text: value}), + ) + const styles = [a.leading_normal, style] + + React.useEffect(() => { + if (!resolveFacets) return + + async function detectFacets() { + const rt = new RichTextAPI({text: richText.text}) + await rt.detectFacets(getAgent()) + setRichText(rt) + } + + if (!detected.current) { + detected.current = true + detectFacets() + } + }, [richText, setRichText, resolveFacets]) + + const {text, facets} = richText + + if (!facets?.length) { + if (text.length <= 5 && /^\p{Extended_Pictographic}+$/u.test(text)) { + return ( + + {text} + + ) + } + return ( + + {text} + + ) + } + + const els = [] + let key = 0 + // N.B. must access segments via `richText.segments`, not via destructuring + for (const segment of richText.segments()) { + const link = segment.link + const mention = segment.mention + if ( + mention && + AppBskyRichtextFacet.validateMention(mention).success && + !disableLinks + ) { + els.push( + + {segment.text} + , + ) + } else if (link && AppBskyRichtextFacet.validateLink(link).success) { + if (disableLinks) { + els.push(toShortUrl(segment.text)) + } else { + els.push( + + {toShortUrl(segment.text)} + , + ) + } + } else { + els.push(segment.text) + } + key++ + } + + return ( + + {els} + + ) +} diff --git a/src/components/Typography.tsx b/src/components/Typography.tsx index 66cf0720..64aa6d1a 100644 --- a/src/components/Typography.tsx +++ b/src/components/Typography.tsx @@ -1,11 +1,50 @@ import React from 'react' -import {Text as RNText, TextProps} from 'react-native' +import {Text as RNText, TextStyle, TextProps} from 'react-native' import {useTheme, atoms, web, flatten} from '#/alf' +/** + * Util to calculate lineHeight from a text size atom and a leading atom + * + * Example: + * `leading(atoms.text_md, atoms.leading_normal)` // => 24 + */ +export function leading< + Size extends {fontSize?: number}, + Leading extends {lineHeight?: number}, +>(textSize: Size, leading: Leading) { + const size = textSize?.fontSize || atoms.text_md.fontSize + const lineHeight = leading?.lineHeight || atoms.leading_normal.lineHeight + return size * lineHeight +} + +/** + * Ensures that `lineHeight` defaults to a relative value of `1`, or applies + * other relative leading atoms. + * + * If the `lineHeight` value is > 2, we assume it's an absolute value and + * returns it as-is. + */ +function normalizeTextStyles(styles: TextStyle[]) { + const s = flatten(styles) + // should always be defined on these components + const fontSize = s.fontSize || atoms.text_md.fontSize + + if (s?.lineHeight) { + if (s.lineHeight <= 2) { + s.lineHeight = fontSize * s.lineHeight + } + } else { + s.lineHeight = fontSize + } + + return s +} + export function Text({style, ...rest}: TextProps) { const t = useTheme() - return + const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)]) + return } export function H1({style, ...rest}: TextProps) { @@ -19,7 +58,12 @@ export function H1({style, ...rest}: TextProps) { ) } @@ -35,7 +79,12 @@ export function H2({style, ...rest}: TextProps) { ) } @@ -51,7 +100,12 @@ export function H3({style, ...rest}: TextProps) { ) } @@ -67,7 +121,12 @@ export function H4({style, ...rest}: TextProps) { ) } @@ -83,7 +142,12 @@ export function H5({style, ...rest}: TextProps) { ) } @@ -99,7 +163,12 @@ export function H6({style, ...rest}: TextProps) { ) } @@ -110,15 +179,16 @@ export function P({style, ...rest}: TextProps) { web({ role: 'paragraph', }) || {} - const _style = flatten(style) - const lineHeight = - (_style?.lineHeight || atoms.text_md.lineHeight) * - atoms.leading_normal.lineHeight return ( ) } diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index 1ee58303..67515049 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -208,7 +208,7 @@ export function createInput(Component: typeof TextInput) { paddingBottom: 2, }), { - lineHeight: a.text_md.lineHeight * 1.1875, + lineHeight: a.text_md.fontSize * 1.1875, textAlignVertical: rest.multiline ? 'top' : undefined, minHeight: rest.multiline ? 60 : undefined, }, diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx index ad82bdff..d3c03424 100644 --- a/src/components/forms/Toggle.tsx +++ b/src/components/forms/Toggle.tsx @@ -2,7 +2,7 @@ import React from 'react' import {Pressable, View, ViewStyle} from 'react-native' import {HITSLOP_10} from 'lib/constants' -import {useTheme, atoms as a, web, native} from '#/alf' +import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf' import {Text} from '#/components/Typography' import {useInteractionState} from '#/components/hooks/useInteractionState' @@ -49,7 +49,7 @@ export type GroupProps = React.PropsWithChildren<{ label: string }> -export type ItemProps = { +export type ItemProps = ViewStyleProp & { type?: 'radio' | 'checkbox' name: string label: string @@ -57,7 +57,6 @@ export type ItemProps = { disabled?: boolean onChange?: (selected: boolean) => void isInvalid?: boolean - style?: (state: ItemState) => ViewStyle children: ((props: ItemState) => React.ReactNode) | React.ReactNode } @@ -125,6 +124,7 @@ export function Group({ return ( {typeof children === 'function' ? children(state) : children} diff --git a/src/components/forms/ToggleButton.tsx b/src/components/forms/ToggleButton.tsx index 615fedae..5cd51d79 100644 --- a/src/components/forms/ToggleButton.tsx +++ b/src/components/forms/ToggleButton.tsx @@ -20,6 +20,7 @@ export function Group({children, multiple, ...props}: GroupProps) { + {children} ) @@ -95,11 +96,12 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) { borderLeftWidth: 1, marginLeft: -1, }, - a.px_lg, + a.flex_grow, a.py_md, native({ - paddingTop: 14, + paddingBottom: 10, }), + a.px_sm, t.atoms.bg, t.atoms.border, baseStyles, diff --git a/src/components/icons/ArrowRotateCounterClockwise.tsx b/src/components/icons/ArrowRotateCounterClockwise.tsx new file mode 100644 index 00000000..35cd23a9 --- /dev/null +++ b/src/components/icons/ArrowRotateCounterClockwise.tsx @@ -0,0 +1,6 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded = + createSinglePathSVG({ + path: 'M5 3a1 1 0 0 1 1 1v1.423c.498-.46 1.02-.869 1.58-1.213C8.863 3.423 10.302 3 12.028 3a9 9 0 1 1-8.487 12 1 1 0 0 1 1.885-.667A7 7 0 1 0 12.028 5c-1.37 0-2.444.327-3.402.915-.474.29-.93.652-1.383 1.085H9a1 1 0 0 1 0 2H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z', + }) diff --git a/src/components/icons/At.tsx b/src/components/icons/At.tsx new file mode 100644 index 00000000..24872505 --- /dev/null +++ b/src/components/icons/At.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const At_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 4a8 8 0 1 0 4.21 14.804 1 1 0 0 1 1.054 1.7A9.958 9.958 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10c0 1.104-.27 2.31-.949 3.243-.716.984-1.849 1.6-3.331 1.465a4.207 4.207 0 0 1-2.93-1.585c-.94 1.21-2.388 1.94-3.985 1.715-2.53-.356-4.04-2.91-3.682-5.458.358-2.547 2.514-4.586 5.044-4.23.905.127 1.68.536 2.286 1.126a1 1 0 0 1 1.964.368l-.515 3.545v.002a2.222 2.222 0 0 0 1.999 2.526c.75.068 1.212-.21 1.533-.65.358-.493.566-1.245.566-2.067a8 8 0 0 0-8-8Zm-.112 5.13c-1.195-.168-2.544.819-2.784 2.529-.24 1.71.784 3.03 1.98 3.198 1.195.168 2.543-.819 2.784-2.529.24-1.71-.784-3.03-1.98-3.198Z', +}) diff --git a/src/components/icons/Check.tsx b/src/components/icons/Check.tsx new file mode 100644 index 00000000..24316c78 --- /dev/null +++ b/src/components/icons/Check.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Check_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M21.59 3.193a1 1 0 0 1 .217 1.397l-11.706 16a1 1 0 0 1-1.429.193l-6.294-5a1 1 0 1 1 1.244-1.566l5.48 4.353 11.09-15.16a1 1 0 0 1 1.398-.217Z', +}) diff --git a/src/components/icons/Chevron.tsx b/src/components/icons/Chevron.tsx new file mode 100644 index 00000000..b1a9deea --- /dev/null +++ b/src/components/icons/Chevron.tsx @@ -0,0 +1,9 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const ChevronLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M15.707 3.293a1 1 0 0 1 0 1.414L8.414 12l7.293 7.293a1 1 0 0 1-1.414 1.414l-8-8a1 1 0 0 1 0-1.414l8-8a1 1 0 0 1 1.414 0Z', +}) + +export const ChevronRight_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M8.293 3.293a1 1 0 0 1 1.414 0l8 8a1 1 0 0 1 0 1.414l-8 8a1 1 0 0 1-1.414-1.414L15.586 12 8.293 4.707a1 1 0 0 1 0-1.414Z', +}) diff --git a/src/components/icons/CircleInfo.tsx b/src/components/icons/CircleInfo.tsx new file mode 100644 index 00000000..cc3813bf --- /dev/null +++ b/src/components/icons/CircleInfo.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const CircleInfo_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-4a1 1 0 0 1-1-1Zm1-3a1 1 0 1 0 2 0 1 1 0 0 0-2 0Z', +}) diff --git a/src/components/icons/Emoji.tsx b/src/components/icons/Emoji.tsx new file mode 100644 index 00000000..568cd71e --- /dev/null +++ b/src/components/icons/Emoji.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const EmojiSad_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M6.343 6.343a8 8 0 1 1 11.314 11.314A8 8 0 0 1 6.343 6.343ZM19.071 4.93c-3.905-3.905-10.237-3.905-14.142 0-3.905 3.905-3.905 10.237 0 14.142 3.905 3.905 10.237 3.905 14.142 0 3.905-3.905 3.905-10.237 0-14.142Zm-3.537 9.535a5 5 0 0 0-7.07 0 1 1 0 1 0 1.413 1.415 3 3 0 0 1 4.243 0 1 1 0 0 0 1.414-1.415ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5ZM9.25 11c.69 0 1.25-.672 1.25-1.5S9.94 8 9.25 8 8 8.672 8 9.5 8.56 11 9.25 11Z', +}) diff --git a/src/components/icons/EyeSlash.tsx b/src/components/icons/EyeSlash.tsx new file mode 100644 index 00000000..a936a1c7 --- /dev/null +++ b/src/components/icons/EyeSlash.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const EyeSlash_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M2.293 2.293a1 1 0 0 1 1.414 0L7.335 5.92l.03.03 3.22 3.222 4.243 4.242 3.22 3.22.03.03 3.63 3.629a1 1 0 0 1-1.415 1.414l-3.09-3.09c-2.65 1.478-5.625 1.778-8.421.869-3.039-.987-5.779-3.37-7.67-7.027a1 1 0 0 1 0-.918c1.086-2.1 2.452-3.78 3.996-5.019L2.293 3.707a1 1 0 0 1 0-1.414Zm4.24 5.654 2.021 2.021a4 4 0 0 0 5.478 5.478l1.688 1.688c-2.042.982-4.246 1.124-6.32.45-2.34-.76-4.594-2.586-6.265-5.584.97-1.739 2.135-3.083 3.398-4.053Zm3.535 3.535 2.45 2.45a2 2 0 0 1-2.45-2.45Zm.81-5.405c3.573-.49 7.45 1.369 9.987 5.923a14.797 14.797 0 0 1-1.347 2.02 1 1 0 1 0 1.564 1.247 17.078 17.078 0 0 0 1.806-2.808 1 1 0 0 0 0-.918c-2.833-5.479-7.584-8.088-12.281-7.446a1 1 0 0 0 .271 1.982Z', +}) diff --git a/src/components/icons/FilterTimeline.tsx b/src/components/icons/FilterTimeline.tsx new file mode 100644 index 00000000..ea11a429 --- /dev/null +++ b/src/components/icons/FilterTimeline.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const FilterTimeline_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M7.002 5a1 1 0 0 0-2 0v11.587l-1.295-1.294a1 1 0 0 0-1.414 1.414l3.002 3a1 1 0 0 0 1.414 0l2.998-3a1 1 0 0 0-1.414-1.414l-1.291 1.292V5ZM16 16a1 1 0 1 0 0 2h4a1 1 0 1 0 0-2h-4Zm-3-4a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1Zm-1-6a1 1 0 1 0 0 2h8a1 1 0 1 0 0-2h-8Z', +}) diff --git a/src/components/icons/Growth.tsx b/src/components/icons/Growth.tsx new file mode 100644 index 00000000..ab5684a5 --- /dev/null +++ b/src/components/icons/Growth.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Growth_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3 4a1 1 0 0 1 1-1h1a8.003 8.003 0 0 1 7.75 6.006A7.985 7.985 0 0 1 19 6h1a1 1 0 0 1 1 1v1a8 8 0 0 1-8 8v4a1 1 0 1 1-2 0v-7a8 8 0 0 1-8-8V4Zm2 1a6 6 0 0 1 6 6 6 6 0 0 1-6-6Zm8 9a6 6 0 0 1 6-6 6 6 0 0 1-6 6Z', +}) diff --git a/src/components/icons/Hashtag.tsx b/src/components/icons/Hashtag.tsx new file mode 100644 index 00000000..668ed925 --- /dev/null +++ b/src/components/icons/Hashtag.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Hashtag_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M9.124 3.008a1 1 0 0 1 .868 1.116L9.632 7h5.985l.39-3.124a1 1 0 0 1 1.985.248L17.632 7H20a1 1 0 1 1 0 2h-2.617l-.75 6H20a1 1 0 1 1 0 2h-3.617l-.39 3.124a1 1 0 1 1-1.985-.248l.36-2.876H8.382l-.39 3.124a1 1 0 1 1-1.985-.248L6.368 17H4a1 1 0 1 1 0-2h2.617l.75-6H4a1 1 0 1 1 0-2h3.617l.39-3.124a1 1 0 0 1 1.117-.868ZM9.383 9l-.75 6h5.984l.75-6H9.383Z', +}) diff --git a/src/components/icons/ListMagnifyingGlass.tsx b/src/components/icons/ListMagnifyingGlass.tsx new file mode 100644 index 00000000..a897fe85 --- /dev/null +++ b/src/components/icons/ListMagnifyingGlass.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const ListMagnifyingGlass_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3 4a1 1 0 0 1 1-1h13a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm1 4a1 1 0 0 0 0 2h5a1 1 0 0 0 0-2H4Zm-1 7a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm0 5a1 1 0 0 1 1-1h13a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm9-8a4 4 0 1 1 7.446 2.032l.99.989a1 1 0 1 1-1.415 1.414l-.99-.989A4 4 0 0 1 12 12Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z', +}) diff --git a/src/components/icons/ListSparkle.tsx b/src/components/icons/ListSparkle.tsx new file mode 100644 index 00000000..4d472465 --- /dev/null +++ b/src/components/icons/ListSparkle.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const ListSparkle_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M4 5a1 1 0 0 0 0 2h16a1 1 0 1 0 0-2H4Zm0 12a1 1 0 1 0 0 2h3a1 1 0 1 0 0-2H4Zm-1-5a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1Zm14-3a1 1 0 0 1 .92.606l1.342 3.132 3.132 1.343a1 1 0 0 1 0 1.838l-3.132 1.343-1.343 3.132a1 1 0 0 1-1.838 0l-1.343-3.132-3.132-1.343a1 1 0 0 1 0-1.838l3.132-1.343 1.343-3.132A1 1 0 0 1 17 9Zm0 3.539-.58 1.355a1 1 0 0 1-.526.525L14.539 15l1.355.58a1 1 0 0 1 .525.526L17 17.461l.58-1.355a1 1 0 0 1 .526-.525L19.461 15l-1.355-.58a1 1 0 0 1-.525-.526L17 12.539Z', +}) diff --git a/src/components/icons/News2.tsx b/src/components/icons/News2.tsx new file mode 100644 index 00000000..f2124e7b --- /dev/null +++ b/src/components/icons/News2.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const News2_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M1 5a1 1 0 0 1 1-1h7a3.99 3.99 0 0 1 3 1.354A3.99 3.99 0 0 1 15 4h7a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-6.723c-.52 0-1 .125-1.4.372-.421.26-.761.633-.983 1.075a1 1 0 0 1-1.788 0 2.664 2.664 0 0 0-.983-1.075c-.4-.247-.88-.372-1.4-.372H2a1 1 0 0 1-1-1V5Zm10 3a2 2 0 0 0-2-2H3v12h5.723c.776 0 1.564.173 2.277.569V8Zm2 10.569V8a2 2 0 0 1 2-2h6v12h-5.723c-.776 0-1.564.173-2.277.569Z', +}) diff --git a/src/components/icons/Plus.tsx b/src/components/icons/Plus.tsx new file mode 100644 index 00000000..d0698f7f --- /dev/null +++ b/src/components/icons/Plus.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const PlusLarge_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 3a1 1 0 0 1 1 1v7h7a1 1 0 1 1 0 2h-7v7a1 1 0 1 1-2 0v-7H4a1 1 0 1 1 0-2h7V4a1 1 0 0 1 1-1Z', +}) diff --git a/src/components/icons/Trending2.tsx b/src/components/icons/Trending2.tsx new file mode 100644 index 00000000..5fba4167 --- /dev/null +++ b/src/components/icons/Trending2.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Trending2_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'm18.192 5.004 1.864 5.31a1 1 0 0 0 1.887-.662L20.08 4.34c-.665-1.893-3.378-1.741-3.834.207l-3.381 14.449-2.985-9.605C9.3 7.531 6.684 7.506 6.07 9.355l-1.18 3.56-.969-2.312a1 1 0 0 0-1.844.772l.97 2.315c.715 1.71 3.159 1.613 3.741-.144l1.18-3.56 2.985 9.605c.607 1.952 3.392 1.848 3.857-.138l3.381-14.449Z', +}) diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts index c84f7979..54e143fa 100644 --- a/src/lib/analytics/types.ts +++ b/src/lib/analytics/types.ts @@ -131,6 +131,38 @@ interface TrackPropertiesMap { 'Onboarding:Reset': {} 'Onboarding:SuggestedFollowFollowed': {} 'Onboarding:CustomFeedAdded': {} + // Onboarding v2 + 'OnboardingV2:Begin': {} + 'OnboardingV2:StepInterests:Start': {} + 'OnboardingV2:StepInterests:End': { + selectedInterests: string[] + selectedInterestsLength: number + } + 'OnboardingV2:StepInterests:Error': {} + 'OnboardingV2:StepSuggestedAccounts:Start': {} + 'OnboardingV2:StepSuggestedAccounts:End': { + selectedAccountsLength: number + } + 'OnboardingV2:StepFollowingFeed:Start': {} + 'OnboardingV2:StepFollowingFeed:End': {} + 'OnboardingV2:StepAlgoFeeds:Start': {} + 'OnboardingV2:StepAlgoFeeds:End': { + selectedPrimaryFeeds: string[] + selectedPrimaryFeedsLength: number + selectedSecondaryFeeds: string[] + selectedSecondaryFeedsLength: number + } + 'OnboardingV2:StepTopicalFeeds:Start': {} + 'OnboardingV2:StepTopicalFeeds:End': { + selectedFeeds: string[] + selectedFeedsLength: number + } + 'OnboardingV2:StepModeration:Start': {} + 'OnboardingV2:StepModeration:End': {} + 'OnboardingV2:StepFinished:Start': {} + 'OnboardingV2:StepFinished:End': {} + 'OnboardingV2:Complete': {} + 'OnboardingV2:Skip': {} } interface ScreenPropertiesMap { diff --git a/src/lib/build-flags.ts b/src/lib/build-flags.ts index cf05114e..d651887f 100644 --- a/src/lib/build-flags.ts +++ b/src/lib/build-flags.ts @@ -1,2 +1,3 @@ export const LOGIN_INCLUDE_DEV_SERVERS = true export const PWI_ENABLED = true +export const NEW_ONBOARDING_ENABLED = false diff --git a/src/screens/Onboarding/IconCircle.tsx b/src/screens/Onboarding/IconCircle.tsx new file mode 100644 index 00000000..a54c8b4e --- /dev/null +++ b/src/screens/Onboarding/IconCircle.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import {View} from 'react-native' + +import { + useTheme, + atoms as a, + ViewStyleProp, + TextStyleProp, + flatten, +} from '#/alf' +import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth' +import {Props} from '#/components/icons/common' + +export function IconCircle({ + icon: Icon, + size = 'xl', + style, + iconStyle, +}: ViewStyleProp & { + icon: typeof Growth + size?: Props['size'] + iconStyle?: TextStyleProp['style'] +}) { + const t = useTheme() + + return ( + + + + ) +} diff --git a/src/screens/Onboarding/Layout.tsx b/src/screens/Onboarding/Layout.tsx new file mode 100644 index 00000000..50487c18 --- /dev/null +++ b/src/screens/Onboarding/Layout.tsx @@ -0,0 +1,231 @@ +import React from 'react' +import {View} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' + +import {IS_DEV} from '#/env' +import {isWeb} from '#/platform/detection' +import {useOnboardingDispatch} from '#/state/shell' + +import { + useTheme, + atoms as a, + useBreakpoints, + web, + native, + flatten, + TextStyleProp, +} from '#/alf' +import {H2, P, leading} from '#/components/Typography' +import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' +import {Button, ButtonIcon} from '#/components/Button' +import {ScrollView} from '#/view/com/util/Views' +import {createPortalGroup} from '#/components/Portal' + +import {Context} from '#/screens/Onboarding/state' + +const COL_WIDTH = 500 + +export const OnboardingControls = createPortalGroup() + +export function Layout({children}: React.PropsWithChildren<{}>) { + const {_} = useLingui() + const t = useTheme() + const insets = useSafeAreaInsets() + const {gtMobile} = useBreakpoints() + const onboardDispatch = useOnboardingDispatch() + const {state, dispatch} = React.useContext(Context) + const scrollview = React.useRef(null) + const prevActiveStep = React.useRef(state.activeStep) + + React.useEffect(() => { + if (state.activeStep !== prevActiveStep.current) { + prevActiveStep.current = state.activeStep + scrollview.current?.scrollTo({y: 0, animated: false}) + } + }, [state]) + + const paddingTop = gtMobile ? a.py_5xl : a.py_lg + const dialogLabel = _(msg`Set up your account`) + + return ( + + {IS_DEV && ( + + + + )} + + {!gtMobile && state.hasPrev && ( + + + + + + )} + + + + + + + {Array(state.totalSteps) + .fill(0) + .map((_, i) => ( + + ))} + + + + + {children} + + + + + + + + + + {gtMobile && + (state.hasPrev ? ( + + ) : ( + + ))} + + + + + ) +} + +export function Title({ + children, + style, +}: React.PropsWithChildren) { + return ( +

+ {children} +

+ ) +} + +export function Description({ + children, + style, +}: React.PropsWithChildren) { + const t = useTheme() + return

{children}

+} diff --git a/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx b/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx new file mode 100644 index 00000000..c7f1e6e4 --- /dev/null +++ b/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx @@ -0,0 +1,378 @@ +import React from 'react' +import {View} from 'react-native' +import LinearGradient from 'react-native-linear-gradient' +import {Image} from 'expo-image' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' + +import {useTheme, atoms as a} from '#/alf' +import * as Toggle from '#/components/forms/Toggle' +import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed' +import {Text, H3} from '#/components/Typography' +import {RichText} from '#/components/RichText' + +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {FeedConfig} from '#/screens/Onboarding/StepAlgoFeeds' + +function PrimaryFeedCardInner({ + feed, + config, +}: { + feed: FeedSourceInfo + config: FeedConfig +}) { + const t = useTheme() + const ctx = Toggle.useItemContext() + + const styles = React.useMemo( + () => ({ + active: [t.atoms.bg_contrast_25], + selected: [ + a.shadow_md, + { + backgroundColor: + t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950, + }, + ], + selectedHover: [ + { + backgroundColor: + t.name === 'light' ? t.palette.primary_25 : t.palette.primary_975, + }, + ], + textSelected: [{color: t.palette.white}], + checkboxSelected: [ + { + borderColor: t.palette.white, + }, + ], + }), + [t], + ) + + return ( + + {ctx.selected && config.gradient && ( + v[1])} + locations={config.gradient.values.map(v => v[0])} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={[a.absolute, a.inset_0]} + /> + )} + + + + + + + +

+ {feed.displayName} +

+ + + by @{feed.creatorHandle} + +
+ + + {ctx.selected && } + +
+ + + + + + + + ) +} + +export function PrimaryFeedCard({config}: {config: FeedConfig}) { + const {_} = useLingui() + const {data: feed} = useFeedSourceInfoQuery({uri: config.uri}) + + return !feed ? ( + + ) : ( + + + + ) +} + +function FeedCardInner({feed}: {feed: FeedSourceInfo; config: FeedConfig}) { + const t = useTheme() + const ctx = Toggle.useItemContext() + + const styles = React.useMemo( + () => ({ + active: [t.atoms.bg_contrast_25], + selected: [ + { + backgroundColor: + t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950, + }, + ], + selectedHover: [ + { + backgroundColor: + t.name === 'light' ? t.palette.primary_25 : t.palette.primary_975, + }, + ], + textSelected: [], + checkboxSelected: [ + { + backgroundColor: t.palette.primary_500, + }, + ], + }), + [t], + ) + + return ( + + + + + + + +

+ {feed.displayName} +

+ + @{feed.creatorHandle} + +
+ + + {ctx.selected && } + +
+ + + + + + + + ) +} + +export function FeedCard({config}: {config: FeedConfig}) { + const {_} = useLingui() + const {data: feed} = useFeedSourceInfoQuery({uri: config.uri}) + + return !feed ? ( + + ) : feed.avatar ? ( + + + + ) : null +} + +export function FeedCardPlaceholder({primary}: {primary?: boolean}) { + const t = useTheme() + return ( + + + + + + + + + + + + + + + + + ) +} diff --git a/src/screens/Onboarding/StepAlgoFeeds/index.tsx b/src/screens/Onboarding/StepAlgoFeeds/index.tsx new file mode 100644 index 00000000..4920c5ad --- /dev/null +++ b/src/screens/Onboarding/StepAlgoFeeds/index.tsx @@ -0,0 +1,160 @@ +import React from 'react' +import {View} from 'react-native' +import {useLingui} from '@lingui/react' +import {msg, Trans} from '@lingui/macro' + +import {atoms as a, tokens, useTheme} from '#/alf' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Toggle from '#/components/forms/Toggle' +import {Text} from '#/components/Typography' +import {Loader} from '#/components/Loader' +import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' +import {useAnalytics} from '#/lib/analytics/analytics' + +import {Context} from '#/screens/Onboarding/state' +import { + Title, + Description, + OnboardingControls, +} from '#/screens/Onboarding/Layout' +import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard' +import {IconCircle} from '#/screens/Onboarding/IconCircle' + +export type FeedConfig = { + default: boolean + uri: string + gradient?: typeof tokens.gradients.midnight | typeof tokens.gradients.nordic +} + +const PRIMARY_FEEDS: FeedConfig[] = [ + { + default: true, + uri: 'at://did:plc:wqowuobffl66jv3kpsvo7ak4/app.bsky.feed.generator/the-algorithm', + gradient: tokens.gradients.midnight, + }, + { + default: false, + uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot', + gradient: tokens.gradients.midnight, + }, +] + +const SECONDARY_FEEDS: FeedConfig[] = [ + { + default: false, + uri: 'at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/infreq', + }, + { + default: false, + uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends', + }, + { + default: false, + uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/best-of-follows', + }, + { + default: false, + uri: 'at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/catch-up', + }, + { + default: false, + uri: 'at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/at-bangers', + }, +] + +export function StepAlgoFeeds() { + const {_} = useLingui() + const {track} = useAnalytics() + const t = useTheme() + const {state, dispatch} = React.useContext(Context) + const [primaryFeedUris, setPrimaryFeedUris] = React.useState( + PRIMARY_FEEDS.map(f => (f.default ? f.uri : '')).filter(Boolean), + ) + const [secondaryFeedUris, setSeconaryFeedUris] = React.useState([]) + const [saving, setSaving] = React.useState(false) + + const saveFeeds = React.useCallback(async () => { + setSaving(true) + + const uris = primaryFeedUris.concat(secondaryFeedUris) + dispatch({type: 'setAlgoFeedsStepResults', feedUris: uris}) + + setSaving(false) + dispatch({type: 'next'}) + track('OnboardingV2:StepAlgoFeeds:End', { + selectedPrimaryFeeds: primaryFeedUris, + selectedPrimaryFeedsLength: primaryFeedUris.length, + selectedSecondaryFeeds: secondaryFeedUris, + selectedSecondaryFeedsLength: secondaryFeedUris.length, + }) + }, [primaryFeedUris, secondaryFeedUris, dispatch, track]) + + React.useEffect(() => { + track('OnboardingV2:StepAlgoFeeds:Start') + }, [track]) + + return ( + + + + + <Trans>Choose your algorithmic feeds</Trans> + + + + Feeds are created by users and can give you entirely new experiences. + + + + + + + We recommend "For You" by Skygaze: + + + + Or you can try our "Discover" algorithm: + + + + + + + There are many feeds to try: + + + {SECONDARY_FEEDS.map(config => ( + + ))} + + + + + + + + + ) +} diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx new file mode 100644 index 00000000..02c45f59 --- /dev/null +++ b/src/screens/Onboarding/StepFinished.tsx @@ -0,0 +1,158 @@ +import React from 'react' +import {View} from 'react-native' +import {useLingui} from '@lingui/react' +import {msg, Trans} from '@lingui/macro' + +import {logger} from '#/logger' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText, ButtonIcon} from '#/components/Button' +import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2' +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth' +import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2' +import {Text} from '#/components/Typography' +import {useOnboardingDispatch} from '#/state/shell' +import {Loader} from '#/components/Loader' +import {useSetSaveFeedsMutation} from '#/state/queries/preferences' +import {getAgent} from '#/state/session' +import {useAnalytics} from '#/lib/analytics/analytics' + +import {Context} from '#/screens/Onboarding/state' +import { + Title, + Description, + OnboardingControls, +} from '#/screens/Onboarding/Layout' +import {IconCircle} from '#/screens/Onboarding/IconCircle' +import { + bulkWriteFollows, + sortPrimaryAlgorithmFeeds, +} from '#/screens/Onboarding/util' + +export function StepFinished() { + const {_} = useLingui() + const t = useTheme() + const {track} = useAnalytics() + const {state, dispatch} = React.useContext(Context) + const onboardDispatch = useOnboardingDispatch() + const [saving, setSaving] = React.useState(false) + const {mutateAsync: saveFeeds} = useSetSaveFeedsMutation() + + const finishOnboarding = React.useCallback(async () => { + setSaving(true) + + const { + interestsStepResults, + suggestedAccountsStepResults, + algoFeedsStepResults, + topicalFeedsStepResults, + } = state + const {selectedInterests} = interestsStepResults + const selectedFeeds = [ + ...sortPrimaryAlgorithmFeeds(algoFeedsStepResults.feedUris), + ...topicalFeedsStepResults.feedUris, + ] + + try { + await Promise.all([ + bulkWriteFollows(suggestedAccountsStepResults.accountDids), + // these must be serial + (async () => { + await getAgent().setInterestsPref({tags: selectedInterests}) + await saveFeeds({ + saved: selectedFeeds, + pinned: selectedFeeds, + }) + })(), + ]) + } catch (e: any) { + logger.info(`onboarding: bulk save failed`) + logger.error(e) + // don't alert the user, just let them into their account + } + + setSaving(false) + dispatch({type: 'finish'}) + onboardDispatch({type: 'finish'}) + track('OnboardingV2:StepFinished:End') + track('OnboardingV2:Complete') + }, [state, dispatch, onboardDispatch, setSaving, saveFeeds, track]) + + React.useEffect(() => { + track('OnboardingV2:StepFinished:Start') + }, [track]) + + return ( + + + + + <Trans>You're ready to go!</Trans> + + + We hope you have a wonderful time. Remember, Bluesky is: + + + + + + + + Public + + + + Your posts, likes, and blocks are public. Mutes are private. + + + + + + + + + Open + + + Never lose access to your followers and data. + + + + + + + + Flexible + + + Choose the algorithms that power your custom feeds. + + + + + + + + + + ) +} diff --git a/src/screens/Onboarding/StepFollowingFeed.tsx b/src/screens/Onboarding/StepFollowingFeed.tsx new file mode 100644 index 00000000..4b3c6288 --- /dev/null +++ b/src/screens/Onboarding/StepFollowingFeed.tsx @@ -0,0 +1,160 @@ +import React from 'react' +import {View} from 'react-native' +import {useLingui} from '@lingui/react' +import {msg, Trans} from '@lingui/macro' + +import {atoms as a} from '#/alf' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' +import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {Text} from '#/components/Typography' +import {Divider} from '#/components/Divider' +import * as Toggle from '#/components/forms/Toggle' +import {useAnalytics} from '#/lib/analytics/analytics' + +import {Context} from '#/screens/Onboarding/state' +import { + Title, + Description, + OnboardingControls, +} from '#/screens/Onboarding/Layout' +import { + usePreferencesQuery, + useSetFeedViewPreferencesMutation, +} from 'state/queries/preferences' +import {IconCircle} from '#/screens/Onboarding/IconCircle' + +export function StepFollowingFeed() { + const {_} = useLingui() + const {track} = useAnalytics() + const {dispatch} = React.useContext(Context) + + const {data: preferences} = usePreferencesQuery() + const {mutate: setFeedViewPref, variables} = + useSetFeedViewPreferencesMutation() + + const showReplies = !( + variables?.hideReplies ?? preferences?.feedViewPrefs.hideReplies + ) + const showReposts = !( + variables?.hideReposts ?? preferences?.feedViewPrefs.hideReposts + ) + const showQuotes = !( + variables?.hideQuotePosts ?? preferences?.feedViewPrefs.hideQuotePosts + ) + + const onContinue = React.useCallback(() => { + dispatch({type: 'next'}) + track('OnboardingV2:StepFollowingFeed:End') + }, [track, dispatch]) + + React.useEffect(() => { + track('OnboardingV2:StepFollowingFeed:Start') + }, [track]) + + return ( + // Hack for now to move the image container up + + + + + <Trans>Your default feed is "Following"</Trans> + + + It show posts from the people your follow as they happen. + + + + { + setFeedViewPref({ + hideReplies: showReplies, + }) + }}> + + + Show replies in Following + + + + + + { + setFeedViewPref({ + hideReposts: showReposts, + }) + }}> + + + Show reposts in Following + + + + + + { + setFeedViewPref({ + hideQuotePosts: showQuotes, + }) + }}> + + + Show quotes in Following + + + + + + + + You can change these settings later. + + + + + + + ) +} diff --git a/src/screens/Onboarding/StepInterests/InterestButton.tsx b/src/screens/Onboarding/StepInterests/InterestButton.tsx new file mode 100644 index 00000000..02413b18 --- /dev/null +++ b/src/screens/Onboarding/StepInterests/InterestButton.tsx @@ -0,0 +1,79 @@ +import React from 'react' +import {View, ViewStyle, TextStyle} from 'react-native' + +import {useTheme, atoms as a, native} from '#/alf' +import * as Toggle from '#/components/forms/Toggle' +import {Text} from '#/components/Typography' + +import {INTEREST_TO_DISPLAY_NAME} from '#/screens/Onboarding/StepInterests/data' + +export function InterestButton({interest}: {interest: string}) { + const t = useTheme() + const ctx = Toggle.useItemContext() + + const styles = React.useMemo(() => { + const hovered: ViewStyle[] = [ + { + backgroundColor: + t.name === 'light' ? t.palette.contrast_200 : t.palette.contrast_50, + }, + ] + const focused: ViewStyle[] = [] + const pressed: ViewStyle[] = [] + const selected: ViewStyle[] = [ + { + backgroundColor: t.palette.contrast_900, + }, + ] + const selectedHover: ViewStyle[] = [ + { + backgroundColor: t.palette.contrast_800, + }, + ] + const textSelected: TextStyle[] = [ + { + color: t.palette.contrast_100, + }, + ] + + return { + hovered, + focused, + pressed, + selected, + selectedHover, + textSelected, + } + }, [t]) + + return ( + + + {INTEREST_TO_DISPLAY_NAME[interest]} + + + ) +} diff --git a/src/screens/Onboarding/StepInterests/data.ts b/src/screens/Onboarding/StepInterests/data.ts new file mode 100644 index 00000000..00a25331 --- /dev/null +++ b/src/screens/Onboarding/StepInterests/data.ts @@ -0,0 +1,36 @@ +export const INTEREST_TO_DISPLAY_NAME: { + [key: string]: string +} = { + news: 'News', + journalism: 'Journalism', + nature: 'Nature', + art: 'Art', + comics: 'Comics', + writers: 'Writers', + culture: 'Culture', + sports: 'Sports', + pets: 'Pets', + animals: 'Animals', + books: 'Books', + education: 'Education', + climate: 'Climate', + science: 'Science', + politics: 'Politics', + fitness: 'Fitness', + tech: 'Tech', + dev: 'Software Dev', + comedy: 'Comedy', + gaming: 'Video Games', + food: 'Food', + cooking: 'Cooking', +} + +export type ApiResponseMap = { + interests: string[] + suggestedAccountDids: { + [key: string]: string[] + } + suggestedFeedUris: { + [key: string]: string[] + } +} diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx new file mode 100644 index 00000000..6f60991d --- /dev/null +++ b/src/screens/Onboarding/StepInterests/index.tsx @@ -0,0 +1,260 @@ +import React from 'react' +import {View} from 'react-native' +import {useLingui} from '@lingui/react' +import {msg, Trans} from '@lingui/macro' +import {useQuery} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' +import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' +import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji' +import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {Loader} from '#/components/Loader' +import * as Toggle from '#/components/forms/Toggle' +import {getAgent} from '#/state/session' +import {useAnalytics} from '#/lib/analytics/analytics' +import {Text} from '#/components/Typography' +import {useOnboardingDispatch} from '#/state/shell' + +import {Context} from '#/screens/Onboarding/state' +import { + Title, + Description, + OnboardingControls, +} from '#/screens/Onboarding/Layout' +import { + ApiResponseMap, + INTEREST_TO_DISPLAY_NAME, +} from '#/screens/Onboarding/StepInterests/data' +import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton' +import {IconCircle} from '#/screens/Onboarding/IconCircle' + +export function StepInterests() { + const {_} = useLingui() + const t = useTheme() + const {track} = useAnalytics() + const {gtMobile} = useBreakpoints() + const {state, dispatch} = React.useContext(Context) + const [saving, setSaving] = React.useState(false) + const [interests, setInterests] = React.useState( + state.interestsStepResults.selectedInterests.map(i => i), + ) + const onboardDispatch = useOnboardingDispatch() + const {isLoading, isError, error, data, refetch, isFetching} = useQuery({ + queryKey: ['interests'], + queryFn: async () => { + try { + const {data} = + await getAgent().app.bsky.unspecced.getTaggedSuggestions() + return data.suggestions.reduce( + (agg, s) => { + const {tag, subject, subjectType} = s + const isDefault = tag === 'default' + + if (!agg.interests.includes(tag) && !isDefault) { + agg.interests.push(tag) + } + + if (subjectType === 'user') { + agg.suggestedAccountDids[tag] = + agg.suggestedAccountDids[tag] || [] + agg.suggestedAccountDids[tag].push(subject) + } + + if (subjectType === 'feed') { + // agg all feeds into defaults + if (isDefault) { + agg.suggestedFeedUris[tag] = agg.suggestedFeedUris[tag] || [] + } else { + agg.suggestedFeedUris[tag] = agg.suggestedFeedUris[tag] || [] + agg.suggestedFeedUris[tag].push(subject) + agg.suggestedFeedUris.default.push(subject) + } + } + + return agg + }, + { + interests: [], + suggestedAccountDids: {}, + suggestedFeedUris: {}, + } as ApiResponseMap, + ) + } catch (e: any) { + logger.info( + `onboarding: getTaggedSuggestions fetch or processing failed`, + ) + logger.error(e) + track('OnboardingV2:StepInterests:Error') + + throw new Error(`a network error occurred`) + } + }, + }) + + const saveInterests = React.useCallback(async () => { + setSaving(true) + + try { + setSaving(false) + dispatch({ + type: 'setInterestsStepResults', + apiResponse: data!, + selectedInterests: interests, + }) + dispatch({type: 'next'}) + + track('OnboardingV2:StepInterests:End', { + selectedInterests: interests, + selectedInterestsLength: interests.length, + }) + } catch (e: any) { + logger.info(`onboading: error saving interests`) + logger.error(e) + } + }, [interests, data, setSaving, dispatch, track]) + + const skipOnboarding = React.useCallback(() => { + onboardDispatch({type: 'finish'}) + dispatch({type: 'finish'}) + track('OnboardingV2:Skip') + }, [onboardDispatch, dispatch, track]) + + React.useEffect(() => { + track('OnboardingV2:Begin') + track('OnboardingV2:StepInterests:Start') + }, [track]) + + const title = isError ? ( + Oh no! Something went wrong. + ) : ( + What are your interests? + ) + const description = isError ? ( + + We weren't able to connect. Please try again to continue setting up your + account. If it continues to fail, you can skip this flow. + + ) : ( + We'll use this to help customize your experience. + ) + + return ( + + + + {title} + {description} + + + {isLoading ? ( + + ) : isError || !data ? ( + + + + Error:{' '} + + {error?.message || 'an unknown error occurred'} + + + ) : ( + + + {data.interests.map(interest => ( + + + + ))} + + + )} + + + + {isError ? ( + + + + + ) : ( + + )} + + + ) +} diff --git a/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx b/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx new file mode 100644 index 00000000..bc4c0387 --- /dev/null +++ b/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx @@ -0,0 +1,135 @@ +import React from 'react' +import {View} from 'react-native' +import {useLingui} from '@lingui/react' +import {msg, Trans} from '@lingui/macro' + +import {isIOS} from '#/platform/detection' +import * as Toast from '#/view/com/util/Toast' +import {atoms as a, useTheme} from '#/alf' +import { + usePreferencesQuery, + usePreferencesSetAdultContentMutation, +} from '#/state/queries/preferences' +import {logger} from '#/logger' +import {Text} from '#/components/Typography' +import {InlineLink} from '#/components/Link' +import * as Toggle from '#/components/forms/Toggle' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' + +function Card({children}: React.PropsWithChildren<{}>) { + const t = useTheme() + return ( + + {children} + + ) +} + +export function AdultContentEnabledPref() { + const {_} = useLingui() + const t = useTheme() + + // Reuse logic here form ContentFilteringSettings.tsx + const {data: preferences} = usePreferencesQuery() + const {mutate, variables} = usePreferencesSetAdultContentMutation() + + const onToggleAdultContent = React.useCallback(async () => { + if (isIOS) return + + try { + mutate({ + enabled: !(variables?.enabled ?? preferences?.adultContentEnabled), + }) + } catch (e) { + Toast.show( + _(msg`There was an issue syncing your preferences with the server`), + ) + logger.error('Failed to update preferences with server', {error: e}) + } + }, [variables, preferences, mutate, _]) + + if (!preferences) return null + + if (isIOS) { + if (preferences?.adultContentEnabled === true) { + return null + } else { + return ( + + + + + Adult content can only be enabled via the Web at{' '} + + bsky.app + + . + + + + ) + } + } else { + if (preferences?.userAge) { + if (preferences.userAge >= 18) { + return ( + + + + Enable Adult Content + + + + + ) + } else { + return ( + + + + + You must be 18 years or older to enable adult content + + + + ) + } + } + + return null + } +} diff --git a/src/screens/Onboarding/StepModeration/ModerationOption.tsx b/src/screens/Onboarding/StepModeration/ModerationOption.tsx new file mode 100644 index 00000000..904c4729 --- /dev/null +++ b/src/screens/Onboarding/StepModeration/ModerationOption.tsx @@ -0,0 +1,85 @@ +import React from 'react' +import {View} from 'react-native' +import {LabelPreference} from '@atproto/api' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' + +import { + CONFIGURABLE_LABEL_GROUPS, + ConfigurableLabelGroup, + usePreferencesQuery, + usePreferencesSetContentLabelMutation, +} from '#/state/queries/preferences' +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import * as ToggleButton from '#/components/forms/ToggleButton' + +export function ModerationOption({ + labelGroup, +}: { + labelGroup: ConfigurableLabelGroup +}) { + const {_} = useLingui() + const t = useTheme() + const groupInfo = CONFIGURABLE_LABEL_GROUPS[labelGroup] + const {data: preferences} = usePreferencesQuery() + const {mutate, variables} = usePreferencesSetContentLabelMutation() + const visibility = + variables?.visibility ?? preferences?.contentLabels?.[labelGroup] + + const onChange = React.useCallback( + (vis: string[]) => { + mutate({labelGroup, visibility: vis[0] as LabelPreference}) + }, + [mutate, labelGroup], + ) + + const labels = { + hide: _(msg`Hide`), + warn: _(msg`Warn`), + show: _(msg`Show`), + } + + return ( + + + {groupInfo.title} + + {groupInfo.subtitle} + + + + {!preferences?.adultContentEnabled && groupInfo.isAdultImagery ? ( + + {labels.hide} + + ) : ( + + + {labels.hide} + + + {labels.warn} + + + {labels.show} + + + )} + + + ) +} diff --git a/src/screens/Onboarding/StepModeration/index.tsx b/src/screens/Onboarding/StepModeration/index.tsx new file mode 100644 index 00000000..be605e40 --- /dev/null +++ b/src/screens/Onboarding/StepModeration/index.tsx @@ -0,0 +1,91 @@ +import React from 'react' +import {View} from 'react-native' +import {useLingui} from '@lingui/react' +import {msg, Trans} from '@lingui/macro' + +import {atoms as a} from '#/alf' +import {configurableLabelGroups} from 'state/queries/preferences' +import {Divider} from '#/components/Divider' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' +import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {Loader} from '#/components/Loader' +import {useAnalytics} from '#/lib/analytics/analytics' + +import { + Description, + OnboardingControls, + Title, +} from '#/screens/Onboarding/Layout' +import {ModerationOption} from '#/screens/Onboarding/StepModeration/ModerationOption' +import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/AdultContentEnabledPref' +import {Context} from '#/screens/Onboarding/state' +import {IconCircle} from '#/screens/Onboarding/IconCircle' + +export function StepModeration() { + const {_} = useLingui() + const {track} = useAnalytics() + const {state, dispatch} = React.useContext(Context) + const {data: preferences} = usePreferencesQuery() + + const onContinue = React.useCallback(() => { + dispatch({type: 'next'}) + track('OnboardingV2:StepModeration:End') + }, [track, dispatch]) + + React.useEffect(() => { + track('OnboardingV2:StepModeration:Start') + }, [track]) + + return ( + + + + + <Trans>You are in control</Trans> + + + + Select the types of content that you want to see (or not see), and + we'll handle the rest. + + + + {!preferences ? ( + + + + ) : ( + <> + + + + {configurableLabelGroups.map((g, index) => ( + + {index === 0 && } + + + + ))} + + + )} + + + + + + ) +} diff --git a/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx new file mode 100644 index 00000000..bc707ce8 --- /dev/null +++ b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx @@ -0,0 +1,188 @@ +import React from 'react' +import {View, ViewStyle} from 'react-native' +import {AppBskyActorDefs, moderateProfile} from '@atproto/api' + +import {useTheme, atoms as a, flatten} from '#/alf' +import {Text} from '#/components/Typography' +import {useItemContext} from '#/components/forms/Toggle' +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {useModerationOpts} from '#/state/queries/preferences' +import {RichText} from '#/components/RichText' + +export function SuggestedAccountCard({ + profile, + moderationOpts, +}: { + profile: AppBskyActorDefs.ProfileViewDetailed + moderationOpts: ReturnType +}) { + const t = useTheme() + const ctx = useItemContext() + const moderation = moderateProfile(profile, moderationOpts!) + + const styles = React.useMemo(() => { + const light = t.name === 'light' + const base: ViewStyle[] = [t.atoms.bg_contrast_50] + const hover: ViewStyle[] = [t.atoms.bg_contrast_25] + const selected: ViewStyle[] = [ + { + backgroundColor: light ? t.palette.primary_50 : t.palette.primary_950, + }, + ] + const selectedHover: ViewStyle[] = [ + { + backgroundColor: light ? t.palette.primary_25 : t.palette.primary_975, + }, + ] + const checkboxBase: ViewStyle[] = [t.atoms.bg] + const checkboxSelected: ViewStyle[] = [ + { + backgroundColor: t.palette.primary_500, + }, + ] + const avatarBase: ViewStyle[] = [t.atoms.bg_contrast_100] + const avatarSelected: ViewStyle[] = [ + { + backgroundColor: light ? t.palette.primary_100 : t.palette.primary_900, + }, + ] + + return { + base, + hover: flatten(hover), + selected: flatten(selected), + selectedHover: flatten(selectedHover), + checkboxBase: flatten(checkboxBase), + checkboxSelected: flatten(checkboxSelected), + avatarBase: flatten(avatarBase), + avatarSelected: flatten(avatarSelected), + } + }, [t]) + + return ( + + + + + + + + + {profile.displayName} + + {profile.handle} + + + + + {ctx.selected && } + + + + {profile.description && ( + <> + + + + + )} + + ) +} + +export function SuggestedAccountCardPlaceholder() { + const t = useTheme() + return ( + + + + + + + + + + ) +} diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx new file mode 100644 index 00000000..723e53a9 --- /dev/null +++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx @@ -0,0 +1,198 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyActorDefs} from '@atproto/api' +import {useLingui} from '@lingui/react' +import {msg, Trans} from '@lingui/macro' + +import {atoms as a, useBreakpoints} from '#/alf' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {Text} from '#/components/Typography' +import {useProfilesQuery} from '#/state/queries/profile' +import {Loader} from '#/components/Loader' +import * as Toggle from '#/components/forms/Toggle' +import {useModerationOpts} from '#/state/queries/preferences' +import {useAnalytics} from '#/lib/analytics/analytics' + +import {Context} from '#/screens/Onboarding/state' +import { + Title, + Description, + OnboardingControls, +} from '#/screens/Onboarding/Layout' +import { + SuggestedAccountCard, + SuggestedAccountCardPlaceholder, +} from '#/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard' +import {INTEREST_TO_DISPLAY_NAME} from '#/screens/Onboarding/StepInterests/data' +import {aggregateInterestItems} from '#/screens/Onboarding/util' +import {IconCircle} from '#/screens/Onboarding/IconCircle' + +export function Inner({ + profiles, + onSelect, + moderationOpts, +}: { + profiles: AppBskyActorDefs.ProfileViewDetailed[] + onSelect: (dids: string[]) => void + moderationOpts: ReturnType +}) { + const {_} = useLingui() + const [dids, setDids] = React.useState(profiles.map(p => p.did)) + + React.useEffect(() => { + onSelect(dids) + }, [dids, onSelect]) + + return ( + + + {profiles.map(profile => ( + + + + ))} + + + ) +} + +export function StepSuggestedAccounts() { + const {_} = useLingui() + const {track} = useAnalytics() + const {state, dispatch} = React.useContext(Context) + const {gtMobile} = useBreakpoints() + const suggestedDids = React.useMemo(() => { + return aggregateInterestItems( + state.interestsStepResults.selectedInterests, + state.interestsStepResults.apiResponse.suggestedAccountDids, + state.interestsStepResults.apiResponse.suggestedAccountDids.default, + ) + }, [state.interestsStepResults]) + const moderationOpts = useModerationOpts() + const { + isLoading: isProfilesLoading, + isError, + data, + error, + } = useProfilesQuery({ + handles: suggestedDids, + }) + const [dids, setDids] = React.useState([]) + const [saving, setSaving] = React.useState(false) + + const interestsText = React.useMemo(() => { + const i = state.interestsStepResults.selectedInterests.map( + i => INTEREST_TO_DISPLAY_NAME[i], + ) + return i.join(', ') + }, [state.interestsStepResults.selectedInterests]) + + const handleContinue = React.useCallback(async () => { + setSaving(true) + + if (dids.length) { + dispatch({type: 'setSuggestedAccountsStepResults', accountDids: dids}) + } + + setSaving(false) + dispatch({type: 'next'}) + track('OnboardingV2:StepSuggestedAccounts:Start', { + selectedAccountsLength: dids.length, + }) + }, [dids, setSaving, dispatch, track]) + + const handleSkip = React.useCallback(() => { + // if a user comes back and clicks skip, erase follows + dispatch({type: 'setSuggestedAccountsStepResults', accountDids: []}) + dispatch({type: 'next'}) + }, [dispatch]) + + const isLoading = isProfilesLoading && moderationOpts + + React.useEffect(() => { + track('OnboardingV2:StepSuggestedAccounts:Start') + }, [track]) + + return ( + + + + + <Trans>Here are some accounts for your to follow</Trans> + + + {state.interestsStepResults.selectedInterests.length ? ( + Based on your interest in {interestsText} + ) : ( + These are popular accounts you might like. + )} + + + + {isLoading ? ( + + {Array(10) + .fill(0) + .map((_, i) => ( + + ))} + + ) : isError || !data ? ( + {error?.toString()} + ) : ( + + )} + + + + + + + + + + ) +} diff --git a/src/screens/Onboarding/StepTopicalFeeds.tsx b/src/screens/Onboarding/StepTopicalFeeds.tsx new file mode 100644 index 00000000..516c18e6 --- /dev/null +++ b/src/screens/Onboarding/StepTopicalFeeds.tsx @@ -0,0 +1,113 @@ +import React from 'react' +import {View} from 'react-native' +import {useLingui} from '@lingui/react' +import {msg, Trans} from '@lingui/macro' + +import {atoms as a} from '#/alf' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' +import {ListMagnifyingGlass_Stroke2_Corner0_Rounded as ListMagnifyingGlass} from '#/components/icons/ListMagnifyingGlass' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Toggle from '#/components/forms/Toggle' +import {Loader} from '#/components/Loader' +import {useAnalytics} from '#/lib/analytics/analytics' + +import {Context} from '#/screens/Onboarding/state' +import { + Title, + Description, + OnboardingControls, +} from '#/screens/Onboarding/Layout' +import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard' +import {INTEREST_TO_DISPLAY_NAME} from '#/screens/Onboarding/StepInterests/data' +import {aggregateInterestItems} from '#/screens/Onboarding/util' +import {IconCircle} from '#/screens/Onboarding/IconCircle' + +export function StepTopicalFeeds() { + const {_} = useLingui() + const {track} = useAnalytics() + const {state, dispatch} = React.useContext(Context) + const [selectedFeedUris, setSelectedFeedUris] = React.useState([]) + const [saving, setSaving] = React.useState(false) + const suggestedFeedUris = React.useMemo(() => { + return aggregateInterestItems( + state.interestsStepResults.selectedInterests, + state.interestsStepResults.apiResponse.suggestedFeedUris, + state.interestsStepResults.apiResponse.suggestedFeedUris.default, + ).slice(0, 10) + }, [state.interestsStepResults]) + + const interestsText = React.useMemo(() => { + const i = state.interestsStepResults.selectedInterests.map( + i => INTEREST_TO_DISPLAY_NAME[i], + ) + return i.join(', ') + }, [state.interestsStepResults.selectedInterests]) + + const saveFeeds = React.useCallback(async () => { + setSaving(true) + + dispatch({type: 'setTopicalFeedsStepResults', feedUris: selectedFeedUris}) + + setSaving(false) + dispatch({type: 'next'}) + track('OnboardingV2:StepTopicalFeeds:End', { + selectedFeeds: selectedFeedUris, + selectedFeedsLength: selectedFeedUris.length, + }) + }, [selectedFeedUris, dispatch, track]) + + React.useEffect(() => { + track('OnboardingV2:StepTopicalFeeds:Start') + }, [track]) + + return ( + + + + + <Trans>Feeds can be topical as well!</Trans> + + + {state.interestsStepResults.selectedInterests.length ? ( + + Here are some topical feeds based on your interests: {interestsText} + . You can choose to follow as many as you like. + + ) : ( + + Here are some popular topical feeds. You can choose to follow as + many as you like. + + )} + + + + + + {suggestedFeedUris.map(uri => ( + + ))} + + + + + + + + + ) +} diff --git a/src/screens/Onboarding/index.tsx b/src/screens/Onboarding/index.tsx new file mode 100644 index 00000000..a4eb0401 --- /dev/null +++ b/src/screens/Onboarding/index.tsx @@ -0,0 +1,38 @@ +import React from 'react' + +import {Portal} from '#/components/Portal' + +import {Context, initialState, reducer} from '#/screens/Onboarding/state' +import {Layout, OnboardingControls} from '#/screens/Onboarding/Layout' +import {StepInterests} from '#/screens/Onboarding/StepInterests' +import {StepSuggestedAccounts} from '#/screens/Onboarding/StepSuggestedAccounts' +import {StepFollowingFeed} from '#/screens/Onboarding/StepFollowingFeed' +import {StepAlgoFeeds} from '#/screens/Onboarding/StepAlgoFeeds' +import {StepTopicalFeeds} from '#/screens/Onboarding/StepTopicalFeeds' +import {StepFinished} from '#/screens/Onboarding/StepFinished' +import {StepModeration} from '#/screens/Onboarding/StepModeration' + +export function Onboarding() { + const [state, dispatch] = React.useReducer(reducer, {...initialState}) + + return ( + + + ({state, dispatch}), [state, dispatch])}> + + {state.activeStep === 'interests' && } + {state.activeStep === 'suggestedAccounts' && ( + + )} + {state.activeStep === 'followingFeed' && } + {state.activeStep === 'algoFeeds' && } + {state.activeStep === 'topicalFeeds' && } + {state.activeStep === 'moderation' && } + {state.activeStep === 'finished' && } + + + + + ) +} diff --git a/src/screens/Onboarding/state.ts b/src/screens/Onboarding/state.ts new file mode 100644 index 00000000..164c2f5f --- /dev/null +++ b/src/screens/Onboarding/state.ts @@ -0,0 +1,201 @@ +import React from 'react' + +import {ApiResponseMap} from '#/screens/Onboarding/StepInterests/data' +import {logger} from '#/logger' + +export type OnboardingState = { + hasPrev: boolean + totalSteps: number + activeStep: + | 'interests' + | 'suggestedAccounts' + | 'followingFeed' + | 'algoFeeds' + | 'topicalFeeds' + | 'moderation' + | 'finished' + activeStepIndex: number + + interestsStepResults: { + selectedInterests: string[] + apiResponse: ApiResponseMap + } + suggestedAccountsStepResults: { + accountDids: string[] + } + algoFeedsStepResults: { + feedUris: string[] + } + topicalFeedsStepResults: { + feedUris: string[] + } +} + +export type OnboardingAction = + | { + type: 'next' + } + | { + type: 'prev' + } + | { + type: 'finish' + } + | { + type: 'setInterestsStepResults' + selectedInterests: string[] + apiResponse: ApiResponseMap + } + | { + type: 'setSuggestedAccountsStepResults' + accountDids: string[] + } + | { + type: 'setAlgoFeedsStepResults' + feedUris: string[] + } + | { + type: 'setTopicalFeedsStepResults' + feedUris: string[] + } + +export const initialState: OnboardingState = { + hasPrev: false, + totalSteps: 7, + activeStep: 'interests', + activeStepIndex: 1, + + interestsStepResults: { + selectedInterests: [], + apiResponse: { + interests: [], + suggestedAccountDids: {}, + suggestedFeedUris: {}, + }, + }, + suggestedAccountsStepResults: { + accountDids: [], + }, + algoFeedsStepResults: { + feedUris: [], + }, + topicalFeedsStepResults: { + feedUris: [], + }, +} + +export const Context = React.createContext<{ + state: OnboardingState + dispatch: React.Dispatch +}>({ + state: {...initialState}, + dispatch: () => {}, +}) + +export function reducer( + s: OnboardingState, + a: OnboardingAction, +): OnboardingState { + let next = {...s} + + switch (a.type) { + case 'next': { + if (s.activeStep === 'interests') { + next.activeStep = 'suggestedAccounts' + next.activeStepIndex = 2 + } else if (s.activeStep === 'suggestedAccounts') { + next.activeStep = 'followingFeed' + next.activeStepIndex = 3 + } else if (s.activeStep === 'followingFeed') { + next.activeStep = 'algoFeeds' + next.activeStepIndex = 4 + } else if (s.activeStep === 'algoFeeds') { + next.activeStep = 'topicalFeeds' + next.activeStepIndex = 5 + } else if (s.activeStep === 'topicalFeeds') { + next.activeStep = 'moderation' + next.activeStepIndex = 6 + } else if (s.activeStep === 'moderation') { + next.activeStep = 'finished' + next.activeStepIndex = 7 + } + break + } + case 'prev': { + if (s.activeStep === 'suggestedAccounts') { + next.activeStep = 'interests' + next.activeStepIndex = 1 + } else if (s.activeStep === 'followingFeed') { + next.activeStep = 'suggestedAccounts' + next.activeStepIndex = 2 + } else if (s.activeStep === 'algoFeeds') { + next.activeStep = 'followingFeed' + next.activeStepIndex = 3 + } else if (s.activeStep === 'topicalFeeds') { + next.activeStep = 'algoFeeds' + next.activeStepIndex = 4 + } else if (s.activeStep === 'moderation') { + next.activeStep = 'topicalFeeds' + next.activeStepIndex = 5 + } else if (s.activeStep === 'finished') { + next.activeStep = 'moderation' + next.activeStepIndex = 6 + } + break + } + case 'finish': { + next = initialState + break + } + case 'setInterestsStepResults': { + next.interestsStepResults = { + selectedInterests: a.selectedInterests, + apiResponse: a.apiResponse, + } + break + } + case 'setSuggestedAccountsStepResults': { + next.suggestedAccountsStepResults = { + accountDids: next.suggestedAccountsStepResults.accountDids.concat( + a.accountDids, + ), + } + break + } + case 'setAlgoFeedsStepResults': { + next.algoFeedsStepResults = { + feedUris: a.feedUris, + } + break + } + case 'setTopicalFeedsStepResults': { + next.topicalFeedsStepResults = { + feedUris: next.topicalFeedsStepResults.feedUris.concat(a.feedUris), + } + break + } + } + + const state = { + ...next, + hasPrev: next.activeStep !== 'interests', + } + + logger.debug(`onboarding`, { + hasPrev: state.hasPrev, + activeStep: state.activeStep, + activeStepIndex: state.activeStepIndex, + interestsStepResults: { + selectedInterests: state.interestsStepResults.selectedInterests, + }, + suggestedAccountsStepResults: state.suggestedAccountsStepResults, + algoFeedsStepResults: state.algoFeedsStepResults, + topicalFeedsStepResults: state.topicalFeedsStepResults, + }) + + if (s.activeStep !== state.activeStep) { + logger.info(`onboarding: step changed`, {activeStep: state.activeStep}) + } + + return state +} diff --git a/src/screens/Onboarding/util.ts b/src/screens/Onboarding/util.ts new file mode 100644 index 00000000..2a709a67 --- /dev/null +++ b/src/screens/Onboarding/util.ts @@ -0,0 +1,112 @@ +import {AppBskyGraphFollow, AppBskyGraphGetFollows} from '@atproto/api' + +import {until} from '#/lib/async/until' +import {getAgent} from '#/state/session' + +function shuffle(array: any) { + let currentIndex = array.length, + randomIndex + + // While there remain elements to shuffle. + while (currentIndex > 0) { + // Pick a remaining element. + randomIndex = Math.floor(Math.random() * currentIndex) + currentIndex-- + + // And swap it with the current element. + ;[array[currentIndex], array[randomIndex]] = [ + array[randomIndex], + array[currentIndex], + ] + } + + return array +} + +export function aggregateInterestItems( + interests: string[], + map: {[key: string]: string[]}, + fallbackItems: string[], +) { + const selected = interests.length + const all = interests + .map(i => { + const suggestions = shuffle(map[i]) + + if (selected === 1) { + return suggestions // return all + } else if (selected === 2) { + return suggestions.slice(0, 5) // return 5 + } else { + return suggestions.slice(0, 3) // return 3 + } + }) + .flat() + // dedupe suggestions + const results = Array.from(new Set(all)) + + // backfill + if (results.length < 20) { + results.push(...shuffle(fallbackItems)) + } + + // dedupe and return 20 + return Array.from(new Set(results)).slice(0, 20) +} + +export async function bulkWriteFollows(dids: string[]) { + const session = getAgent().session + + if (!session) { + throw new Error(`bulkWriteFollows failed: no session`) + } + + const followRecords: AppBskyGraphFollow.Record[] = dids.map(did => { + return { + $type: 'app.bsky.graph.follow', + subject: did, + createdAt: new Date().toISOString(), + } + }) + const followWrites = followRecords.map(r => ({ + $type: 'com.atproto.repo.applyWrites#create', + collection: 'app.bsky.graph.follow', + value: r, + })) + + await getAgent().com.atproto.repo.applyWrites({ + repo: session.did, + writes: followWrites, + }) + await whenFollowsIndexed(session.did, res => !!res.data.follows.length) +} + +async function whenFollowsIndexed( + actor: string, + fn: (res: AppBskyGraphGetFollows.Response) => boolean, +) { + await until( + 5, // 5 tries + 1e3, // 1s delay between tries + fn, + () => + getAgent().app.bsky.graph.getFollows({ + actor, + limit: 1, + }), + ) +} + +/** + * Kinda hacky, but we want For Your or Discover to appear as the first pinned + * feed after Following + */ +export function sortPrimaryAlgorithmFeeds(uris: string[]) { + return uris.sort(uri => { + return uri.includes('the-algorithm') + ? -1 + : uri.includes('whats-hot') + ? 0 + : 1 + }) +} diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts index b7f9206e..2d9d0299 100644 --- a/src/state/queries/preferences/const.ts +++ b/src/state/queries/preferences/const.ts @@ -48,4 +48,5 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = { feedViewPrefs: DEFAULT_HOME_FEED_PREFS, threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS, userAge: 13, // TODO(pwi) + interests: {tags: []}, } diff --git a/src/state/queries/preferences/types.ts b/src/state/queries/preferences/types.ts index 5fca8d55..cd9a2e8f 100644 --- a/src/state/queries/preferences/types.ts +++ b/src/state/queries/preferences/types.ts @@ -5,14 +5,17 @@ import { BskyFeedViewPreference, } from '@atproto/api' -export type ConfigurableLabelGroup = - | 'nsfw' - | 'nudity' - | 'suggestive' - | 'gore' - | 'hate' - | 'spam' - | 'impersonation' +export const configurableLabelGroups = [ + 'nsfw', + 'nudity', + 'suggestive', + 'gore', + 'hate', + 'spam', + 'impersonation', +] as const +export type ConfigurableLabelGroup = (typeof configurableLabelGroups)[number] + export type LabelGroup = | ConfigurableLabelGroup | 'illegal' diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 43426918..e6203550 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -24,6 +24,7 @@ import {STALE} from '#/state/queries' import {track} from '#/lib/analytics/analytics' export const RQKEY = (did: string) => ['profile', did] +export const profilesQueryKey = (handles: string[]) => ['profiles', handles] export function useProfileQuery({did}: {did: string | undefined}) { const {currentAccount} = useSession() @@ -45,6 +46,17 @@ export function useProfileQuery({did}: {did: string | undefined}) { }) } +export function useProfilesQuery({handles}: {handles: string[]}) { + return useQuery({ + staleTime: STALE.MINUTES.FIVE, + queryKey: profilesQueryKey(handles), + queryFn: async () => { + const res = await getAgent().getProfiles({actors: handles}) + return res.data + }, + }) +} + interface ProfileUpdateParams { profile: AppBskyActorDefs.ProfileView updates: diff --git a/src/view/screens/Storybook/Buttons.tsx b/src/view/screens/Storybook/Buttons.tsx index fbdc84eb..320db13f 100644 --- a/src/view/screens/Storybook/Buttons.tsx +++ b/src/view/screens/Storybook/Buttons.tsx @@ -11,6 +11,7 @@ import { } from '#/components/Button' import {H1} from '#/components/Typography' import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight' +import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' export function Buttons() { @@ -91,14 +92,16 @@ export function Buttons() { )} + + + + + + + + + + + + + + + + + + diff --git a/src/view/screens/Storybook/Forms.tsx b/src/view/screens/Storybook/Forms.tsx index 9396cca6..2d5495d7 100644 --- a/src/view/screens/Storybook/Forms.tsx +++ b/src/view/screens/Storybook/Forms.tsx @@ -209,6 +209,23 @@ export function Forms() { Show + + + + + Hide + + + Warn + + + Show + + + ) diff --git a/src/view/screens/Storybook/Links.tsx b/src/view/screens/Storybook/Links.tsx index c3b1c0e0..245a61a0 100644 --- a/src/view/screens/Storybook/Links.tsx +++ b/src/view/screens/Storybook/Links.tsx @@ -1,38 +1,39 @@ import React from 'react' import {View} from 'react-native' -import {atoms as a} from '#/alf' +import {useTheme, atoms as a} from '#/alf' import {ButtonText} from '#/components/Button' -import {Link} from '#/components/Link' -import {H1, H3} from '#/components/Typography' +import {InlineLink, Link} from '#/components/Link' +import {H1, H3, Text} from '#/components/Typography' export function Links() { + const t = useTheme() return (

Links

- External - - + +

External with custom children

- - + https://blueskyweb.xyz - - + Internal - + Link as a button + + + + + View @bsky.app's profile + +
) diff --git a/src/view/screens/Storybook/Typography.tsx b/src/view/screens/Storybook/Typography.tsx index 2e1f04a6..ecd6ec88 100644 --- a/src/view/screens/Storybook/Typography.tsx +++ b/src/view/screens/Storybook/Typography.tsx @@ -3,6 +3,7 @@ import {View} from 'react-native' import {atoms as a} from '#/alf' import {Text, H1, H2, H3, H4, H5, H6, P} from '#/components/Typography' +import {RichText} from '#/components/RichText' export function Typography() { return ( @@ -25,6 +26,16 @@ export function Typography() { atoms.text_sm atoms.text_xs atoms.text_2xs + + +
) } diff --git a/src/view/shell/createNativeStackNavigatorWithAuth.tsx b/src/view/shell/createNativeStackNavigatorWithAuth.tsx index 7e275502..0f240ea0 100644 --- a/src/view/shell/createNativeStackNavigatorWithAuth.tsx +++ b/src/view/shell/createNativeStackNavigatorWithAuth.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import {View} from 'react-native' -import {PWI_ENABLED} from '#/lib/build-flags' +import {PWI_ENABLED, NEW_ONBOARDING_ENABLED} from '#/lib/build-flags' // Based on @react-navigation/native-stack/src/createNativeStackNavigator.ts // MIT License @@ -38,6 +38,7 @@ import {isWeb} from 'platform/detection' import {Deactivated} from '#/screens/Deactivated' import {LoggedOut} from '../com/auth/LoggedOut' import {Onboarding} from '../com/auth/Onboarding' +import {Onboarding as NewOnboarding} from '#/screens/Onboarding' type NativeStackNavigationOptionsWithAuth = NativeStackNavigationOptions & { requireAuth?: boolean @@ -111,7 +112,11 @@ function NativeStackNavigator({ return setShowLoggedOut(false)} /> } if (onboardingState.isActive) { - return + if (NEW_ONBOARDING_ENABLED) { + return + } else { + return + } } const newDescriptors: typeof descriptors = {} for (let key in descriptors) { diff --git a/web/index.html b/web/index.html index 92001e71..7dd0e1a7 100644 --- a/web/index.html +++ b/web/index.html @@ -40,26 +40,7 @@ /* Prevent text size change on orientation change https://gist.github.com/tfausak/2222823#file-ios-8-web-app-html-L138 */ -webkit-text-size-adjust: 100%; height: calc(100% + env(safe-area-inset-top)); - scrollbar-gutter: stable; - } - - /* Remove autofill styles on Webkit */ - input:-webkit-autofill, - input:-webkit-autofill:hover, - input:-webkit-autofill:focus, - textarea:-webkit-autofill, - textarea:-webkit-autofill:hover, - textarea:-webkit-autofill:focus, - select:-webkit-autofill, - select:-webkit-autofill:hover, - select:-webkit-autofill:focus { - border: 0; - -webkit-text-fill-color: transparent; - -webkit-box-shadow: none; - } - /* Force left-align date/time inputs on iOS mobile */ - input::-webkit-date-and-time-value { - text-align: left; + scrollbar-gutter: stable both-edges; } /* Color theming */ @@ -71,7 +52,7 @@ html.colorMode--dark { --text: white; --background: hsl(211, 20%, 4%); - --backgroundLight: hsl(211, 20%, 10%); + --backgroundLight: hsl(211, 20%, 20%); color-scheme: dark; } @media (prefers-color-scheme: light) { @@ -85,11 +66,33 @@ html.colorMode--system { --text: white; --background: hsl(211, 20%, 4%); - --backgroundLight: hsl(211, 20%, 10%); + --backgroundLight: hsl(211, 20%, 20%); color-scheme: dark; } } + ::selection { + background-color: var(--backgroundLight); + } + + /* Remove autofill styles on Webkit */ + input:autofill, + input:-webkit-autofill, + input:-webkit-autofill:hover, + input:-webkit-autofill:focus, + input:-webkit-autofill:active{ + -webkit-background-clip: text; + -webkit-text-fill-color: var(--text); + transition: background-color 5000s ease-in-out 0s; + box-shadow: inset 0 0 20px 20px var(--background); + background: var(--background); + color: var(--text); + } + /* Force left-align date/time inputs on iOS mobile */ + input::-webkit-date-and-time-value { + text-align: left; + } + body { display: flex; /* Allows you to scroll below the viewport; default value is visible */