diff --git a/shared/constants/init/index.native.tsx b/shared/constants/init/index.native.tsx index ee6149d76aa2..46bc349fd2cf 100644 --- a/shared/constants/init/index.native.tsx +++ b/shared/constants/init/index.native.tsx @@ -46,6 +46,8 @@ import {errorToActionOrThrow} from '@/stores/fs' import * as ScreenCapture from 'expo-screen-capture' import * as Styles from '@/styles' import {getSecureFlagSetting} from '@/constants/platform.native' +import * as Contacts from 'expo-contacts' +import {syncContactsToServer} from '@/util/contacts.native' const finishedRegularDownloadIDs = new Set() @@ -230,9 +232,23 @@ const onChatClearWatch = async () => { export const onEngineIncoming = (action: EngineGen.Actions) => { _onEngineIncoming(action) switch (action.type) { - case 'chat.1.chatUi.triggerContactSync': - useSettingsContactsState.getState().dispatch.manageContactsCache() + case 'chat.1.chatUi.triggerContactSync': { + const syncContacts = async () => { + const {importEnabled, permissionStatus, dispatch} = useSettingsContactsState.getState() + if (!importEnabled) { + return + } + const status = + permissionStatus === 'unknown' ? (await Contacts.getPermissionsAsync()).status : permissionStatus + if (status !== 'granted') { + return + } + const result = await syncContactsToServer() + dispatch.notifySyncSucceeded(result.defaultCountryCode) + } + ignorePromise(syncContacts().catch(error => logger.error('Error syncing contacts:', error))) break + } case 'keybase.1.logUi.log': { const {params} = action.payload const {level, text} = params diff --git a/shared/settings/contacts-joined.tsx b/shared/settings/contacts-joined.tsx index feadb0911506..2b0ccba01b2e 100644 --- a/shared/settings/contacts-joined.tsx +++ b/shared/settings/contacts-joined.tsx @@ -3,7 +3,6 @@ import * as Kb from '@/common-adapters' import type * as T from '@/constants/types' import * as React from 'react' import UnconnectedFollowButton from '@/profile/user/actions/follow-button' -import {useSettingsContactsState} from '@/stores/settings-contacts' import {useTrackerState} from '@/stores/tracker' import {useFollowerState} from '@/stores/followers' @@ -74,10 +73,13 @@ const Item = ({item}: {item: T.RPCGen.ProcessedContact}) => { ) } -const ContactsJoinedModal = () => { - const people = useSettingsContactsState(s => s.alreadyOnKeybase) +type Props = { + resolvedContacts?: ReadonlyArray +} + +const ContactsJoinedModal = ({resolvedContacts = []}: Props) => { const following = useFollowerState(s => s.following) - const filteredPeople = people.filter(p => !following.has(p.username)) + const filteredPeople = resolvedContacts.filter(p => !following.has(p.username)) return ( <> diff --git a/shared/settings/manage-contacts.tsx b/shared/settings/manage-contacts.tsx index 139c5feab68b..ae8343249a53 100644 --- a/shared/settings/manage-contacts.tsx +++ b/shared/settings/manage-contacts.tsx @@ -1,43 +1,121 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' +import * as React from 'react' +import type * as T from '@/constants/types' +import logger from '@/logger' import {SettingsSection} from './account' import {useSettingsContactsState} from '@/stores/settings-contacts' import {settingsFeedbackTab} from '@/constants/settings' import {useConfigState} from '@/stores/config' +import {clearContactList, syncContactsToServer} from '@/util/contacts.native' +import {useWaitingState} from '@/stores/waiting' const enabledDescription = 'Your phone contacts are being synced on this device.' const disabledDescription = 'Import your phone contacts and start encrypted chats with your friends.' +type PermissionStatus = 'granted' | 'denied' | 'undetermined' | 'unknown' const ManageContacts = () => { + const [error, setError] = React.useState('') + const [importedCount, setImportedCount] = React.useState() + const [joinedContacts, setJoinedContacts] = React.useState>([]) + const [working, setWorking] = React.useState(false) const contactsState = useSettingsContactsState( C.useShallow(s => ({ contactsImported: s.importEnabled, editContactImportEnabled: s.dispatch.editContactImportEnabled, loadContactImportEnabled: s.dispatch.loadContactImportEnabled, + notifySyncSucceeded: s.dispatch.notifySyncSucceeded, requestPermissions: s.dispatch.requestPermissions, status: s.permissionStatus, })) ) - const {contactsImported, editContactImportEnabled, loadContactImportEnabled} = contactsState + const {contactsImported, editContactImportEnabled, loadContactImportEnabled, notifySyncSucceeded} = + contactsState const {requestPermissions, status} = contactsState - const waiting = C.Waiting.useAnyWaiting(C.importContactsWaitingKey) + const navigateAppend = C.Router2.navigateAppend + const waiting = C.Waiting.useAnyWaiting(C.importContactsWaitingKey) || working - if (contactsImported === undefined) { - loadContactImportEnabled() - } + React.useEffect(() => { + if (contactsImported === undefined) { + loadContactImportEnabled().catch(() => {}) + } + }, [contactsImported, loadContactImportEnabled]) - const onToggle = () => { - if (status !== 'granted') { - requestPermissions(true, true) - } else { - editContactImportEnabled(!contactsImported, true) + const withWaiting = React.useCallback(async (fn: () => Promise) => { + const {decrement, increment} = useWaitingState.getState().dispatch + increment(C.importContactsWaitingKey) + setWorking(true) + try { + return await fn() + } finally { + setWorking(false) + decrement(C.importContactsWaitingKey) } - } + }, []) + + const onToggle = React.useCallback(async () => { + try { + setError('') + if (contactsImported) { + await withWaiting(async () => { + await editContactImportEnabled(false) + await clearContactList() + notifySyncSucceeded() + }) + setImportedCount(undefined) + setJoinedContacts([]) + return + } + + let effectiveStatus = status + if (effectiveStatus !== 'granted') { + effectiveStatus = await requestPermissions() + } + if (effectiveStatus !== 'granted') { + return + } + + const importResult = await withWaiting(async () => { + await editContactImportEnabled(true) + const result = await syncContactsToServer() + notifySyncSucceeded(result.defaultCountryCode) + return result + }) + setImportedCount(importResult.importedCount) + setJoinedContacts(importResult.resolved) + if (importResult.resolved.length) { + navigateAppend({ + name: 'settingsContactsJoined', + params: {resolvedContacts: [...importResult.resolved]}, + }) + } + } catch (_error) { + const nextError = (_error as {message?: string}).message ?? 'Unknown error' + logger.error('Error updating contacts import:', nextError) + setImportedCount(undefined) + setJoinedContacts([]) + setError(nextError) + } + }, [ + contactsImported, + editContactImportEnabled, + navigateAppend, + notifySyncSucceeded, + requestPermissions, + status, + withWaiting, + ]) return ( - + Phone contacts @@ -62,15 +140,14 @@ const ManageContacts = () => { ) } -const ManageContactsBanner = () => { - const {contactsImported, error, importedCount, status} = useSettingsContactsState( - C.useShallow(s => ({ - contactsImported: s.importEnabled, - error: s.importError, - importedCount: s.importedCount, - status: s.permissionStatus, - })) - ) +const ManageContactsBanner = (props: { + contactsImported?: boolean + error: string + importedCount?: number + joinedContacts: ReadonlyArray + status: PermissionStatus +}) => { + const {contactsImported, error, importedCount, joinedContacts, status} = props const onOpenAppSettings = useConfigState(s => s.dispatch.defer.openAppSettings) const {appendNewChatBuilder, navigateAppend, switchTab} = C.Router2 const onStartChat = () => { @@ -83,13 +160,27 @@ const ManageContactsBanner = () => { params: {feedback: `Contact import failed\n${error}\n\n`}, }) } + const onViewJoinedContacts = () => { + navigateAppend({ + name: 'settingsContactsJoined', + params: {resolvedContacts: [...joinedContacts]}, + }) + } return ( <> {!!importedCount && ( - + )} {(status === 'denied' || (Kb.Styles.isAndroid && status !== 'granted' && contactsImported)) && ( diff --git a/shared/stores/settings-contacts.d.ts b/shared/stores/settings-contacts.d.ts index 6b92103fedfe..25f822b62417 100644 --- a/shared/stores/settings-contacts.d.ts +++ b/shared/stores/settings-contacts.d.ts @@ -3,25 +3,20 @@ import type {UseBoundStore, StoreApi} from 'zustand' type PermissionStatus = 'granted' | 'denied' | 'undetermined' | 'unknown' type Store = T.Immutable<{ - alreadyOnKeybase: Array importEnabled?: boolean - importError: string - importPromptDismissed: boolean - importedCount?: number // OS permissions. 'undetermined' -> we can show the prompt; 'unknown' -> we haven't checked permissionStatus: PermissionStatus + syncGeneration: number userCountryCode?: string - waitingToShowJoinedModal: boolean }> export type State = Store & { dispatch: { - importContactsLater: () => void - editContactImportEnabled: (enable: boolean, fromSettings?: boolean) => void - loadContactPermissions: () => void - loadContactImportEnabled: () => void - manageContactsCache: () => void - requestPermissions: (thenToggleImportOn?: boolean, fromSettings?: boolean) => void + editContactImportEnabled: (enable: boolean) => Promise + loadContactPermissions: () => Promise + loadContactImportEnabled: () => Promise + notifySyncSucceeded: (userCountryCode?: string) => void + requestPermissions: () => Promise resetState: () => void } } diff --git a/shared/stores/settings-contacts.desktop.tsx b/shared/stores/settings-contacts.desktop.tsx index ae5fd96b5add..cb497982bafb 100644 --- a/shared/stores/settings-contacts.desktop.tsx +++ b/shared/stores/settings-contacts.desktop.tsx @@ -2,23 +2,18 @@ import * as Z from '@/util/zustand' import type {Store, State} from './settings-contacts' const initialStore: Store = { - alreadyOnKeybase: [], - importError: '', - importPromptDismissed: false, - importedCount: undefined, permissionStatus: 'unknown', + syncGeneration: 0, userCountryCode: undefined, - waitingToShowJoinedModal: false, } export const useSettingsContactsState = Z.createZustand('settings-contacts', () => { const dispatch: State['dispatch'] = { - editContactImportEnabled: () => {}, - importContactsLater: () => {}, - loadContactImportEnabled: () => {}, - loadContactPermissions: () => {}, - manageContactsCache: () => {}, - requestPermissions: () => {}, + editContactImportEnabled: async () => {}, + loadContactImportEnabled: async () => {}, + loadContactPermissions: async () => 'unknown', + notifySyncSucceeded: () => {}, + requestPermissions: async () => 'unknown', resetState: Z.defaultReset, } return { diff --git a/shared/stores/settings-contacts.native.tsx b/shared/stores/settings-contacts.native.tsx index e63aa7954cc4..ffc037afdc65 100644 --- a/shared/stores/settings-contacts.native.tsx +++ b/shared/stores/settings-contacts.native.tsx @@ -1,16 +1,10 @@ import * as Contacts from 'expo-contacts' -import {ignorePromise} from '@/constants/utils' import {importContactsWaitingKey} from '@/constants/strings' import * as T from '@/constants/types' import * as Z from '@/util/zustand' -import {addNotificationRequest} from 'react-native-kb' import logger from '@/logger' import type {Store, State} from './settings-contacts' import {RPCError} from '@/util/errors' -import * as Localization from 'expo-localization' -import {getE164} from '@/util/phone-numbers' -import {pluralize} from '@/util/string' -import {navigateAppend} from '@/constants/router' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useWaitingState} from '@/stores/waiting' @@ -18,240 +12,83 @@ import {useWaitingState} from '@/stores/waiting' const importContactsConfigKey = (username: string) => `ui.importContacts.${username}` const initialStore: Store = { - alreadyOnKeybase: [], - importError: '', - importPromptDismissed: false, - importedCount: undefined, permissionStatus: 'unknown', + syncGeneration: 0, userCountryCode: undefined, - waitingToShowJoinedModal: false, -} - -const nativeContactsToContacts = (contacts: Contacts.ContactResponse, countryCode: string) => { - return contacts.data.reduce>((ret, contact) => { - const {name, phoneNumbers = [], emails = []} = contact - - const components = phoneNumbers.reduce((res, pn) => { - const formatted = getE164(pn.number || '', pn.countryCode || countryCode) - if (formatted) { - res.push({ - label: pn.label, - phoneNumber: formatted, - }) - } - return res - }, []) - components.push(...emails.map(e => ({email: e.email, label: e.label}))) - if (components.length) { - ret.push({components, name}) - } - - return ret - }, []) -} - -// When the notif is tapped we are only passed the message, use this as a marker -// so we can handle it correctly. -const contactNotifMarker = 'Your contact' -const makeContactsResolvedMessage = (cts: T.Immutable>) => { - if (cts.length === 0) { - return '' - } - switch (cts.length) { - case 1: - return `${contactNotifMarker} ${cts[0]?.contactName ?? ''} joined Keybase!` - case 2: - return `${contactNotifMarker}s ${cts[0]?.contactName ?? ''} and ${ - cts[1]?.contactName ?? '' - } joined Keybase!` - default: { - const lenMinusTwo = cts.length - 2 - return `${contactNotifMarker}s ${cts[0]?.contactName ?? ''}, ${ - cts[1]?.contactName ?? '' - }, and ${lenMinusTwo} ${pluralize('other', lenMinusTwo)} joined Keybase!` - } - } } export const useSettingsContactsState = Z.createZustand('settings-contacts', (set, get) => { const dispatch: State['dispatch'] = { - editContactImportEnabled: (enable, fromSettings) => { - if (fromSettings) { - set(s => { - s.waitingToShowJoinedModal = true - }) + editContactImportEnabled: async enable => { + const username = useCurrentUserState.getState().username + if (!username) { + logger.warn('no username') + return } - const f = async () => { - const username = useCurrentUserState.getState().username - if (!username) { - logger.warn('no username') - return - } - await T.RPCGen.configGuiSetValueRpcPromise( - {path: importContactsConfigKey(username), value: {b: enable, isNull: false}}, + await T.RPCGen.configGuiSetValueRpcPromise( + {path: importContactsConfigKey(username), value: {b: enable, isNull: false}}, + importContactsWaitingKey + ) + await get().dispatch.loadContactImportEnabled() + }, + loadContactImportEnabled: async () => { + if (!useConfigState.getState().loggedIn) { + return + } + const username = useCurrentUserState.getState().username + if (!username) { + logger.warn('no username') + return + } + let enabled = false + try { + const value = await T.RPCGen.configGuiGetValueRpcPromise( + {path: importContactsConfigKey(username)}, importContactsWaitingKey ) - get().dispatch.loadContactImportEnabled() - } - ignorePromise(f()) - }, - importContactsLater: () => { - set(s => { - s.importPromptDismissed = true - }) - }, - loadContactImportEnabled: () => { - const f = async () => { - if (!useConfigState.getState().loggedIn) { - return - } - const username = useCurrentUserState.getState().username - if (!username) { - logger.warn('no username') + enabled = !!value.b && !value.isNull + } catch (error) { + if (!(error instanceof RPCError)) { return } - let enabled = false - try { - const value = await T.RPCGen.configGuiGetValueRpcPromise( - {path: importContactsConfigKey(username)}, - importContactsWaitingKey - ) - enabled = !!value.b && !value.isNull - } catch (error) { - if (!(error instanceof RPCError)) { - return - } - if (!error.message.includes('no such key')) { - logger.error(`Error reading config: ${error.message}`) - } + if (!error.message.includes('no such key')) { + logger.error(`Error reading config: ${error.message}`) } - - set(s => { - s.importEnabled = enabled - }) - get().dispatch.loadContactPermissions() - get().dispatch.manageContactsCache() } - ignorePromise(f()) - }, - loadContactPermissions: () => { - const f = async () => { - const {status} = await Contacts.getPermissionsAsync() - logger.info(`OS status: ${status}`) - set(s => { - s.permissionStatus = status - }) - } - ignorePromise(f()) + set(s => { + s.importEnabled = enabled + }) + await get().dispatch.loadContactPermissions() }, - manageContactsCache: () => { - const f = async () => { - if (get().importEnabled === false) { - await T.RPCGen.contactsSaveContactListRpcPromise({contacts: []}) - set(s => { - s.importedCount = undefined - s.importError = '' - }) - return - } - - // get permissions if we haven't loaded them for some reason - let {permissionStatus} = get() - if (permissionStatus === 'unknown') { - permissionStatus = (await Contacts.getPermissionsAsync()).status - } - const perm = permissionStatus === 'granted' - - const enabled = get().importEnabled - if (!enabled || !perm) { - if (enabled && !perm) { - logger.info('contact import enabled but no contact permissions') - } - if (enabled === undefined) { - logger.info("haven't loaded contact import enabled") - } - return - } - - // feature enabled and permission granted - let mapped: T.RPCChat.Keybase1.Contact[] - let defaultCountryCode = '' - try { - const _contacts = await Contacts.getContactsAsync({ - fields: [Contacts.Fields.Name, Contacts.Fields.PhoneNumbers, Contacts.Fields.Emails], - }) - - defaultCountryCode = Localization.getLocales()[0].regionCode?.toLowerCase() ?? '' - if (__DEV__ && !defaultCountryCode) { - // behavior of parsing can be unexpectedly different with no country code. - // iOS sim + android emu don't supply country codes, so use this one. - defaultCountryCode = 'us' - } - mapped = nativeContactsToContacts(_contacts, defaultCountryCode) - } catch (_error) { - const error = _error as {message: string} - logger.error(`error loading contacts: ${error.message}`) - set(s => { - s.importedCount = undefined - s.importError = error.message - }) - return - } - logger.info(`Importing ${mapped.length} contacts.`) - try { - const {newlyResolved, resolved} = await T.RPCGen.contactsSaveContactListRpcPromise({ - contacts: mapped, - }) - logger.info(`Success`) - set(s => { - s.importedCount = mapped.length - s.importError = '' - }) - set(s => { - s.userCountryCode = defaultCountryCode - }) - if (newlyResolved?.length) { - addNotificationRequest({ - body: makeContactsResolvedMessage(newlyResolved), - id: Math.floor(Math.random() * 2 ** 32).toString(), - }).catch(() => {}) - } - if (get().waitingToShowJoinedModal && resolved) { - set(s => { - s.alreadyOnKeybase = T.castDraft(resolved) - s.waitingToShowJoinedModal = false - }) - if (resolved.length) { - navigateAppend('settingsContactsJoined') - } - } - } catch (_error) { - const error = _error as {message: string} - logger.error('Error saving contacts list: ', error.message) - set(s => { - s.importedCount = undefined - s.importError = error.message - }) + loadContactPermissions: async () => { + const {status} = await Contacts.getPermissionsAsync() + logger.info(`OS status: ${status}`) + set(s => { + s.permissionStatus = status + }) + return status + }, + notifySyncSucceeded: userCountryCode => { + set(s => { + s.syncGeneration++ + if (userCountryCode) { + s.userCountryCode = userCountryCode } - } - ignorePromise(f()) + }) }, - requestPermissions: (thenToggleImportOn?: boolean, fromSettings?: boolean) => { - const f = async () => { - const {decrement, increment} = useWaitingState.getState().dispatch - increment(importContactsWaitingKey) + requestPermissions: async () => { + const {decrement, increment} = useWaitingState.getState().dispatch + increment(importContactsWaitingKey) + try { const status = (await Contacts.requestPermissionsAsync()).status - - if (status === Contacts.PermissionStatus.GRANTED && thenToggleImportOn) { - get().dispatch.editContactImportEnabled(true, fromSettings) - } set(s => { s.permissionStatus = status }) + return status + } finally { decrement(importContactsWaitingKey) } - ignorePromise(f()) }, resetState: Z.defaultReset, } diff --git a/shared/stores/team-building.tsx b/shared/stores/team-building.tsx index 732ff7c81855..1be555f08c84 100644 --- a/shared/stores/team-building.tsx +++ b/shared/stores/team-building.tsx @@ -25,8 +25,10 @@ type Store = T.Immutable<{ userRecs?: Array selectedRole: T.Teams.TeamRoleType sendNotification: boolean + contactsImportPromptDismissed: boolean }> export const initialStore: Store = { + contactsImportPromptDismissed: false, error: '', namespace: 'invalid', searchLimit: 11, @@ -54,6 +56,7 @@ export type State = Store & { onUsersGetBlockState: (usernames: ReadonlyArray) => void onUsersUpdates: (infos: ReadonlyArray<{name: string; info: Partial}>) => void } + dismissContactsImportPrompt: () => void fetchUserRecs: () => void finishTeamBuilding: () => void finishedTeamBuilding: () => void @@ -323,6 +326,11 @@ const createSlice: Z.ImmerStateCreator = (set, get) => { throw new Error('onUsersUpdates not properly initialized') }, }, + dismissContactsImportPrompt: () => { + set(s => { + s.contactsImportPromptDismissed = true + }) + }, fetchUserRecs: () => { const includeContacts = get().namespace === 'chat' const f = async () => { diff --git a/shared/stores/tests/settings-contacts.desktop.test.ts b/shared/stores/tests/settings-contacts.desktop.test.ts index 28eb998e8062..1e12668ea1ef 100644 --- a/shared/stores/tests/settings-contacts.desktop.test.ts +++ b/shared/stores/tests/settings-contacts.desktop.test.ts @@ -10,44 +10,21 @@ test('desktop settings contacts store is a no-op wrapper around the default stat const {dispatch} = useSettingsContactsState.getState() dispatch.editContactImportEnabled(false) - dispatch.importContactsLater() dispatch.loadContactImportEnabled() dispatch.loadContactPermissions() - dispatch.manageContactsCache() + dispatch.notifySyncSucceeded() dispatch.requestPermissions() useSettingsContactsState.setState({ - alreadyOnKeybase: [ - { - assertion: 'alice@keybase', - component: {label: 'alice'}, - contactIndex: 0, - contactName: 'Alice', - displayLabel: 'alice', - displayName: 'Alice', - following: false, - fullName: 'Alice', - resolved: true, - uid: 'uid', - username: 'alice', - }, - ], - importError: 'error', - importPromptDismissed: true, - importedCount: 2, permissionStatus: 'granted', - userCountryCode: 'US', - waitingToShowJoinedModal: true, + syncGeneration: 3, + userCountryCode: 'us', }) resetAllStores() expect(useSettingsContactsState.getState()).toMatchObject({ - alreadyOnKeybase: [], - importError: '', - importPromptDismissed: false, - importedCount: undefined, permissionStatus: 'unknown', + syncGeneration: 0, userCountryCode: undefined, - waitingToShowJoinedModal: false, }) }) diff --git a/shared/team-building/contacts.desktop.tsx b/shared/team-building/contacts.desktop.tsx new file mode 100644 index 000000000000..10a3dda17207 --- /dev/null +++ b/shared/team-building/contacts.desktop.tsx @@ -0,0 +1,9 @@ +import type * as T from '@/constants/types' + +export const ContactsBanner = (_props: { + namespace: T.TB.AllowedNamespace + selectedService: T.TB.ServiceIdWithContact + onRedoSearch: () => void +}) => null + +export const ContactsImportButton = () => null diff --git a/shared/team-building/contacts.tsx b/shared/team-building/contacts.native.tsx similarity index 77% rename from shared/team-building/contacts.tsx rename to shared/team-building/contacts.native.tsx index 8eda6c68cf6f..e989ccf2610e 100644 --- a/shared/team-building/contacts.tsx +++ b/shared/team-building/contacts.native.tsx @@ -2,57 +2,61 @@ import * as React from 'react' import * as C from '@/constants' import * as Kb from '@/common-adapters' import type * as T from '@/constants/types' +import logger from '@/logger' import {useSettingsContactsState} from '@/stores/settings-contacts' import {useTBContext} from '@/stores/team-building' +import {syncContactsToServer} from '@/util/contacts.native' const useContactsProps = () => { const { contactsImported, contactsPermissionStatus, editContactImportEnabled, - importContactsLater, - isImportPromptDismissed, loadContactImportEnabled, - numContactsImported, + notifySyncSucceeded, requestPermissions, + syncGeneration, } = useSettingsContactsState( C.useShallow(s => ({ contactsImported: s.importEnabled, contactsPermissionStatus: s.permissionStatus, editContactImportEnabled: s.dispatch.editContactImportEnabled, - importContactsLater: s.dispatch.importContactsLater, - isImportPromptDismissed: s.importPromptDismissed, loadContactImportEnabled: s.dispatch.loadContactImportEnabled, - numContactsImported: s.importedCount || 0, + notifySyncSucceeded: s.dispatch.notifySyncSucceeded, requestPermissions: s.dispatch.requestPermissions, + syncGeneration: s.syncGeneration, + })) + ) + const {dismissContactsImportPrompt, isImportPromptDismissed} = useTBContext( + C.useShallow(s => ({ + dismissContactsImportPrompt: s.dispatch.dismissContactsImportPrompt, + isImportPromptDismissed: s.contactsImportPromptDismissed, })) ) - const onAskForContactsLater = importContactsLater - const onLoadContactsSetting = loadContactImportEnabled - - const onImportContactsPermissionsGranted = () => { - editContactImportEnabled(true, false) - } - const onImportContactsPermissionsNotGranted = () => { - requestPermissions(true, false) - } - - const onImportContacts = - contactsPermissionStatus === 'denied' - ? undefined - : contactsPermissionStatus === 'granted' - ? onImportContactsPermissionsGranted - : onImportContactsPermissionsNotGranted + const onImportContacts = React.useCallback(async () => { + try { + const status = + contactsPermissionStatus === 'granted' ? contactsPermissionStatus : await requestPermissions() + if (status !== 'granted') { + return + } + await editContactImportEnabled(true) + const result = await syncContactsToServer() + notifySyncSucceeded(result.defaultCountryCode) + } catch (error) { + logger.error('Error importing contacts from team building:', error) + } + }, [contactsPermissionStatus, editContactImportEnabled, notifySyncSucceeded, requestPermissions]) return { contactsImported, contactsPermissionStatus, isImportPromptDismissed, - numContactsImported, - onAskForContactsLater, + onAskForContactsLater: dismissContactsImportPrompt, onImportContacts, - onLoadContactsSetting, + onLoadContactsSetting: loadContactImportEnabled, + syncGeneration, } } @@ -66,42 +70,35 @@ export const ContactsBanner = (props: { contactsImported, contactsPermissionStatus, isImportPromptDismissed, - numContactsImported, onAskForContactsLater, onImportContacts, onLoadContactsSetting, + syncGeneration, } = useContactsProps() const fetchUserRecs = useTBContext(s => s.dispatch.fetchUserRecs) - const onRedoRecs = fetchUserRecs - const prevNumContactsImportedRef = React.useRef(numContactsImported) + const prevSyncGenerationRef = React.useRef(syncGeneration) - // Redo search if # of imported contacts changes React.useEffect(() => { - if (prevNumContactsImportedRef.current !== numContactsImported) { - prevNumContactsImportedRef.current = numContactsImported + if (prevSyncGenerationRef.current !== syncGeneration) { + prevSyncGenerationRef.current = syncGeneration onRedoSearch() - onRedoRecs() + fetchUserRecs() } - }, [numContactsImported, onRedoSearch, onRedoRecs]) + }, [fetchUserRecs, onRedoSearch, syncGeneration]) - // Ensure that we know whether contacts are loaded, and if not, that we load - // the current config setting. React.useEffect(() => { if (contactsImported === undefined) { - onLoadContactsSetting() + onLoadContactsSetting().catch(() => {}) } }, [contactsImported, onLoadContactsSetting]) - // If we've imported contacts already, or the user has dismissed the message, - // then there's nothing for us to do. if ( contactsImported === undefined || selectedService !== 'keybase' || contactsImported || isImportPromptDismissed || - contactsPermissionStatus === 'denied' || - !onImportContacts + contactsPermissionStatus === 'denied' ) return null @@ -138,7 +135,6 @@ export const ContactsImportButton = () => { const {contactsImported, contactsPermissionStatus, isImportPromptDismissed, onImportContacts} = useContactsProps() - // If we've imported contacts already, then there's nothing for us to do. if ( contactsImported === undefined || contactsImported || diff --git a/shared/teams/common/use-contacts.native.tsx b/shared/teams/common/use-contacts.native.tsx index 0b74fa763798..e5eb060c7dd3 100644 --- a/shared/teams/common/use-contacts.native.tsx +++ b/shared/teams/common/use-contacts.native.tsx @@ -2,9 +2,9 @@ import * as Contacts from 'expo-contacts' import * as React from 'react' import {e164ToDisplay} from '@/util/phone-numbers' import logger from '@/logger' -import * as Localization from 'expo-localization' import {useSettingsContactsState} from '@/stores/settings-contacts' import {getE164} from '@/util/phone-numbers' +import {getDefaultCountryCode} from '@/util/contacts.native' // Contact info coming from the native contacts library. export type Contact = { @@ -35,22 +35,7 @@ const fetchContacts = async (regionFromState: string): Promise<[Array, Contacts.Fields.Image, ], }) - - let region = '' - if (regionFromState) { - logger.debug(`Got region from state: ${regionFromState}, no need to call NativeModules.`) - region = regionFromState - } else { - { - let defaultCountryCode = Localization.getLocales()[0].regionCode?.toLowerCase() ?? '' - if (__DEV__ && !defaultCountryCode) { - // behavior of parsing can be unexpectedly different with no country code. - // iOS sim + android emu don't supply country codes, so use this one. - defaultCountryCode = 'us' - } - region = defaultCountryCode - } - } + const region = regionFromState || getDefaultCountryCode() const mapped = contacts.data.reduce>((ret, contact) => { const {name = '', phoneNumbers = [], emails = []} = contact @@ -112,7 +97,7 @@ const useContacts = () => { setNoAccessPermanent(true) setLoading(false) } - }, [setErrorMessage, setContacts, permStatus, savedRegion]) + }, [permStatus, savedRegion]) const requestPermissions = useSettingsContactsState(s => s.dispatch.requestPermissions) React.useEffect(() => { @@ -121,7 +106,7 @@ const useContacts = () => { // dispatch more than once. if (permStatus === 'unknown' || permStatus === 'undetermined') { setNoAccessPermanent(false) - requestPermissions(false) + requestPermissions().catch(() => {}) } }, [requestPermissions, permStatus]) diff --git a/shared/util/contacts.native.tsx b/shared/util/contacts.native.tsx new file mode 100644 index 000000000000..33283dc22880 --- /dev/null +++ b/shared/util/contacts.native.tsx @@ -0,0 +1,86 @@ +import * as Contacts from 'expo-contacts' +import * as Localization from 'expo-localization' +import logger from '@/logger' +import * as T from '@/constants/types' +import {addNotificationRequest} from 'react-native-kb' +import {getE164} from '@/util/phone-numbers' +import {pluralize} from '@/util/string' + +export const getDefaultCountryCode = () => { + let defaultCountryCode = Localization.getLocales()[0].regionCode?.toLowerCase() ?? '' + if (__DEV__ && !defaultCountryCode) { + // behavior of parsing can be unexpectedly different with no country code. + // iOS sim + android emu don't supply country codes, so use this one. + defaultCountryCode = 'us' + } + return defaultCountryCode +} + +const nativeContactsToContacts = (contacts: Contacts.ContactResponse, countryCode: string) => + contacts.data.reduce>((ret, contact) => { + const {name, phoneNumbers = [], emails = []} = contact + + const components = phoneNumbers.reduce((res, pn) => { + const formatted = getE164(pn.number || '', pn.countryCode || countryCode) + if (formatted) { + res.push({ + label: pn.label, + phoneNumber: formatted, + }) + } + return res + }, []) + components.push(...emails.map(e => ({email: e.email, label: e.label}))) + if (components.length) { + ret.push({components, name}) + } + + return ret + }, []) + +const contactNotifMarker = 'Your contact' +const makeContactsResolvedMessage = (cts: T.Immutable>) => { + if (cts.length === 0) { + return '' + } + switch (cts.length) { + case 1: + return `${contactNotifMarker} ${cts[0]?.contactName ?? ''} joined Keybase!` + case 2: + return `${contactNotifMarker}s ${cts[0]?.contactName ?? ''} and ${ + cts[1]?.contactName ?? '' + } joined Keybase!` + default: { + const lenMinusTwo = cts.length - 2 + return `${contactNotifMarker}s ${cts[0]?.contactName ?? ''}, ${ + cts[1]?.contactName ?? '' + }, and ${lenMinusTwo} ${pluralize('other', lenMinusTwo)} joined Keybase!` + } + } +} + +export const clearContactList = () => T.RPCGen.contactsSaveContactListRpcPromise({contacts: []}) + +export const syncContactsToServer = async () => { + const contacts = await Contacts.getContactsAsync({ + fields: [Contacts.Fields.Name, Contacts.Fields.PhoneNumbers, Contacts.Fields.Emails], + }) + const defaultCountryCode = getDefaultCountryCode() + const mapped = nativeContactsToContacts(contacts, defaultCountryCode) + logger.info(`Importing ${mapped.length} contacts.`) + const {newlyResolved = [], resolved = []} = await T.RPCGen.contactsSaveContactListRpcPromise({ + contacts: mapped, + }) + if (newlyResolved.length) { + addNotificationRequest({ + body: makeContactsResolvedMessage(newlyResolved), + id: Math.floor(Math.random() * 2 ** 32).toString(), + }).catch(() => {}) + } + return { + defaultCountryCode, + importedCount: mapped.length, + newlyResolved, + resolved, + } +} diff --git a/skill/zustand-store-pruning/references/store-checklist.md b/skill/zustand-store-pruning/references/store-checklist.md index 1d836cedbd6e..b52446ce2e6e 100644 --- a/skill/zustand-store-pruning/references/store-checklist.md +++ b/skill/zustand-store-pruning/references/store-checklist.md @@ -12,7 +12,7 @@ Status: - [ ] `teams` - [ ] `push` Files: `shared/stores/push.desktop.tsx`, `shared/stores/push.native.tsx`, `shared/stores/push.d.ts` -- [ ] `settings-contacts` +- [x] `settings-contacts` Files: `shared/stores/settings-contacts.desktop.tsx`, `shared/stores/settings-contacts.native.tsx`, `shared/stores/settings-contacts.d.ts` ## Notes