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/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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}
|
StarterPackEdit: {rkey?: string}
|
||||||
VideoFeed: VideoFeedSourceContext
|
VideoFeed: VideoFeedSourceContext
|
||||||
Bookmarks: undefined
|
Bookmarks: undefined
|
||||||
|
SyncContactsFlow: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||||
|
|||||||
@@ -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',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ export function SettingsScreen({}: Props) {
|
|||||||
<Trans>Content and media</Trans>
|
<Trans>Content and media</Trans>
|
||||||
</SettingsList.ItemText>
|
</SettingsList.ItemText>
|
||||||
</SettingsList.LinkItem>
|
</SettingsList.LinkItem>
|
||||||
|
{isNative && (
|
||||||
<SettingsList.LinkItem
|
<SettingsList.LinkItem
|
||||||
to="/settings/sync-contacts"
|
to="/settings/sync-contacts"
|
||||||
label={_(msg`Sync contacts`)}>
|
label={_(msg`Sync contacts`)}>
|
||||||
@@ -216,6 +217,7 @@ export function SettingsScreen({}: Props) {
|
|||||||
<Trans>Sync contacts</Trans>
|
<Trans>Sync contacts</Trans>
|
||||||
</SettingsList.ItemText>
|
</SettingsList.ItemText>
|
||||||
</SettingsList.LinkItem>
|
</SettingsList.LinkItem>
|
||||||
|
)}
|
||||||
<SettingsList.LinkItem
|
<SettingsList.LinkItem
|
||||||
to="/settings/appearance"
|
to="/settings/appearance"
|
||||||
label={_(msg`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 {
|
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,6 +31,7 @@ export function SyncContactsSettingsScreen({}: Props) {
|
|||||||
</Layout.Header.Content>
|
</Layout.Header.Content>
|
||||||
<Layout.Header.Slot />
|
<Layout.Header.Slot />
|
||||||
</Layout.Header.Outer>
|
</Layout.Header.Outer>
|
||||||
|
{isNative ? (
|
||||||
<Layout.Content>
|
<Layout.Content>
|
||||||
<SettingsList.Container>
|
<SettingsList.Container>
|
||||||
<SettingsList.Item>
|
<SettingsList.Item>
|
||||||
@@ -36,21 +42,44 @@ export function SyncContactsSettingsScreen({}: Props) {
|
|||||||
</SettingsList.Item>
|
</SettingsList.Item>
|
||||||
<SettingsList.Item style={[a.pt_0]}>
|
<SettingsList.Item style={[a.pt_0]}>
|
||||||
<Text
|
<Text
|
||||||
style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}>
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
a.leading_snug,
|
||||||
|
]}>
|
||||||
<Trans>
|
<Trans>
|
||||||
Contacts from your address book will be uploaded to Bluesky on
|
Contacts from your address book will be uploaded to Bluesky on
|
||||||
an ongoing basis to help connect you with your friends and
|
an ongoing basis to help connect you with your friends and
|
||||||
personalize content, such as making suggestions for you and
|
personalize content, such as making suggestions for you and
|
||||||
others. Turning off syncing will not remove previously uploaded
|
others. Turning off syncing will not remove previously
|
||||||
contacts.{' '}
|
uploaded contacts.{' '}
|
||||||
<InlineLinkText to="#" label="todo">
|
<InlineLinkText to="#" label="todo">
|
||||||
TODO: Add learn more link
|
TODO: Add learn more link
|
||||||
</InlineLinkText>
|
</InlineLinkText>
|
||||||
</Trans>
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</SettingsList.Item>
|
</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>
|
</SettingsList.Container>
|
||||||
</Layout.Content>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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