diff --git a/shared/constants/deeplinks.test.ts b/shared/constants/deeplinks.test.ts
new file mode 100644
index 000000000000..3add9978c440
--- /dev/null
+++ b/shared/constants/deeplinks.test.ts
@@ -0,0 +1,69 @@
+///
+jest.mock('./router', () => ({
+ navUpToScreen: jest.fn(),
+ navigateAppend: jest.fn(),
+ navigateToThread: jest.fn(),
+ navToProfile: jest.fn(),
+ previewConversation: jest.fn(),
+ switchTab: jest.fn(),
+}))
+jest.mock('@/teams/team-page-actions', () => ({showTeamByName: jest.fn()}))
+import * as Router from './router'
+import * as Tabs from './tabs'
+import {settingsDevicesTab} from './settings'
+import {handleAppLink} from './deeplinks'
+
+const withIsMobile = (isMobile: boolean, f: () => void) => {
+ const was = global.isMobile
+ global.isMobile = isMobile
+ try {
+ f()
+ } finally {
+ global.isMobile = was
+ }
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+})
+
+// On desktop handleAppLink IS the linking subscription's listener (router.tsx passes it as
+// both listener and fallback), so this case is the whole implementation there.
+test('a devices link opens the devices tab on desktop', () => {
+ withIsMobile(false, () => {
+ handleAppLink('keybase://devices')
+
+ expect(Router.switchTab).toHaveBeenCalledWith(Tabs.devicesTab)
+ expect(Router.navUpToScreen).toHaveBeenCalledWith('devicesRoot')
+ expect(Router.navigateAppend).not.toHaveBeenCalled()
+ })
+})
+
+// The mobile half of this switch is the fallback only: isHandledByLinkingConfig now claims
+// keybase://devices, so on mobile every producer routes it to the linking config instead (the
+// phone shape it builds is covered by router-v2/linking-phone.test.ts). It still has to be
+// phone-correct, because the config is the thing that can stop claiming a URL.
+// One call covers phone and tablet: the push carries no target, so it is handled by whichever
+// navigator registers the route -- the root stack above the tabs on a phone, the Settings tab
+// stack on a tablet. navUpToScreen cannot do that; it pins its popTo to the active stack, which
+// at a tab root on a phone is the Settings tab stack, where the route does not exist.
+test('a devices link pushes the devices screen without pinning it to the settings tab stack', () => {
+ withIsMobile(true, () => {
+ handleAppLink('keybase://devices')
+
+ expect(Router.switchTab).toHaveBeenCalledWith(Tabs.settingsTab)
+ expect(Router.navigateAppend).toHaveBeenCalledWith({name: settingsDevicesTab, params: {}})
+ expect(Router.navUpToScreen).not.toHaveBeenCalled()
+ })
+})
+
+// The invite install link normalizes to this; the linking config handles it on mobile, but
+// desktop routes every URL through here, so both have to agree on where it goes.
+test('an add-phone link opens the add-phone modal over settings', () => {
+ withIsMobile(false, () => {
+ handleAppLink('keybase://settingsAddPhone')
+
+ expect(Router.switchTab).toHaveBeenCalledWith(Tabs.settingsTab)
+ expect(Router.navigateAppend).toHaveBeenCalledWith({name: 'settingsAddPhone', params: {}})
+ })
+})
diff --git a/shared/constants/deeplinks.tsx b/shared/constants/deeplinks.tsx
index df62b988a6c1..731dc1360116 100644
--- a/shared/constants/deeplinks.tsx
+++ b/shared/constants/deeplinks.tsx
@@ -1,7 +1,15 @@
import logger from '@/logger'
import * as T from '@/constants/types'
-import {navigateAppend, navigateToThread, navToProfile, previewConversation, switchTab} from './router'
+import {
+ navigateAppend,
+ navigateToThread,
+ navToProfile,
+ navUpToScreen,
+ previewConversation,
+ switchTab,
+} from './router'
import * as Tabs from './tabs'
+import {settingsDevicesTab} from './settings'
import {showTeamByName} from '@/teams/team-page-actions'
const prefix = 'keybase://'
@@ -75,6 +83,27 @@ const handleKeybaseLink = (link: string) => {
return
}
break
+ case 'devices':
+ // Devices live under Settings on phone/tablet and in their own tab on desktop.
+ if (!isMobile) {
+ switchTab(Tabs.devicesTab)
+ navUpToScreen('devicesRoot')
+ return
+ }
+ switchTab(Tabs.settingsTab)
+ // navUpToScreen pins its popTo to the deepest active stack, which at a tab root on a
+ // phone is the Settings tab stack -- and that stack knows only settingsRoot there, so
+ // StackRouter returned null and the action was dropped. An untargeted push lands
+ // wherever the route is registered: the root stack above the tabs on a phone, the
+ // Settings tab stack on a tablet. Same call the phone settings list itself makes.
+ navigateAppend({name: settingsDevicesTab, params: {}})
+ return
+ case 'settingsAddPhone':
+ // Where the invite install link (https://keybase.io/phone-app) lands. The linking config
+ // also handles it; desktop routes every URL here, so this must agree with it.
+ switchTab(Tabs.settingsTab)
+ navigateAppend({name: 'settingsAddPhone', params: {}})
+ return
case 'private':
case 'public':
try {
diff --git a/shared/constants/init/index.tsx b/shared/constants/init/index.tsx
index 51c81055da2d..88b76432ee8e 100644
--- a/shared/constants/init/index.tsx
+++ b/shared/constants/init/index.tsx
@@ -158,7 +158,6 @@ const loadStartupDetails = async () => {
let conversation: T.Chat.ConversationIDKey | undefined
let conversationUid = ''
let followUser = ''
- let link = ''
let tab = ''
// Top priority, push
@@ -166,11 +165,10 @@ const loadStartupDetails = async () => {
logger.info('initialState: push', push.startupConversation, push.startupFollowUser)
conversation = push.startupConversation
followUser = push.startupFollowUser ?? ''
- } else if (initialUrl) {
- // Second priority, deep link
- link = initialUrl
- } else if (routeState) {
- // Last priority, saved from last session
+ } else if (!initialUrl && routeState) {
+ // Last priority, saved from last session. The linking config reads the launch URL
+ // itself; this read only decides whether the saved route may be restored, since a
+ // launch URL outranks it.
try {
const item = JSON.parse(routeState) as
| undefined
@@ -203,7 +201,6 @@ const loadStartupDetails = async () => {
conversation: conversation ?? noConversationIDKey,
conversationUid,
followUser,
- link,
tab: tab as Tabs.Tab,
})
@@ -592,7 +589,6 @@ const _initDesktopPlatformListener = () => {
useConfigState.getState().dispatch.setStartupDetails({
conversation: Chat.noConversationIDKey,
followUser: '',
- link: '',
tab: undefined,
})
}
diff --git a/shared/router-v2/deep-link-emitter.tsx b/shared/router-v2/deep-link-emitter.tsx
index e3a39b62f4f2..441b1cb77e80 100644
--- a/shared/router-v2/deep-link-emitter.tsx
+++ b/shared/router-v2/deep-link-emitter.tsx
@@ -40,6 +40,15 @@ const normalizeHttpUrl = (url: string): string | undefined => {
: `keybase://team-page/${teamName}`
}
+ // /phone-app — the install link our own chat invite banner texts to an unresolved @phone
+ // participant (chat/conversation/bottom-banner.tsx). It is not a username, so it has to be
+ // carved out ahead of the single-segment rule below, which would otherwise open a profile
+ // for a user that does not exist. It always opens Add Phone Number: the invitee's inviter
+ // wrote to a number, and nothing here knows (or waits to learn) whether they have one.
+ if (pathname === '/phone-app' || pathname === '/phone-app/') {
+ return 'keybase://settingsAddPhone'
+ }
+
// /username (single path segment)
const userMatch = pathname.match(/^\/((?:[a-zA-Z0-9][a-zA-Z0-9_-]?)+)\/?$/)
if (userMatch?.[1]) {
diff --git a/shared/router-v2/linking-initial-url.test.ts b/shared/router-v2/linking-initial-url.test.ts
index 4930318e5da8..250735e6c62c 100644
--- a/shared/router-v2/linking-initial-url.test.ts
+++ b/shared/router-v2/linking-initial-url.test.ts
@@ -21,7 +21,6 @@ type Startup = {
conversation: T.Chat.ConversationIDKey
conversationUid?: string
followUser: string
- link: string
tab?: Tabs.Tab
}
@@ -32,7 +31,6 @@ const setStartup = (st: Partial) => {
startup: {
conversation: T.Chat.noConversationIDKey,
followUser: '',
- link: '',
loaded: true,
...st,
},
diff --git a/shared/router-v2/linking-phone.test.ts b/shared/router-v2/linking-phone.test.ts
new file mode 100644
index 000000000000..ef7328c218be
--- /dev/null
+++ b/shared/router-v2/linking-phone.test.ts
@@ -0,0 +1,56 @@
+///
+// Phone shapes. isSplit is computed at module load and this suite loads as desktop, so
+// global.isMobile alone yields the tablet shapes (see linking.test.ts / linking-state.test.ts);
+// only mocking the module gets the phone ones. On a phone each tab stack holds just its root
+// screen -- every other route is registered on the root stack, above the tabs -- so a nested
+// route is silently dropped on rehydrate and the tap lands on the tab root.
+jest.mock('@/constants/chat/layout', () => ({isSplit: false, threadRouteName: 'chatConversation'}))
+import * as Settings from '@/constants/settings'
+import * as Tabs from '@/constants/tabs'
+import {createLinkingConfig} from './linking'
+
+const getStateFromPath = (path: string) =>
+ (createLinkingConfig(jest.fn()).getStateFromPath as (p: string) => unknown)(path)
+
+const wasMobile = global.isMobile
+beforeAll(() => {
+ global.isMobile = true
+})
+afterAll(() => {
+ global.isMobile = wasMobile
+})
+
+test('a devices link opens devices on the root stack above the tabs on a phone', () => {
+ expect(getStateFromPath('devices')).toEqual({
+ index: 1,
+ routes: [
+ {
+ name: 'loggedIn',
+ state: {
+ index: 0,
+ routes: [{name: Tabs.settingsTab, state: {index: 0, routes: [{name: 'settingsRoot'}]}}],
+ },
+ },
+ {name: Settings.settingsDevicesTab},
+ ],
+ })
+})
+
+// A control: if the isSplit mock ever stopped taking effect, this would produce the split
+// (chatRoot-with-params) shape instead, and the devices expectation above would be testing
+// the tablet path while claiming to test the phone one.
+test('the phone shapes are in force -- a conversation opens above the tabs, not in the chat tab', () => {
+ expect(getStateFromPath('convid/conv-1')).toEqual({
+ index: 1,
+ routes: [
+ {
+ name: 'loggedIn',
+ state: {
+ index: 0,
+ routes: [{name: Tabs.chatTab, state: {index: 0, routes: [{name: 'chatRoot', params: {}}]}}],
+ },
+ },
+ {name: 'chatConversation', params: {conversationIDKey: 'conv-1'}},
+ ],
+ })
+})
diff --git a/shared/router-v2/linking-state.test.ts b/shared/router-v2/linking-state.test.ts
index 72e211644714..ce2c3ad5e3a7 100644
--- a/shared/router-v2/linking-state.test.ts
+++ b/shared/router-v2/linking-state.test.ts
@@ -144,6 +144,17 @@ test('the push prompt is a modal with no tab parked underneath', () => {
})
})
+test('add-phone is a modal over the settings tab', () => {
+ expect(isHandledByLinkingConfig('keybase://settingsAddPhone')).toBe(true)
+ expect(getStateFromPath('settingsAddPhone')).toEqual({
+ index: 1,
+ routes: [
+ {name: 'loggedIn', state: {index: 0, routes: [{name: Tabs.settingsTab}]}},
+ {name: 'settingsAddPhone'},
+ ],
+ })
+})
+
test('every app tab name is a bare tab switch', () => {
for (const tab of [
Tabs.chatTab,
diff --git a/shared/router-v2/linking.test.ts b/shared/router-v2/linking.test.ts
index cb61c28fc083..b985780ecdb4 100644
--- a/shared/router-v2/linking.test.ts
+++ b/shared/router-v2/linking.test.ts
@@ -3,7 +3,9 @@ import {useConfigState} from '@/stores/config'
import {useCurrentUserState} from '@/stores/current-user'
import {useNavigationIntentsState} from '@/stores/navigation-intents'
import {emitDeepLink} from './deep-link-emitter'
-import {subscribeNavigationIntents} from './linking'
+import * as Settings from '@/constants/settings'
+import * as Tabs from '@/constants/tabs'
+import {createLinkingConfig, isHandledByLinkingConfig, subscribeNavigationIntents} from './linking'
const setCurrentUser = (uid: string) => {
useCurrentUserState.getState().dispatch.setBootstrap({
@@ -148,3 +150,58 @@ test('consumes an intent after bootstrap fills in the uid the router readied wit
expect(listener).toHaveBeenCalledWith('keybase://convid/post-bootstrap-conversation')
unsubscribe()
})
+
+const getStateFromPath = (path: string) =>
+ (createLinkingConfig(jest.fn()).getStateFromPath as (p: string) => unknown)(path)
+
+test('a devices link is consumed by the linking config, not by handleAppLink', () => {
+ useNavigationIntentsState.getState().dispatch.setNavigationReady(true, 'current-uid')
+ const listener = jest.fn()
+ const handleAppLink = jest.fn()
+ const unsubscribe = subscribeNavigationIntents(listener, handleAppLink)
+
+ emitDeepLink('keybase://devices')
+
+ expect(isHandledByLinkingConfig('keybase://devices')).toBe(true)
+ expect(listener).toHaveBeenCalledWith('keybase://devices')
+ expect(handleAppLink).not.toHaveBeenCalled()
+ unsubscribe()
+})
+
+// isSplit is baked in at module load and this suite loads as desktop, so global.isMobile alone
+// gets the tablet shape, not the phone one. Phone coverage lives in linking-phone.test.ts.
+test('a devices link opens the devices screen inside the settings tab on tablet', () => {
+ const wasMobile = global.isMobile
+ global.isMobile = true
+ try {
+ expect(getStateFromPath('devices')).toEqual({
+ index: 0,
+ routes: [
+ {
+ name: 'loggedIn',
+ state: {
+ index: 0,
+ routes: [
+ {
+ name: Tabs.settingsTab,
+ state: {
+ index: 1,
+ routes: [{name: 'settingsRoot'}, {name: Settings.settingsDevicesTab}],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ })
+ } finally {
+ global.isMobile = wasMobile
+ }
+})
+
+test('a devices link opens the devices tab on desktop', () => {
+ expect(getStateFromPath('devices')).toEqual({
+ index: 0,
+ routes: [{name: 'loggedIn', state: {index: 0, routes: [{name: Tabs.devicesTab}]}}],
+ })
+})
diff --git a/shared/router-v2/linking.tsx b/shared/router-v2/linking.tsx
index 6f3442f00728..c6e86a3a4cf2 100644
--- a/shared/router-v2/linking.tsx
+++ b/shared/router-v2/linking.tsx
@@ -1,3 +1,4 @@
+import * as Settings from '@/constants/settings'
import * as Tabs from '@/constants/tabs'
import {isSplit} from '@/constants/chat/layout'
import {isValidConversationIDKey, stringToConversationIDKey} from '@/constants/types/chat/common'
@@ -124,6 +125,9 @@ export const subscribeNavigationIntents = (
try {
// Profile links use imperative navigation to build their intermediate
// back stack. Other known URLs can use React Navigation's linking state.
+ // This split only differs on mobile: desktop passes handleAppLink as both
+ // arguments (router.tsx), so every URL there lands in handleKeybaseLink,
+ // which must therefore stay correct for URLs the config also handles.
if (intent.url.startsWith('keybase://profile/')) {
handleAppLink(intent.url)
} else if (isHandledByLinkingConfig(intent.url)) {
@@ -183,6 +187,36 @@ const customGetStateFromPath = (
// profile/new-proof is handled by handleAppLink fallback for now
break
+ // keybase://devices — a tap on a device push. Devices live in the Settings tab on phone and
+ // tablet, and in their own tab on desktop.
+ case 'devices':
+ if (!isMobile) {
+ return makeTabState(Tabs.devicesTab)
+ }
+ if (isSplit) {
+ // Tablet: the Settings tab stack holds every settings route, so devices pushes
+ // above the tab root, inside that stack.
+ return makeTabState(Tabs.settingsTab, [{name: 'settingsRoot'}, {name: Settings.settingsDevicesTab}])
+ }
+ // Phone: settingsRoot is the only screen in the Settings tab stack, so a nested devices
+ // route is filtered out on rehydrate and the tap lands on settingsRoot. Devices is
+ // registered on the root stack there, above the tabs.
+ return {
+ index: 1,
+ routes: [
+ {
+ name: 'loggedIn',
+ state: {
+ index: 0,
+ routes: [
+ {name: Tabs.settingsTab, state: {index: 0, routes: [{name: 'settingsRoot'}]}},
+ ],
+ },
+ },
+ {name: Settings.settingsDevicesTab},
+ ],
+ }
+
// KBFS paths: keybase://private/..., keybase://public/...
case 'private':
case 'public': {
@@ -227,6 +261,11 @@ const customGetStateFromPath = (
case 'settingsPushPrompt':
return makeModalState('settingsPushPrompt')
+ // keybase://settingsAddPhone — where https://keybase.io/phone-app lands. Settings sits
+ // under the modal so dismissing it leaves the invitee somewhere they can find it again.
+ case 'settingsAddPhone':
+ return makeModalState('settingsAddPhone', undefined, Tabs.settingsTab)
+
// Tab switches: keybase://tabs.chatTab, etc.
case Tabs.chatTab:
case Tabs.peopleTab:
@@ -247,6 +286,16 @@ const customGetStateFromPath = (
// ---- Linking config ----
+// Known URLs become launch state; the rest open imperatively once the router is up.
+// setInitialURLOnce also consumes: markInitialURLHandled clears a pending intent with the
+// same URL, so subscribeNavigationIntents won't navigate to it a second time.
+const openInitialLink = (link: string, handleAppLink: (link: string) => void) => {
+ if (isHandledByLinkingConfig(link)) return setInitialURLOnce(link)
+ setInitialURLOnce(link)
+ setTimeout(() => handleAppLink(link), 1)
+ return null
+}
+
export const createLinkingConfig = (
handleAppLink: (link: string) => void
): LinkingOptions => {
@@ -284,11 +333,7 @@ export const createLinkingConfig = (
if (deepLinkUrl) {
const normalized = normalizeUrl(deepLinkUrl)
if (normalized) {
- if (isHandledByLinkingConfig(normalized)) return setInitialURLOnce(normalized)
- // URL not handled by linking config; use imperative navigation as fallback
- setInitialURLOnce(normalized)
- setTimeout(() => handleAppLink(normalized), 1)
- return null
+ return openInitialLink(normalized, handleAppLink)
}
}
diff --git a/shared/router-v2/url-normalize.test.ts b/shared/router-v2/url-normalize.test.ts
index 6f7707c9cda5..5b857c4faf1a 100644
--- a/shared/router-v2/url-normalize.test.ts
+++ b/shared/router-v2/url-normalize.test.ts
@@ -1,5 +1,6 @@
///
import {normalizeUrl} from './deep-link-emitter'
+import {useSettingsPhoneState} from '@/stores/settings-phone'
test('keybase urls pass through untouched', () => {
expect(normalizeUrl('keybase://convid/conv-1')).toBe('keybase://convid/conv-1')
@@ -79,3 +80,15 @@ test('a slash-separated subteam path is not a team-page link', () => {
// second segment and nothing matches
expect(normalizeUrl('https://keybase.io/team/keybase/sub')).toBeUndefined()
})
+
+test('the invite install link opens add-phone, not a profile for a user named phone-app', () => {
+ expect(normalizeUrl('https://keybase.io/phone-app')).toBe('keybase://settingsAddPhone')
+ expect(normalizeUrl('https://keybase.io/phone-app/')).toBe('keybase://settingsAddPhone')
+ expect(normalizeUrl('https://keybase.io/phone-app?utm=x')).toBe('keybase://settingsAddPhone')
+})
+
+test('the invite install link opens add-phone even when the user already has a number', () => {
+ useSettingsPhoneState.setState({phones: new Map([['+15555555555', {} as never]])})
+ expect(normalizeUrl('https://keybase.io/phone-app')).toBe('keybase://settingsAddPhone')
+ useSettingsPhoneState.getState().dispatch.resetState()
+})
diff --git a/shared/settings/load-settings.tsx b/shared/settings/load-settings.tsx
index 8770fd103a4a..c922fa21cde0 100644
--- a/shared/settings/load-settings.tsx
+++ b/shared/settings/load-settings.tsx
@@ -1,40 +1,49 @@
-import * as Tabs from '@/constants/tabs'
import * as S from '@/constants/strings'
import * as T from '@/constants/types'
import {ignorePromise} from '@/constants/utils'
import logger from '@/logger'
-import {navigateAppend, switchTab} from '@/constants/router'
import {RPCError} from '@/util/errors'
import {useConfigState} from '@/stores/config'
+import {useCurrentUserState} from '@/stores/current-user'
import {useSettingsEmailState} from '@/stores/settings-email'
import {useSettingsPhoneState} from '@/stores/settings-phone'
-let maybeLoadAppLinkOnce = false
-
export const loadSettings = () => {
- const maybeLoadAppLink = () => {
- const phones = useSettingsPhoneState.getState().phones
- if (!phones || phones.size > 0) {
- return
- }
-
- if (maybeLoadAppLinkOnce || !useConfigState.getState().startup.link.endsWith('/phone-app')) {
- return
- }
- maybeLoadAppLinkOnce = true
- switchTab(Tabs.settingsTab)
- navigateAppend({name: 'settingsAddPhone', params: {}})
- }
-
const f = async () => {
if (!useConfigState.getState().loggedIn) {
return
}
+ // Anything that writes these two stores while this RPC is in flight knows something the
+ // reply does not, so the reply must not land on top of it. Apply each half only to the
+ // value it was read against. The racing writer is usually an emailsChanged/phoneNumbersChanged notification, but
+ // notifyEmailVerified and sentVerificationEmail trip it too -- so a resend-verification
+ // click mid-load drops that round's server list, by design.
+ const emailsBefore = useSettingsEmailState.getState().emails
+ const phonesBefore = useSettingsPhoneState.getState().phones
+ const uidBefore = useCurrentUserState.getState().uid
try {
const settings = await T.RPCGen.userLoadMySettingsRpcPromise(undefined, S.waitingKeySettingsLoadSettings)
- useSettingsEmailState.getState().dispatch.notifyEmailAddressEmailsChanged(settings.emails ?? [])
- useSettingsPhoneState.getState().dispatch.setNumbers(settings.phoneNumbers ?? undefined)
- maybeLoadAppLink()
+ // A logout does NOT trip the identity checks below: Z.defaultReset restores the values
+ // captured at store creation, so on a cold start emails is the same initial Map and
+ // phones the same undefined. Without this, the reply would repopulate the stores for a
+ // logged-out app and the next account could read the previous one's settings.
+ if (!useConfigState.getState().loggedIn) {
+ return
+ }
+ // An account switch is invisible to both checks above: the reset restores the same
+ // creation-time values the reference checks compare against, and the new account is
+ // logged in by the time the reply lands. Only the uid separates account A's reply from
+ // account B's stores -- and writing it would also make B's own in-flight load look
+ // raced, so B would be left showing A's emails and phone numbers.
+ if (useCurrentUserState.getState().uid !== uidBefore) {
+ return
+ }
+ if (useSettingsEmailState.getState().emails === emailsBefore) {
+ useSettingsEmailState.getState().dispatch.notifyEmailAddressEmailsChanged(settings.emails ?? [])
+ }
+ if (useSettingsPhoneState.getState().phones === phonesBefore) {
+ useSettingsPhoneState.getState().dispatch.setNumbers(settings.phoneNumbers ?? undefined)
+ }
} catch (error) {
if (!(error instanceof RPCError)) {
return
diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx
index f0ab46999359..18715f9c7129 100644
--- a/shared/stores/config.tsx
+++ b/shared/stores/config.tsx
@@ -46,7 +46,6 @@ type Store = T.Immutable<{
// Used to avoid replaying a conversation under a different account.
conversationUid?: string
followUser: string
- link: string
tab?: Tab
}
userSwitching: boolean
@@ -84,7 +83,6 @@ const initialStore: Store = {
startup: {
conversation: noConversationIDKey,
followUser: '',
- link: '',
loaded: false,
},
userSwitching: false,
diff --git a/shared/stores/tests/config.test.ts b/shared/stores/tests/config.test.ts
index 1df0b762284f..e6595a24ca60 100644
--- a/shared/stores/tests/config.test.ts
+++ b/shared/stores/tests/config.test.ts
@@ -17,7 +17,6 @@ const resetConfigState = () => {
startup: {
conversation: noConversationIDKey,
followUser: '',
- link: '',
loaded: false,
},
userSwitching: false,
@@ -39,20 +38,17 @@ test('setStartupDetails only records the first startup payload', () => {
dispatch.setStartupDetails({
conversation: 'first-convo' as any,
followUser: 'alice',
- link: 'keybase://first',
tab: undefined,
})
dispatch.setStartupDetails({
conversation: 'second-convo' as any,
followUser: 'bob',
- link: 'keybase://second',
tab: undefined,
})
expect(useConfigState.getState().startup).toEqual({
conversation: 'first-convo',
followUser: 'alice',
- link: 'keybase://first',
loaded: true,
tab: undefined,
})
diff --git a/shared/stores/tests/settings.test.ts b/shared/stores/tests/settings.test.ts
index ae699f7d314f..6ce1eb83322c 100644
--- a/shared/stores/tests/settings.test.ts
+++ b/shared/stores/tests/settings.test.ts
@@ -1,14 +1,9 @@
///
-jest.mock('../../constants/router', () => ({
- clearModals: jest.fn(),
- navigateAppend: jest.fn(),
- switchTab: jest.fn(),
-}))
-
import * as T from '../../constants/types'
import {loadSettings} from '../../settings/load-settings'
import {resetAllStores} from '../../util/zustand'
import {useConfigState} from '../config'
+import {useCurrentUserState} from '../current-user'
import {useSettingsEmailState} from '../settings-email'
import {useSettingsPhoneState} from '../settings-phone'
@@ -51,4 +46,92 @@ describe('settings loading', () => {
expect(emailHandler).toHaveBeenCalledWith(emails)
expect(phoneHandler).toHaveBeenCalledWith(phoneNumbers)
})
+
+ test('a notification that lands while the settings load is in flight is not overwritten', async () => {
+ const stale = [{ctime: 0, phoneNumber: '+15550000000', superseded: false, verified: true, visibility: 0}]
+ const notified = [{ctime: 0, phoneNumber: '+15551111111', superseded: false, verified: true, visibility: 0}]
+ const staleEmails = [
+ {email: 'stale@example.com', isPrimary: true, isVerified: true, lastVerifyEmailDate: 0, visibility: 0},
+ ]
+ const notifiedEmails = [
+ {email: 'fresh@example.com', isPrimary: true, isVerified: true, lastVerifyEmailDate: 0, visibility: 0},
+ ]
+
+ useConfigState.setState({loggedIn: true})
+ jest.spyOn(T.RPCGen, 'userLoadMySettingsRpcPromise').mockImplementation((async () => {
+ // the notifications win the race: they carry the newer server state
+ await Promise.resolve()
+ useSettingsPhoneState.getState().dispatch.notifyPhoneNumberPhoneNumbersChanged(notified)
+ useSettingsEmailState.getState().dispatch.notifyEmailAddressEmailsChanged(notifiedEmails)
+ return {emails: staleEmails, phoneNumbers: stale}
+ }) as never)
+
+ loadSettings()
+ for (let i = 0; i < 10; ++i) await Promise.resolve()
+
+ expect([...useSettingsPhoneState.getState().phones!.keys()]).toEqual(['+15551111111'])
+ expect([...useSettingsEmailState.getState().emails.keys()]).toEqual(['fresh@example.com'])
+ })
+
+ test('a logout while the settings load is in flight drops the reply', async () => {
+ const emails = [
+ {email: 'a@example.com', isPrimary: true, isVerified: true, lastVerifyEmailDate: 0, visibility: 0},
+ ]
+ const phoneNumbers = [
+ {ctime: 0, phoneNumber: '+15555555555', superseded: false, verified: true, visibility: 0},
+ ]
+
+ useConfigState.setState({loggedIn: true})
+ jest.spyOn(T.RPCGen, 'userLoadMySettingsRpcPromise').mockImplementation((async () => {
+ // Z.defaultReset restores the identities captured at store creation, so the reference
+ // checks cannot see this; only the loggedIn re-read can.
+ await Promise.resolve()
+ useConfigState.getState().dispatch.setLoggedIn(false)
+ return {emails, phoneNumbers}
+ }) as never)
+
+ loadSettings()
+ for (let i = 0; i < 10; ++i) await Promise.resolve()
+
+ expect(useSettingsPhoneState.getState().phones).toBeUndefined()
+ expect([...useSettingsEmailState.getState().emails.keys()]).toEqual([])
+ })
+
+ test('an account switch while the settings load is in flight drops the reply', async () => {
+ const emails = [
+ {email: 'a@example.com', isPrimary: true, isVerified: true, lastVerifyEmailDate: 0, visibility: 0},
+ ]
+ const phoneNumbers = [
+ {ctime: 0, phoneNumber: '+15555555555', superseded: false, verified: true, visibility: 0},
+ ]
+
+ const bootstrap = (uid: string) =>
+ useCurrentUserState.getState().dispatch.setBootstrap({
+ deviceID: '',
+ deviceName: '',
+ uid,
+ username: uid,
+ })
+
+ useConfigState.setState({loggedIn: true})
+ bootstrap('uid-a')
+ jest.spyOn(T.RPCGen, 'userLoadMySettingsRpcPromise').mockImplementation((async () => {
+ // The switch, as the app performs it: the stores go back to their creation-time values
+ // and the next account logs in. Neither the reference checks nor the loggedIn re-read
+ // can tell that apart from a quiet load, so only the uid stops account A's reply from
+ // landing in account B's stores.
+ await Promise.resolve()
+ useSettingsEmailState.getState().dispatch.resetState()
+ useSettingsPhoneState.getState().dispatch.resetState()
+ bootstrap('uid-b')
+ useConfigState.getState().dispatch.setLoggedIn(true)
+ return {emails, phoneNumbers}
+ }) as never)
+
+ loadSettings()
+ for (let i = 0; i < 10; ++i) await Promise.resolve()
+
+ expect(useSettingsPhoneState.getState().phones).toBeUndefined()
+ expect([...useSettingsEmailState.getState().emails.keys()]).toEqual([])
+ })
})