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/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx
index ad82bdff5f..fe919132c7 100644
--- a/src/components/forms/Toggle.tsx
+++ b/src/components/forms/Toggle.tsx
@@ -125,6 +125,7 @@ export function Group({
return (
{
- setSaving(true)
- await new Promise(y => setTimeout(y, 1000))
- setSaving(false)
- dispatch({type: 'next'})
- }, [setSaving, dispatch])
-
- return (
-
-
-
-
-
- What are your interests?
-
- We'll use this to help customize your experience.
-
-
-
-
-
-
- )
-}
diff --git a/src/screens/Onboarding/StepInterests/InterestButton.tsx b/src/screens/Onboarding/StepInterests/InterestButton.tsx
new file mode 100644
index 0000000000..e75a5addcd
--- /dev/null
+++ b/src/screens/Onboarding/StepInterests/InterestButton.tsx
@@ -0,0 +1,78 @@
+import React from 'react'
+import {View, ViewStyle, TextStyle} from 'react-native'
+
+import {useTheme, atoms as a} from '#/alf'
+import * as Toggle from '#/components/forms/Toggle'
+import {Text} from '#/components/Typography'
+
+import {InterestItem} from '#/screens/Onboarding/StepInterests/data'
+
+export function InterestButton({interest}: {interest: InterestItem}) {
+ 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.title}
+
+
+ )
+}
diff --git a/src/screens/Onboarding/StepInterests/data.ts b/src/screens/Onboarding/StepInterests/data.ts
new file mode 100644
index 0000000000..c996fbdea7
--- /dev/null
+++ b/src/screens/Onboarding/StepInterests/data.ts
@@ -0,0 +1,139 @@
+export type InterestItem = {
+ title: string
+ name: string
+ tags: string[]
+}
+
+export const INTERESTS: InterestItem[] = [
+ {
+ title: 'News',
+ name: 'news',
+ tags: [],
+ },
+ {
+ title: 'Journalism',
+ name: 'journalism',
+ tags: [],
+ },
+ {
+ title: 'Writers',
+ name: 'writers',
+ tags: [],
+ },
+ {
+ title: 'Culture',
+ name: 'culture',
+ tags: [],
+ },
+ {
+ title: 'Books',
+ name: 'books',
+ tags: [],
+ },
+ {
+ title: 'Sports',
+ name: 'sports',
+ tags: [],
+ },
+ {
+ title: 'Comedy',
+ name: 'comedy',
+ tags: [],
+ },
+ {
+ title: 'Pets',
+ name: 'pets',
+ tags: [],
+ },
+ {
+ title: 'Nature',
+ name: 'nature',
+ tags: [],
+ },
+ {
+ title: 'Art',
+ name: 'art',
+ tags: [],
+ },
+ {
+ title: 'Comics',
+ name: 'comics',
+ tags: [],
+ },
+ {
+ title: 'Education',
+ name: 'education',
+ tags: [],
+ },
+ {
+ title: 'Climate',
+ name: 'climate',
+ tags: [],
+ },
+ {
+ title: 'Science',
+ name: 'science',
+ tags: [],
+ },
+ {
+ title: 'Politics',
+ name: 'politics',
+ tags: [],
+ },
+ {
+ title: 'Fitness',
+ name: 'fitness',
+ tags: [],
+ },
+ {
+ title: 'Health',
+ name: 'health',
+ tags: [],
+ },
+ {
+ title: 'Tech & Society',
+ name: 'tech_and_society',
+ tags: [],
+ },
+ {
+ title: 'Software Dev',
+ name: 'software_development',
+ tags: [],
+ },
+ {
+ title: 'Video Games',
+ name: 'video_games',
+ tags: [],
+ },
+ {
+ title: 'Food & Cooking',
+ name: 'food_and_cooking',
+ tags: [],
+ },
+]
+
+export const TEMP_ACCOUNT_MAPPING: {
+ [key: string]: string[]
+} = {
+ news: ['bsky.app', 'jay.bsky.team'],
+ journalism: ['bsky.app', 'jay.bsky.team'],
+ writers: ['bsky.app', 'jay.bsky.team'],
+ culture: ['bsky.app', 'jay.bsky.team'],
+ books: ['bsky.app', 'jay.bsky.team'],
+ sports: ['bsky.app', 'jay.bsky.team'],
+ comedy: ['bsky.app', 'jay.bsky.team'],
+ pets: ['bsky.app', 'jay.bsky.team'],
+ nature: ['bsky.app', 'jay.bsky.team'],
+ art: ['bsky.app', 'jay.bsky.team'],
+ comics: ['bsky.app', 'jay.bsky.team'],
+ education: ['bsky.app', 'jay.bsky.team'],
+ climate: ['bsky.app', 'jay.bsky.team'],
+ science: ['bsky.app', 'jay.bsky.team'],
+ politics: ['bsky.app', 'jay.bsky.team'],
+ fitness: ['bsky.app', 'jay.bsky.team'],
+ health: ['bsky.app', 'jay.bsky.team'],
+ tech_and_society: ['bsky.app', 'jay.bsky.team'],
+ software_development: ['bsky.app', 'jay.bsky.team'],
+ video_games: ['bsky.app', 'jay.bsky.team'],
+ food_and_cooking: ['bsky.app', 'jay.bsky.team'],
+}
diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx
new file mode 100644
index 0000000000..a7b2638c2a
--- /dev/null
+++ b/src/screens/Onboarding/StepInterests/index.tsx
@@ -0,0 +1,116 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useQueryClient} from '@tanstack/react-query'
+
+import {logger} from '#/logger'
+import {useTheme, atoms as a} from '#/alf'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {Loader} from '#/components/Loader'
+import * as Toggle from '#/components/forms/Toggle'
+import {profilesQueryKey} from '#/state/queries/profile'
+
+import {Context} from '#/screens/Onboarding/state'
+import {
+ Title,
+ Description,
+ OnboardingControls,
+} from '#/screens/Onboarding/Layout'
+import {
+ INTERESTS as INTEREST_OPTIONS,
+ TEMP_ACCOUNT_MAPPING,
+} from '#/screens/Onboarding/StepInterests/data'
+import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton'
+
+export function StepInterests() {
+ const t = useTheme()
+ const query = useQueryClient()
+ const {state, dispatch} = React.useContext(Context)
+ const [saving, setSaving] = React.useState(false)
+ const [interests, setInterests] = React.useState([])
+
+ const saveInterests = React.useCallback(async () => {
+ setSaving(true)
+
+ try {
+ const {
+ data: {accounts},
+ } = {data: {accounts: interests.map(i => TEMP_ACCOUNT_MAPPING[i]).flat()}}
+ const suggestedAccounts = Array.from(new Set(accounts))
+
+ // wait max 2s for query to finish
+ await Promise.race([
+ await query.prefetchQuery({
+ queryKey: profilesQueryKey(suggestedAccounts),
+ }),
+ await new Promise(y => setTimeout(y, 2000)),
+ ])
+
+ // done
+ setSaving(false)
+ dispatch({
+ type: 'setSuggestedAccountHandles',
+ suggestedAccountHandles: suggestedAccounts,
+ })
+ dispatch({type: 'next'})
+ } catch (e: any) {
+ logger.info(`onboading: error saving interests`)
+ logger.error(e)
+ }
+ }, [interests, setSaving, dispatch, query])
+
+ return (
+
+
+
+
+
+ What are your interests?
+
+ We'll use this to help customize your experience.
+
+
+
+
+
+ {INTEREST_OPTIONS.map(interest => (
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx
new file mode 100644
index 0000000000..c99ed00461
--- /dev/null
+++ b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx
@@ -0,0 +1,110 @@
+import React from 'react'
+import {View, ViewStyle} from 'react-native'
+import {AppBskyActorDefs} 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'
+
+export function SuggestedAccountCard({
+ profile,
+}: {
+ profile: AppBskyActorDefs.ProfileViewDetailed
+}) {
+ const t = useTheme()
+ const ctx = useItemContext()
+
+ const styles = React.useMemo(() => {
+ const base: ViewStyle[] = [t.atoms.bg_contrast_25]
+ const hover: ViewStyle[] = [t.atoms.bg_contrast_50]
+ const selected: ViewStyle[] = [
+ {
+ backgroundColor: t.palette.primary_975,
+ },
+ ]
+ const selectedHover: ViewStyle[] = [
+ {
+ backgroundColor: t.palette.primary_950,
+ },
+ ]
+ const checkboxBase: ViewStyle[] = [t.atoms.bg_contrast_25]
+ const checkboxSelected: ViewStyle[] = [
+ {
+ backgroundColor: t.palette.primary_500,
+ },
+ ]
+ const avatarBase: ViewStyle[] = [t.atoms.bg_contrast_100]
+ const avatarSelected: ViewStyle[] = [
+ {
+ backgroundColor: 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 && }
+
+
+
+ )
+}
diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
new file mode 100644
index 0000000000..ad50cdab95
--- /dev/null
+++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
@@ -0,0 +1,107 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyActorDefs} from '@atproto/api'
+
+import {useTheme, 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 {Context} from '#/screens/Onboarding/state'
+import {
+ Title,
+ Description,
+ OnboardingControls,
+} from '#/screens/Onboarding/Layout'
+import {SuggestedAccountCard} from '#/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard'
+
+export function Inner({
+ profiles,
+}: {
+ profiles: AppBskyActorDefs.ProfileViewDetailed[]
+}) {
+ const [dids, setDids] = React.useState(profiles.map(p => p.did))
+
+ return (
+
+ {profiles.map(profile => (
+
+
+
+ ))}
+
+ )
+}
+
+export function StepSuggestedAccounts() {
+ const t = useTheme()
+ const {state, dispatch} = React.useContext(Context)
+ const {gtMobile} = useBreakpoints()
+ const {isLoading, isError, data} = useProfilesQuery({
+ handles: state.suggestedAccountHandles,
+ })
+
+ return (
+
+
+
+
+
+ Here are some accounts for your to follow:
+ Based on your interest in Pets and Books.
+
+
+ {isLoading ? (
+
+ ) : isError || !data ? (
+ Error
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/screens/Onboarding/StepSuggestedFollows.tsx b/src/screens/Onboarding/StepSuggestedFollows.tsx
deleted file mode 100644
index a41c39b9c4..0000000000
--- a/src/screens/Onboarding/StepSuggestedFollows.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import React from 'react'
-import {View} from 'react-native'
-
-import {atoms as a, useBreakpoints} from '#/alf'
-import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
-import {Button, ButtonIcon, ButtonText} from '#/components/Button'
-
-import {Context} from '#/screens/Onboarding/context'
-import {
- Title,
- Description,
- OnboardingControls,
-} from '#/screens/Onboarding/Layout'
-
-export function StepSuggestedFollows() {
- const {state, dispatch} = React.useContext(Context)
- const {gtMobile} = useBreakpoints()
-
- return (
-
- Here are some accounts for your to follow:
- Based on your interest in Pets and Books.
-
-
-
-
-
-
-
-
- )
-}
diff --git a/src/screens/Onboarding/StepTopicalFeeds.tsx b/src/screens/Onboarding/StepTopicalFeeds.tsx
index 0d63db94be..c34376a1b0 100644
--- a/src/screens/Onboarding/StepTopicalFeeds.tsx
+++ b/src/screens/Onboarding/StepTopicalFeeds.tsx
@@ -5,7 +5,7 @@ import {atoms as a} from '#/alf'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
-import {Context} from '#/screens/Onboarding/context'
+import {Context} from '#/screens/Onboarding/state'
import {
Title,
Description,
diff --git a/src/screens/Onboarding/context.ts b/src/screens/Onboarding/context.ts
deleted file mode 100644
index 51c2976e41..0000000000
--- a/src/screens/Onboarding/context.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import React from 'react'
-
-export type OnboardingState = {
- hasPrev: boolean
- totalSteps: number
- activeStep:
- | 'interests'
- | 'suggestedFollows'
- | 'followingFeed'
- | 'algoFeeds'
- | 'topicalFeeds'
- activeStepIndex: number
-}
-
-export type OnboardingAction =
- | {
- type: 'next'
- }
- | {
- type: 'prev'
- }
-
-export const initialState: OnboardingState = {
- hasPrev: false,
- totalSteps: 5,
- activeStep: 'interests',
- activeStepIndex: 1,
-}
-
-export const Context = React.createContext<{
- state: OnboardingState
- dispatch: React.Dispatch
-}>({
- state: initialState,
- dispatch: () => {},
-})
diff --git a/src/screens/Onboarding/index.tsx b/src/screens/Onboarding/index.tsx
index e3bd3bde87..46847241f4 100644
--- a/src/screens/Onboarding/index.tsx
+++ b/src/screens/Onboarding/index.tsx
@@ -1,69 +1,16 @@
import React from 'react'
-import {logger} from '#/logger'
import {Portal} from '#/components/Portal'
-import {
- Context,
- OnboardingState,
- OnboardingAction,
- initialState,
-} from '#/screens/Onboarding/context'
+import {Context, initialState, reducer} from '#/screens/Onboarding/state'
import {Layout, OnboardingControls} from '#/screens/Onboarding/Layout'
import {StepInterests} from '#/screens/Onboarding/StepInterests'
-import {StepSuggestedFollows} from '#/screens/Onboarding/StepSuggestedFollows'
+import {StepSuggestedAccounts} from '#/screens/Onboarding/StepSuggestedAccounts'
import {StepFollowingFeed} from '#/screens/Onboarding/StepFollowingFeed'
import {StepAlgoFeeds} from '#/screens/Onboarding/StepAlgoFeeds'
import {StepTopicalFeeds} from '#/screens/Onboarding/StepTopicalFeeds'
-function reducer(s: OnboardingState, a: OnboardingAction): OnboardingState {
- const next = s
-
- switch (a.type) {
- case 'next': {
- if (s.activeStep === 'interests') {
- next.activeStep = 'suggestedFollows'
- next.activeStepIndex = 2
- } else if (s.activeStep === 'suggestedFollows') {
- 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
- }
- break
- }
- case 'prev': {
- if (s.activeStep === 'suggestedFollows') {
- next.activeStep = 'interests'
- next.activeStepIndex = 1
- } else if (s.activeStep === 'followingFeed') {
- next.activeStep = 'suggestedFollows'
- 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
- }
- break
- }
- }
-
- const state = {
- ...next,
- hasPrev: next.activeStep !== 'interests',
- }
-
- logger.debug(`onboarding`, state)
-
- return state
-}
-
+// TODO lock focus?
export function Onboarding() {
const [state, dispatch] = React.useReducer(reducer, initialState)
@@ -74,8 +21,8 @@ export function Onboarding() {
value={React.useMemo(() => ({state, dispatch}), [state, dispatch])}>
{state.activeStep === 'interests' && }
- {state.activeStep === 'suggestedFollows' && (
-
+ {state.activeStep === 'suggestedAccounts' && (
+
)}
{state.activeStep === 'followingFeed' && }
{state.activeStep === 'algoFeeds' && }
diff --git a/src/screens/Onboarding/state.ts b/src/screens/Onboarding/state.ts
new file mode 100644
index 0000000000..55e17eda33
--- /dev/null
+++ b/src/screens/Onboarding/state.ts
@@ -0,0 +1,101 @@
+import React from 'react'
+
+import {logger} from '#/logger'
+
+export type OnboardingState = {
+ hasPrev: boolean
+ totalSteps: number
+ activeStep:
+ | 'interests'
+ | 'suggestedAccounts'
+ | 'followingFeed'
+ | 'algoFeeds'
+ | 'topicalFeeds'
+ activeStepIndex: number
+ suggestedAccountHandles: string[]
+}
+
+export type OnboardingAction =
+ | {
+ type: 'next'
+ }
+ | {
+ type: 'prev'
+ }
+ | {
+ type: 'setSuggestedAccountHandles'
+ suggestedAccountHandles: string[]
+ }
+
+export const initialState: OnboardingState = {
+ hasPrev: false,
+ totalSteps: 5,
+ activeStep: 'interests',
+ activeStepIndex: 1,
+
+ // result of interests step
+ suggestedAccountHandles: [],
+}
+
+export const Context = React.createContext<{
+ state: OnboardingState
+ dispatch: React.Dispatch
+}>({
+ state: initialState,
+ dispatch: () => {},
+})
+
+export function reducer(
+ s: OnboardingState,
+ a: OnboardingAction,
+): OnboardingState {
+ const 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
+ }
+ 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
+ }
+ break
+ }
+ case 'setSuggestedAccountHandles': {
+ next.suggestedAccountHandles = a.suggestedAccountHandles
+ break
+ }
+ }
+
+ const state = {
+ ...next,
+ hasPrev: next.activeStep !== 'interests',
+ }
+
+ logger.debug(`onboarding`, state)
+
+ return state
+}
diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts
index 21e2e5775b..f535041b9f 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: