state machine, flow screen

This commit is contained in:
Samuel Newman
2025-11-27 22:08:57 +02:00
parent a10b1c5903
commit 2c7ca5d9eb
9 changed files with 305 additions and 37 deletions
-2
View File
@@ -291,8 +291,6 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/notifications/reposts-on-reposts", server.WebGeneric)
e.GET("/settings/notifications/activity", server.WebGeneric)
e.GET("/settings/notifications/miscellaneous", server.WebGeneric)
e.GET("/settings/sync-contacts", server.WebGeneric)
e.GET("/settings/app-icon", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
e.GET("/sys/debug-mod", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric)
+9
View File
@@ -130,6 +130,7 @@ import {
StarterPackScreenShort,
} from '#/screens/StarterPack/StarterPackScreen'
import {Wizard} from '#/screens/StarterPack/Wizard'
import {SyncContactsFlowScreen} from '#/screens/SyncContactsFlowScreen'
import TopicScreen from '#/screens/Topic'
import {VideoFeed} from '#/screens/VideoFeed'
import {type Theme, useTheme} from '#/alf'
@@ -618,6 +619,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
requireAuth: true,
}}
/>
<Stack.Screen
name="SyncContactsFlow"
getComponent={() => SyncContactsFlowScreen}
options={{
title: title(msg`Sync Contacts`),
requireAuth: true,
}}
/>
</>
)
}
@@ -0,0 +1,21 @@
import {useState} from 'react'
import {ScreenTransition} from '#/components/ScreenTransition'
import {type Action, type State} from './state'
export function SyncContactsFlow({
state,
}: {
state: State
dispatch: React.Dispatch<Action>
}) {
const [transitionDirection, _setTransitionDirection] = useState<
'Forward' | 'Backward'
>('Forward')
return (
<ScreenTransition direction={transitionDirection} key={state.step}>
<></>
</ScreenTransition>
)
}
+148
View File
@@ -0,0 +1,148 @@
import {useReducer} from 'react'
import type * as bsky from '#/types/bsky'
export type Contact = {
/**
* Generate ourselves - should be random or sequential
*/
id: string
firstName?: string
lastName?: string
phone?: string[]
}
// TODO: replace with lexicon type
export type Match = {
index: number
profile: bsky.profile.AnyProfileView
}
export type State =
| {
step: '1: phone input'
phoneCode?: string
phone?: string
}
| {
step: '2: verify number'
phoneCode: string
phone: string
lastSentAt: Date
error?: string
}
| {
step: '3: get contacts'
contacts?: Contact[]
}
| {
step: '4: view matches'
contacts: Contact[]
matches: Match[]
}
export type Action =
| {
type: 'VERIFY_PHONE'
payload: {
phoneCode: string
phone: string
}
}
| {
type: 'VERIFY_OTP_ERROR'
payload: {
error: string
}
}
| {
type: 'VERIFY_OTP_SUCCESS'
}
| {
type: 'GET_CONTACTS_SUCCESS'
payload: {
contacts: Contact[]
}
}
| {
type: 'SYNC_CONTACTS_SUCCESS'
payload: {
matches: Match[]
}
}
| {
type: 'BACK'
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'VERIFY_PHONE': {
assertCurrentStep(state, '1: phone input')
return {
step: '2: verify number',
...action.payload,
lastSentAt: new Date(),
}
}
case 'VERIFY_OTP_ERROR': {
assertCurrentStep(state, '2: verify number')
return {
...state,
error: action.payload.error,
}
}
case 'VERIFY_OTP_SUCCESS': {
assertCurrentStep(state, '2: verify number')
return {
step: '3: get contacts',
}
}
case 'BACK': {
assertCurrentStep(state, '2: verify number')
return {
step: '1: phone input',
phone: state.phone,
phoneCode: state.phoneCode,
}
}
case 'GET_CONTACTS_SUCCESS': {
assertCurrentStep(state, '3: get contacts')
return {
...state,
contacts: action.payload.contacts,
}
}
case 'SYNC_CONTACTS_SUCCESS': {
assertCurrentStep(state, '3: get contacts')
return {
step: '4: view matches',
contacts: state.contacts ?? [],
matches: action.payload.matches,
}
}
}
}
class InvalidStateTransitionError extends Error {
constructor(message: string) {
super(message)
this.name = 'InvalidStateTransitionError'
}
}
function assertCurrentStep<S extends State['step']>(
state: State,
step: S,
): asserts state is Extract<State, {step: S}> {
if (state.step !== step) {
throw new InvalidStateTransitionError(
`Invalid state transition: expecting ${step}, got ${state.step}`,
)
}
}
export function useSyncContactsFlowState(
initialState: State = {step: '1: phone input'},
) {
return useReducer(reducer, initialState)
}
+1
View File
@@ -88,6 +88,7 @@ export type CommonNavigatorParams = {
StarterPackEdit: {rkey?: string}
VideoFeed: VideoFeedSourceContext
Bookmarks: undefined
SyncContactsFlow: undefined
}
export type BottomTabNavigatorParams = CommonNavigatorParams & {
+1
View File
@@ -92,4 +92,5 @@ export const router = new Router<AllNavigatableRoutes>({
StarterPackWizard: '/starter-pack/create',
VideoFeed: '/video-feed',
Bookmarks: '/saved',
SyncContactsFlow: '/sync-contacts',
})
+10 -8
View File
@@ -208,14 +208,16 @@ export function SettingsScreen({}: Props) {
<Trans>Content and media</Trans>
</SettingsList.ItemText>
</SettingsList.LinkItem>
<SettingsList.LinkItem
to="/settings/sync-contacts"
label={_(msg`Sync contacts`)}>
<SettingsList.ItemIcon icon={ContactsIcon} />
<SettingsList.ItemText>
<Trans>Sync contacts</Trans>
</SettingsList.ItemText>
</SettingsList.LinkItem>
{isNative && (
<SettingsList.LinkItem
to="/settings/sync-contacts"
label={_(msg`Sync contacts`)}>
<SettingsList.ItemIcon icon={ContactsIcon} />
<SettingsList.ItemText>
<Trans>Sync contacts</Trans>
</SettingsList.ItemText>
</SettingsList.LinkItem>
)}
<SettingsList.LinkItem
to="/settings/appearance"
label={_(msg`Appearance`)}>
+56 -27
View File
@@ -1,19 +1,24 @@
import {Trans} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
type AllNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {isNative} from '#/platform/detection'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {atoms as a, useTheme} from '#/alf'
import {ButtonText} from '#/components/Button'
import {Contacts_Stroke2_Corner2_Rounded as SyncContactsIcon} from '#/components/icons/Contacts'
import * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link'
import {InlineLinkText, Link} from '#/components/Link'
import {Text} from '#/components/Typography'
import * as SettingsList from './components/SettingsList'
type Props = NativeStackScreenProps<AllNavigatorParams, 'SyncContactsSettings'>
export function SyncContactsSettingsScreen({}: Props) {
const t = useTheme()
const {_} = useLingui()
return (
<Layout.Screen>
@@ -26,31 +31,55 @@ export function SyncContactsSettingsScreen({}: Props) {
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content>
<SettingsList.Container>
<SettingsList.Item>
<SettingsList.ItemIcon icon={SyncContactsIcon} />
<SettingsList.ItemText>
<Trans>Sync Contacts</Trans>
</SettingsList.ItemText>
</SettingsList.Item>
<SettingsList.Item style={[a.pt_0]}>
<Text
style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}>
<Trans>
Contacts from your address book will be uploaded to Bluesky on
an ongoing basis to help connect you with your friends and
personalize content, such as making suggestions for you and
others. Turning off syncing will not remove previously uploaded
contacts.{' '}
<InlineLinkText to="#" label="todo">
TODO: Add learn more link
</InlineLinkText>
</Trans>
</Text>
</SettingsList.Item>
</SettingsList.Container>
</Layout.Content>
{isNative ? (
<Layout.Content>
<SettingsList.Container>
<SettingsList.Item>
<SettingsList.ItemIcon icon={SyncContactsIcon} />
<SettingsList.ItemText>
<Trans>Sync Contacts</Trans>
</SettingsList.ItemText>
</SettingsList.Item>
<SettingsList.Item style={[a.pt_0]}>
<Text
style={[
a.text_sm,
t.atoms.text_contrast_medium,
a.leading_snug,
]}>
<Trans>
Contacts from your address book will be uploaded to Bluesky on
an ongoing basis to help connect you with your friends and
personalize content, such as making suggestions for you and
others. Turning off syncing will not remove previously
uploaded contacts.{' '}
<InlineLinkText to="#" label="todo">
TODO: Add learn more link
</InlineLinkText>
</Trans>
</Text>
</SettingsList.Item>
<SettingsList.Item>
<Link
to={{screen: 'SyncContactsFlow'}}
label={_(msg`Upload contacts`)}
size="large"
color="primary"
style={[a.flex_1, a.justify_center]}>
<ButtonText>
<Trans>Upload contacts</Trans>
</ButtonText>
</Link>
</SettingsList.Item>
</SettingsList.Container>
</Layout.Content>
) : (
<ErrorScreen
title={_(msg`Not available on this platform.`)}
message={_(msg`Please use the native app to sync your contacts.`)}
showHeader
/>
)}
</Layout.Screen>
)
}
+59
View File
@@ -0,0 +1,59 @@
import {useCallback} from 'react'
import {BackHandler} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
import {
type AllNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {isNative} from '#/platform/detection'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {useSyncContactsFlowState} from '#/components/contacts/state'
import {SyncContactsFlow} from '#/components/contacts/SyncContactsFlow'
import * as Layout from '#/components/Layout'
type Props = NativeStackScreenProps<AllNavigatorParams, 'SyncContactsFlow'>
export function SyncContactsFlowScreen({navigation}: Props) {
const {_} = useLingui()
const [state, dispatch] = useSyncContactsFlowState()
const overrideGoBack = state.step === '2: verify number'
useFocusEffect(
useCallback(() => {
if (overrideGoBack) {
navigation.setOptions({
gestureEnabled: false,
})
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
dispatch({type: 'BACK'})
return true
})
return () => {
navigation.setOptions({
gestureEnabled: true,
})
sub.remove()
}
}
}, [overrideGoBack, dispatch, navigation]),
)
return (
<Layout.Screen>
{isNative ? (
<SyncContactsFlow state={state} dispatch={dispatch} />
) : (
<ErrorScreen
title={_(msg`Not available on this platform.`)}
message={_(msg`Please use the native app to sync your contacts.`)}
showHeader
/>
)}
</Layout.Screen>
)
}