diff --git a/assets/icons/arrowRotateCounterClockwise_stroke2_corner0_rounded.svg b/assets/icons/arrowRotateCounterClockwise_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..955b3dbc3b
--- /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 0000000000..8d30d7c8c5
--- /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 0000000000..b336a518f5
--- /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 0000000000..d9a8660f7f
--- /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 0000000000..b57fd03986
--- /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 0000000000..926d4b3910
--- /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 0000000000..0a5a43cd0b
--- /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 0000000000..f11bdd937f
--- /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 0000000000..459b9212aa
--- /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 0000000000..ec9083fb1e
--- /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 0000000000..05b2353db8
--- /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 0000000000..1b1857efab
--- /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 0000000000..702e1895f5
--- /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 0000000000..9dbc013798
--- /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 0000000000..66e4c373a0
--- /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 0000000000..8d568437b5
--- /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 0000000000..cc806b0eb6
--- /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 228c3d8940..4fb3a20950 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 203c2f282a..bbf7e32431 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 69a8798539..06d6ebf01c 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 0e370cdc17..f3ae80275e 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 76ac05d40e..dd8d816d2b 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 7c682ac1a1..f88fbcbde2 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 8f686f3c4c..63b0c73f1f 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 1813d9e05e..d696f986b4 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 0000000000..068ee99e07
--- /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 66cf0720de..64aa6d1a4f 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 1ee58303ae..67515049c9 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 ad82bdff5f..d3c0342467 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 615fedae8b..5cd51d794a 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 0000000000..35cd23a975
--- /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 0000000000..2487250545
--- /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 0000000000..24316c7849
--- /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 0000000000..b1a9deea0b
--- /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 0000000000..cc3813bf3c
--- /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 0000000000..568cd71e69
--- /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 0000000000..a936a1c71a
--- /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 0000000000..ea11a429c8
--- /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 0000000000..ab5684a577
--- /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 0000000000..668ed92566
--- /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 0000000000..a897fe8531
--- /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 0000000000..4d472465d0
--- /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 0000000000..f2124e7b88
--- /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 0000000000..d0698f7f44
--- /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 0000000000..5fba4167b5
--- /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 c84f7979a4..54e143fa3f 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 cf05114e02..d651887fff 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 0000000000..a54c8b4e4e
--- /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 0000000000..50487c1899
--- /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 0000000000..c7f1e6e4d4
--- /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 0000000000..4920c5ad7b
--- /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 (
+
+
+
+
+ Choose your algorithmic feeds
+
+
+
+ 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 0000000000..02c45f5904
--- /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 (
+
+
+
+
+ You're ready to go!
+
+
+ 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 0000000000..4b3c628897
--- /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
+
+
+
+
+ Your default feed is "Following"
+
+
+ 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 0000000000..02413b18db
--- /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 0000000000..00a25331cf
--- /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 0000000000..6f60991d5c
--- /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 0000000000..bc4c0387f1
--- /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 0000000000..904c47299e
--- /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 0000000000..be605e4071
--- /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 (
+
+
+
+
+ You are in control
+
+
+
+ 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 0000000000..bc707ce8fb
--- /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 0000000000..723e53a98c
--- /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 (
+
+
+
+
+ Here are some accounts for your to follow
+
+
+ {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 0000000000..516c18e6ea
--- /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 (
+
+
+
+
+ Feeds can be topical as well!
+
+
+ {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 0000000000..a4eb040122
--- /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 0000000000..164c2f5f3c
--- /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 0000000000..2a709a67b6
--- /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 b7f9206e82..2d9d02994e 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 5fca8d5583..cd9a2e8f9f 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 434269183a..e6203550f3 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 fbdc84eb4b..320db13ff8 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 9396cca67b..2d5495d706 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 c3b1c0e0f3..245a61a05b 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 2e1f04a66e..ecd6ec888d 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 7e275502b5..0f240ea007 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 92001e7165..7dd0e1a729 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 */