Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions shared/constants/init/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()

Expand Down Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions shared/settings/contacts-joined.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -74,10 +73,13 @@ const Item = ({item}: {item: T.RPCGen.ProcessedContact}) => {
)
}

const ContactsJoinedModal = () => {
const people = useSettingsContactsState(s => s.alreadyOnKeybase)
type Props = {
resolvedContacts?: ReadonlyArray<T.RPCGen.ProcessedContact>
}

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 (
<>
<Kb.Text type="Body" style={styles.woot} center={true}>
Expand Down
135 changes: 113 additions & 22 deletions shared/settings/manage-contacts.tsx
Original file line number Diff line number Diff line change
@@ -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<number>()
const [joinedContacts, setJoinedContacts] = React.useState<ReadonlyArray<T.RPCGen.ProcessedContact>>([])
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 <R,>(fn: () => Promise<R>) => {
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 (
<Kb.Box2 direction="vertical" fullWidth={true} fullHeight={true} relative={true}>
<Kb.BoxGrow>
<ManageContactsBanner />
<ManageContactsBanner
contactsImported={contactsImported}
error={error}
importedCount={importedCount}
joinedContacts={joinedContacts}
status={status}
/>
<SettingsSection>
<Kb.Box2 direction="vertical" gap="xtiny" fullWidth={true}>
<Kb.Text type="Header">Phone contacts</Kb.Text>
Expand All @@ -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<T.RPCGen.ProcessedContact>
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 = () => {
Expand All @@ -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 && (
<Kb.Banner color="green">
<Kb.BannerParagraph bannerColor="green" content={[`You imported ${importedCount} contacts.`]} />
<Kb.BannerParagraph bannerColor="green" content={[{onClick: onStartChat, text: 'Start a chat'}]} />
<Kb.BannerParagraph
bannerColor="green"
content={[
{onClick: onStartChat, text: 'Start a chat'},
...(joinedContacts.length
? [' or ', {onClick: onViewJoinedContacts, text: 'view contacts on Keybase'}]
: []),
]}
/>
</Kb.Banner>
)}
{(status === 'denied' || (Kb.Styles.isAndroid && status !== 'granted' && contactsImported)) && (
Expand Down
17 changes: 6 additions & 11 deletions shared/stores/settings-contacts.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,20 @@ import type {UseBoundStore, StoreApi} from 'zustand'
type PermissionStatus = 'granted' | 'denied' | 'undetermined' | 'unknown'

type Store = T.Immutable<{
alreadyOnKeybase: Array<T.RPCGen.ProcessedContact>
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<void>
loadContactPermissions: () => Promise<PermissionStatus>
loadContactImportEnabled: () => Promise<void>
notifySyncSucceeded: (userCountryCode?: string) => void
requestPermissions: () => Promise<PermissionStatus>
resetState: () => void
}
}
Expand Down
17 changes: 6 additions & 11 deletions shared/stores/settings-contacts.desktop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<State>('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 {
Expand Down
Loading