diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 3282f072e0..5b60c9fa1e 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -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) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 1282e7d378..a5d4bc6f39 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -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, }} /> + SyncContactsFlowScreen} + options={{ + title: title(msg`Sync Contacts`), + requireAuth: true, + }} + /> ) } diff --git a/src/components/contacts/SyncContactsFlow.tsx b/src/components/contacts/SyncContactsFlow.tsx new file mode 100644 index 0000000000..f75d8f03af --- /dev/null +++ b/src/components/contacts/SyncContactsFlow.tsx @@ -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 +}) { + const [transitionDirection, _setTransitionDirection] = useState< + 'Forward' | 'Backward' + >('Forward') + + return ( + + <> + + ) +} diff --git a/src/components/contacts/state.ts b/src/components/contacts/state.ts new file mode 100644 index 0000000000..2c4870e067 --- /dev/null +++ b/src/components/contacts/state.ts @@ -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( + state: State, + step: S, +): asserts state is Extract { + 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) +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index ceeef7389e..e81bab5cf5 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -88,6 +88,7 @@ export type CommonNavigatorParams = { StarterPackEdit: {rkey?: string} VideoFeed: VideoFeedSourceContext Bookmarks: undefined + SyncContactsFlow: undefined } export type BottomTabNavigatorParams = CommonNavigatorParams & { diff --git a/src/routes.ts b/src/routes.ts index 5afed2c965..fba538c36d 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -92,4 +92,5 @@ export const router = new Router({ StarterPackWizard: '/starter-pack/create', VideoFeed: '/video-feed', Bookmarks: '/saved', + SyncContactsFlow: '/sync-contacts', }) diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index 351a469aab..bbcf500e4d 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -208,14 +208,16 @@ export function SettingsScreen({}: Props) { Content and media - - - - Sync contacts - - + {isNative && ( + + + + Sync contacts + + + )} diff --git a/src/screens/Settings/SyncContactsSettings.tsx b/src/screens/Settings/SyncContactsSettings.tsx index cb8cd8b418..7397836704 100644 --- a/src/screens/Settings/SyncContactsSettings.tsx +++ b/src/screens/Settings/SyncContactsSettings.tsx @@ -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 export function SyncContactsSettingsScreen({}: Props) { const t = useTheme() + const {_} = useLingui() return ( @@ -26,31 +31,55 @@ export function SyncContactsSettingsScreen({}: Props) { - - - - - - Sync Contacts - - - - - - 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.{' '} - - TODO: Add learn more link - - - - - - + {isNative ? ( + + + + + + Sync Contacts + + + + + + 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.{' '} + + TODO: Add learn more link + + + + + + + + Upload contacts + + + + + + ) : ( + + )} ) } diff --git a/src/screens/SyncContactsFlowScreen.tsx b/src/screens/SyncContactsFlowScreen.tsx new file mode 100644 index 0000000000..f3753ea7b6 --- /dev/null +++ b/src/screens/SyncContactsFlowScreen.tsx @@ -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 +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 ( + + {isNative ? ( + + ) : ( + + )} + + ) +}