state machine, flow screen

This commit is contained in:
Samuel Newman
2025-11-27 22:08:57 +02:00
parent ad1d7cdebd
commit 6473716ff0
9 changed files with 305 additions and 37 deletions
-2
View File
@@ -308,8 +308,6 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/notifications/reposts-on-reposts", server.WebGeneric) e.GET("/settings/notifications/reposts-on-reposts", server.WebGeneric)
e.GET("/settings/notifications/activity", server.WebGeneric) e.GET("/settings/notifications/activity", server.WebGeneric)
e.GET("/settings/notifications/miscellaneous", 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", server.WebGeneric)
e.GET("/sys/debug-mod", server.WebGeneric) e.GET("/sys/debug-mod", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric) e.GET("/sys/log", server.WebGeneric)
+9
View File
@@ -130,6 +130,7 @@ import {
StarterPackScreenShort, StarterPackScreenShort,
} from '#/screens/StarterPack/StarterPackScreen' } from '#/screens/StarterPack/StarterPackScreen'
import {Wizard} from '#/screens/StarterPack/Wizard' import {Wizard} from '#/screens/StarterPack/Wizard'
import {SyncContactsFlowScreen} from '#/screens/SyncContactsFlowScreen'
import TopicScreen from '#/screens/Topic' import TopicScreen from '#/screens/Topic'
import {VideoFeed} from '#/screens/VideoFeed' import {VideoFeed} from '#/screens/VideoFeed'
import {type Theme, useTheme} from '#/alf' import {type Theme, useTheme} from '#/alf'
@@ -618,6 +619,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
requireAuth: true, 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} StarterPackEdit: {rkey?: string}
VideoFeed: VideoFeedSourceContext VideoFeed: VideoFeedSourceContext
Bookmarks: undefined Bookmarks: undefined
SyncContactsFlow: undefined
} }
export type BottomTabNavigatorParams = CommonNavigatorParams & { export type BottomTabNavigatorParams = CommonNavigatorParams & {
+1
View File
@@ -92,4 +92,5 @@ export const router = new Router<AllNavigatableRoutes>({
StarterPackWizard: '/starter-pack/create', StarterPackWizard: '/starter-pack/create',
VideoFeed: '/video-feed', VideoFeed: '/video-feed',
Bookmarks: '/saved', Bookmarks: '/saved',
SyncContactsFlow: '/sync-contacts',
}) })
+10 -8
View File
@@ -208,14 +208,16 @@ export function SettingsScreen({}: Props) {
<Trans>Content and media</Trans> <Trans>Content and media</Trans>
</SettingsList.ItemText> </SettingsList.ItemText>
</SettingsList.LinkItem> </SettingsList.LinkItem>
<SettingsList.LinkItem {isNative && (
to="/settings/sync-contacts" <SettingsList.LinkItem
label={_(msg`Sync contacts`)}> to="/settings/sync-contacts"
<SettingsList.ItemIcon icon={ContactsIcon} /> label={_(msg`Sync contacts`)}>
<SettingsList.ItemText> <SettingsList.ItemIcon icon={ContactsIcon} />
<Trans>Sync contacts</Trans> <SettingsList.ItemText>
</SettingsList.ItemText> <Trans>Sync contacts</Trans>
</SettingsList.LinkItem> </SettingsList.ItemText>
</SettingsList.LinkItem>
)}
<SettingsList.LinkItem <SettingsList.LinkItem
to="/settings/appearance" to="/settings/appearance"
label={_(msg`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 { import {
type AllNavigatorParams, type AllNavigatorParams,
type NativeStackScreenProps, type NativeStackScreenProps,
} from '#/lib/routes/types' } 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 {atoms as a, useTheme} from '#/alf'
import {ButtonText} from '#/components/Button'
import {Contacts_Stroke2_Corner2_Rounded as SyncContactsIcon} from '#/components/icons/Contacts' import {Contacts_Stroke2_Corner2_Rounded as SyncContactsIcon} from '#/components/icons/Contacts'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText, Link} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as SettingsList from './components/SettingsList' import * as SettingsList from './components/SettingsList'
type Props = NativeStackScreenProps<AllNavigatorParams, 'SyncContactsSettings'> type Props = NativeStackScreenProps<AllNavigatorParams, 'SyncContactsSettings'>
export function SyncContactsSettingsScreen({}: Props) { export function SyncContactsSettingsScreen({}: Props) {
const t = useTheme() const t = useTheme()
const {_} = useLingui()
return ( return (
<Layout.Screen> <Layout.Screen>
@@ -26,31 +31,55 @@ export function SyncContactsSettingsScreen({}: Props) {
</Layout.Header.Content> </Layout.Header.Content>
<Layout.Header.Slot /> <Layout.Header.Slot />
</Layout.Header.Outer> </Layout.Header.Outer>
<Layout.Content> {isNative ? (
<SettingsList.Container> <Layout.Content>
<SettingsList.Item> <SettingsList.Container>
<SettingsList.ItemIcon icon={SyncContactsIcon} /> <SettingsList.Item>
<SettingsList.ItemText> <SettingsList.ItemIcon icon={SyncContactsIcon} />
<Trans>Sync Contacts</Trans> <SettingsList.ItemText>
</SettingsList.ItemText> <Trans>Sync Contacts</Trans>
</SettingsList.Item> </SettingsList.ItemText>
<SettingsList.Item style={[a.pt_0]}> </SettingsList.Item>
<Text <SettingsList.Item style={[a.pt_0]}>
style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}> <Text
<Trans> style={[
Contacts from your address book will be uploaded to Bluesky on a.text_sm,
an ongoing basis to help connect you with your friends and t.atoms.text_contrast_medium,
personalize content, such as making suggestions for you and a.leading_snug,
others. Turning off syncing will not remove previously uploaded ]}>
contacts.{' '} <Trans>
<InlineLinkText to="#" label="todo"> Contacts from your address book will be uploaded to Bluesky on
TODO: Add learn more link an ongoing basis to help connect you with your friends and
</InlineLinkText> personalize content, such as making suggestions for you and
</Trans> others. Turning off syncing will not remove previously
</Text> uploaded contacts.{' '}
</SettingsList.Item> <InlineLinkText to="#" label="todo">
</SettingsList.Container> TODO: Add learn more link
</Layout.Content> </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> </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>
)
}