state machine, flow screen
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -88,6 +88,7 @@ export type CommonNavigatorParams = {
|
||||
StarterPackEdit: {rkey?: string}
|
||||
VideoFeed: VideoFeedSourceContext
|
||||
Bookmarks: undefined
|
||||
SyncContactsFlow: undefined
|
||||
}
|
||||
|
||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||
|
||||
@@ -92,4 +92,5 @@ export const router = new Router<AllNavigatableRoutes>({
|
||||
StarterPackWizard: '/starter-pack/create',
|
||||
VideoFeed: '/video-feed',
|
||||
Bookmarks: '/saved',
|
||||
SyncContactsFlow: '/sync-contacts',
|
||||
})
|
||||
|
||||
@@ -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`)}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user