From 55bacb96ba1daec7d92b8ff8cf9b217684da35f6 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 9 Sep 2026 14:02:26 -0400 Subject: [PATCH] refactor(engine): let features register their own engine handlers Two mechanisms distributed the same action stream. The deep one - subscribeToEngineAction, type-indexed and HMR-safe - already had ~136 subscriptions. The shallow ones were three central switches: 41 arms in constants/init/shared.tsx reaching into 16 stores, 13 more split by platform in constants/init/index.tsx, and 9 inside the config store. That made constants/init a compile-time dependency of every store it dispatched into. Each feature now registers its own handlers at module init through the same listenersByType the notifier already walks. The three switches are gone; onEngineIncoming is notifyEngineActionListeners. Order was case-arm position, which said nothing. It is now an explicit priority on the registration: sharedFirst < shared < config < component subscriptions < platform, matching the order those four tiers ran in before. The one arm with a genuine internal dependency - the inbox conversation badge map has to be rebuilt before anything derives tab counts from it - registers at sharedFirst. Registrations are permanent: a sign-out reset drops what components subscribed, but nothing re-runs module init to put a registration back. The duplicate gregor nonNull filter (and its duplicate warning) that the shared switch performed alongside the identical one in useNotifState is dropped. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015rccpV5nLxxC5opF5xzrz7 --- shared/chat/blocking/block-buttons-state.tsx | 10 + .../chat/conversation/center-context.test.tsx | 5 +- .../conversation/normal/container.test.tsx | 1 + shared/chat/inbox/badge-state.tsx | 13 ++ shared/chat/inbox/engine.tsx | 50 +++++ shared/chat/inbox/metadata.tsx | 32 +++ shared/common-adapters/avatar/store.tsx | 10 + shared/constants/init/index.tsx | 200 +++++++++-------- shared/constants/init/shared.tsx | 212 ++---------------- shared/devices/index.test.tsx | 5 +- shared/engine/action-listener.test.ts | 70 ++++++ shared/engine/action-listener.tsx | 151 ++++++++++--- shared/router-v2/deep-link-emitter.tsx | 20 ++ shared/stores/config.tsx | 165 +++++++------- shared/stores/followers-engine.tsx | 29 +++ shared/stores/notifications.test.tsx | 11 +- shared/stores/notifications.tsx | 164 +++++++------- shared/stores/settings-email.tsx | 20 ++ shared/stores/settings-phone.tsx | 12 + shared/stores/tests/config.test.ts | 10 +- shared/stores/tests/notifications.test.ts | 46 +++- shared/stores/users.tsx | 59 +++-- 22 files changed, 763 insertions(+), 532 deletions(-) create mode 100644 shared/stores/followers-engine.tsx diff --git a/shared/chat/blocking/block-buttons-state.tsx b/shared/chat/blocking/block-buttons-state.tsx index fab3b76bd0e6..72466a4f82c9 100644 --- a/shared/chat/blocking/block-buttons-state.tsx +++ b/shared/chat/blocking/block-buttons-state.tsx @@ -4,6 +4,7 @@ import * as Z from '@/util/zustand' import {bodyToJSON} from '@/constants/rpc-utils' import {ignorePromise} from '@/constants/utils' import logger from '@/logger' +import {EnginePriority, registerEngineHandlers} from '@/engine/action-listener' const blockButtonsGregorPrefix = 'blockButtons.' @@ -122,3 +123,12 @@ export const useBlockButtonsInfo = (teamID: T.Teams.TeamID) => { return blockButtonsInfo } + +registerEngineHandlers( + { + 'keybase.1.gregorUI.pushState': action => { + useBlockButtonsState.getState().dispatch.updateFromGregorItems(action.payload.params.state.items) + }, + }, + {id: 'chat/blocking/block-buttons-state', priority: EnginePriority.shared} +) diff --git a/shared/chat/conversation/center-context.test.tsx b/shared/chat/conversation/center-context.test.tsx index 9df8cffcf0f8..6e0b37a63f0e 100644 --- a/shared/chat/conversation/center-context.test.tsx +++ b/shared/chat/conversation/center-context.test.tsx @@ -24,7 +24,10 @@ jest.mock('./thread-context', () => ({ jest.mock('./send-actions', () => ({ useConversationSendActions: () => ({sendGiphyResult: jest.fn(), sendMessage: jest.fn()}), })) -jest.mock('@/engine/action-listener', () => ({useEngineActionListener: () => {}})) +jest.mock('@/engine/action-listener', () => ({ + ...jest.requireActual('@/engine/action-listener'), + useEngineActionListener: () => {}, +})) jest.mock('./thread-load-status-context', () => ({ useThreadLoadStatusOptionsGetter: () => () => mockThreadLoadStatusOptions, })) diff --git a/shared/chat/conversation/normal/container.test.tsx b/shared/chat/conversation/normal/container.test.tsx index 7a2622a60607..fd83fe0b0b1a 100644 --- a/shared/chat/conversation/normal/container.test.tsx +++ b/shared/chat/conversation/normal/container.test.tsx @@ -89,6 +89,7 @@ jest.mock('@/constants', () => { }) jest.mock('@/engine/action-listener', () => ({ + ...jest.requireActual('@/engine/action-listener'), useEngineActionListener: jest.fn(), })) diff --git a/shared/chat/inbox/badge-state.tsx b/shared/chat/inbox/badge-state.tsx index dcf716817cbc..8d510967c907 100644 --- a/shared/chat/inbox/badge-state.tsx +++ b/shared/chat/inbox/badge-state.tsx @@ -1,5 +1,6 @@ import * as T from '@/constants/types' import * as Z from '@/util/zustand' +import {EnginePriority, registerEngineHandlers} from '@/engine/action-listener' export type BadgeCounts = {badgeCount: number; unreadCount: number} @@ -35,3 +36,15 @@ export const syncInboxBadgeState = (badgeState?: T.RPCGen.BadgeState) => { export const getInboxBadge = (id: T.Chat.ConversationIDKey): BadgeCounts => useInboxBadgeState.getState().counts.get(id) ?? emptyCounts + +// Runs ahead of every other badgeState handler: the tab badge counts useNotifState +// derives, and anything rendering off them, must never read a conversation map +// from the previous badgeState. +registerEngineHandlers( + { + 'keybase.1.NotifyBadges.badgeState': action => { + syncInboxBadgeState(action.payload.params.badgeState) + }, + }, + {id: 'chat/inbox/badge-state', priority: EnginePriority.sharedFirst} +) diff --git a/shared/chat/inbox/engine.tsx b/shared/chat/inbox/engine.tsx index 7c2620c4dc62..543f55652d0b 100644 --- a/shared/chat/inbox/engine.tsx +++ b/shared/chat/inbox/engine.tsx @@ -15,10 +15,13 @@ import { getInboxConversationMeta, metaReceivedError, metasReceived, + onIncomingInboxUIItem, syncInboxParticipantsFromParticipantMap, updateInboxConversationMeta, unboxRows, } from './metadata' +import {useDaemonState} from '@/stores/daemon' +import {EnginePriority, registerEngineHandlers} from '@/engine/action-listener' type ConvoEngineIncomingResult = { handled: boolean @@ -283,3 +286,50 @@ export const handleConvoEngineIncoming = (action: EngineGen.Actions): ConvoEngin return {handled: false} } } + +const routeConvoEngineIncoming = (action: EngineGen.Actions) => { + const result = handleConvoEngineIncoming(action) + if (result.inboxUIItem) { + onIncomingInboxUIItem(result.inboxUIItem) + } + if (result.userReacjis) { + useDaemonState.getState().dispatch.updateUserReacjis(result.userReacjis) + } +} + +registerEngineHandlers( + { + 'chat.1.NotifyChat.ChatAttachmentDownloadComplete': routeConvoEngineIncoming, + 'chat.1.NotifyChat.ChatAttachmentDownloadProgress': routeConvoEngineIncoming, + 'chat.1.NotifyChat.ChatAttachmentUploadProgress': routeConvoEngineIncoming, + 'chat.1.NotifyChat.ChatAttachmentUploadStart': routeConvoEngineIncoming, + 'chat.1.NotifyChat.ChatConvUpdate': routeConvoEngineIncoming, + 'chat.1.NotifyChat.ChatIdentifyUpdate': action => { + const {update} = action.payload.params + const usernames = update.CanonicalName.split(',') + const broken = (update.breaks.breaks || []).map(b => b.user.username) + useUsersState + .getState() + .dispatch.updates(usernames.map(name => ({info: {broken: broken.includes(name)}, name}))) + }, + 'chat.1.NotifyChat.ChatParticipantsInfo': routeConvoEngineIncoming, + 'chat.1.NotifyChat.ChatPaymentInfo': routeConvoEngineIncoming, + 'chat.1.NotifyChat.ChatPromptUnfurl': routeConvoEngineIncoming, + 'chat.1.NotifyChat.ChatRequestInfo': routeConvoEngineIncoming, + 'chat.1.NotifyChat.ChatSetConvRetention': routeConvoEngineIncoming, + 'chat.1.NotifyChat.ChatSetConvSettings': routeConvoEngineIncoming, + 'chat.1.NotifyChat.ChatSetTeamRetention': routeConvoEngineIncoming, + 'chat.1.NotifyChat.ChatSubteamRename': routeConvoEngineIncoming, + 'chat.1.NotifyChat.ChatTLFFinalize': routeConvoEngineIncoming, + 'chat.1.NotifyChat.ChatThreadsStale': routeConvoEngineIncoming, + 'chat.1.NotifyChat.ChatTypingUpdate': routeConvoEngineIncoming, + 'chat.1.NotifyChat.NewChatActivity': routeConvoEngineIncoming, + 'chat.1.chatUi.chatCoinFlipStatus': routeConvoEngineIncoming, + 'chat.1.chatUi.chatCommandMarkdown': routeConvoEngineIncoming, + 'chat.1.chatUi.chatCommandStatus': routeConvoEngineIncoming, + 'chat.1.chatUi.chatGiphySearchResults': routeConvoEngineIncoming, + 'chat.1.chatUi.chatGiphyToggleResultWindow': routeConvoEngineIncoming, + 'chat.1.chatUi.chatInboxFailed': routeConvoEngineIncoming, + }, + {id: 'chat/inbox/engine', priority: EnginePriority.shared} +) diff --git a/shared/chat/inbox/metadata.tsx b/shared/chat/inbox/metadata.tsx index 8c5d1b3b24a0..9fb8839077f5 100644 --- a/shared/chat/inbox/metadata.tsx +++ b/shared/chat/inbox/metadata.tsx @@ -18,6 +18,10 @@ import * as Z from '@/util/zustand' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useUsersState} from '@/stores/users' +import {useWaitingState} from '@/stores/waiting' +import {useInboxLayoutState} from './layout-state' +import {waitingKeyChatInboxSyncStarted} from '@/constants/strings' +import {EnginePriority, registerEngineHandlers} from '@/engine/action-listener' export const getInboxConversationMeta = (conversationIDKey: T.Chat.ConversationIDKey) => useInboxMetadataState.getState().metas.get(conversationIDKey) @@ -673,3 +677,31 @@ export const onChatInboxSynced = async ( await refreshInbox('inboxSyncedUnknown') } } + +registerEngineHandlers( + { + 'chat.1.NotifyChat.ChatInboxStale': () => { + ignorePromise(useInboxLayoutState.getState().dispatch.refresh('inboxStale')) + }, + 'chat.1.NotifyChat.ChatInboxSyncStarted': () => { + useWaitingState.getState().dispatch.increment(waitingKeyChatInboxSyncStarted) + }, + 'chat.1.NotifyChat.ChatInboxSynced': action => { + useWaitingState.getState().dispatch.clear(waitingKeyChatInboxSyncStarted) + ignorePromise( + onChatInboxSynced(action, async reason => useInboxLayoutState.getState().dispatch.refresh(reason)) + ) + }, + 'chat.1.chatUi.chatInboxConversation': onGetInboxConvsUnboxed, + 'chat.1.chatUi.chatInboxLayout': action => { + const {hasLoaded, dispatch} = useInboxLayoutState.getState() + dispatch.updateLayout(action.payload.params.layout) + const {layout} = useInboxLayoutState.getState() + if (layout) { + onInboxLayoutChanged(layout, hasLoaded) + } + }, + 'chat.1.chatUi.chatInboxUnverified': onGetInboxUnverifiedConvs, + }, + {id: 'chat/inbox/metadata', priority: EnginePriority.shared} +) diff --git a/shared/common-adapters/avatar/store.tsx b/shared/common-adapters/avatar/store.tsx index 2eed306b720a..5404e01ee6c0 100644 --- a/shared/common-adapters/avatar/store.tsx +++ b/shared/common-adapters/avatar/store.tsx @@ -1,5 +1,6 @@ import type * as T from '@/constants/types' import * as Z from '@/util/zustand' +import {EnginePriority, registerEngineHandlers} from '@/engine/action-listener' // This store has no dependencies on other stores and is safe to import directly from other stores. type Store = T.Immutable<{ @@ -38,3 +39,12 @@ export const useAvatarState = Z.createZustand(set => { dispatch, } }) + +registerEngineHandlers( + { + 'keybase.1.NotifyTeam.avatarUpdated': action => { + useAvatarState.getState().dispatch.updated(action.payload.params.name) + }, + }, + {id: 'common-adapters/avatar/store', priority: EnginePriority.shared} +) diff --git a/shared/constants/init/index.tsx b/shared/constants/init/index.tsx index d62d164bc544..8db6aadc2c99 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -14,7 +14,12 @@ import logger from '@/logger' import {getEngine} from '@/engine' import {afterKbfsDaemonRpcStatusChanged} from '@/fs/common/lifecycle' import {logState, setThreadInputCommandStatus} from '@/constants/router' -import {initSharedSubscriptions, _onEngineIncoming, onEngineConnected as onSharedEngineConnected} from './shared' +import {initSharedSubscriptions, onEngineConnected as onSharedEngineConnected} from './shared' +import { + EnginePriority, + notifyEngineActionListeners, + registerEngineHandlers, +} from '@/engine/action-listener' import {noConversationIDKey} from '../types/chat/common' import {dumpLogs, persistRoute} from '@/util/storeless-actions' @@ -219,111 +224,108 @@ const loadStartupDetails = async () => { // ─── onEngineIncoming ───────────────────────────────────────────────────────── export const onEngineIncoming = (action: EngineGen.Actions) => { - _onEngineIncoming(action) + notifyEngineActionListeners(action) +} - if (isMobile) { - switch (action.type) { - case 'chat.1.chatUi.triggerContactSync': - useSettingsContactsState.getState().dispatch.manageContactsCache() - break - case 'keybase.1.logUi.log': { - const {params} = action.payload - const {level, text} = params - logger.info('keybase.1.logUi.log:', params.text.data) - if (level >= T.RPCGen.LogLevel.error) { - NotifyPopup(text.data) - } - break +// Notifications the app shell owns rather than any one feature. These ran after +// every other handler for the same action, so they register at that priority. +const onLogUI = (action: EngineGen.ActionOf<'keybase.1.logUi.log'>) => { + const {params} = action.payload + const {level, text} = params + logger.info('keybase.1.logUi.log:', params.text.data) + if (level >= T.RPCGen.LogLevel.error) { + NotifyPopup(text.data) + } +} + +const nativeEngineHandlers = { + 'chat.1.chatUi.chatClearWatch': () => { + ignorePromise(onChatClearWatch()) + }, + 'chat.1.chatUi.chatWatchPosition': ( + action: EngineGen.ActionOf<'chat.1.chatUi.chatWatchPosition'> + ) => { + ignorePromise(onChatWatchPosition(action)) + }, + 'chat.1.chatUi.triggerContactSync': () => { + useSettingsContactsState.getState().dispatch.manageContactsCache() + }, + 'keybase.1.logUi.log': onLogUI, +} + +const desktopEngineHandlers = { + 'keybase.1.NotifyApp.exit': () => { + console.log('App exit requested') + _getDesktop().KB2.functions.exitApp?.(0) + }, + 'keybase.1.NotifyFS.FSActivity': (action: EngineGen.ActionOf<'keybase.1.NotifyFS.FSActivity'>) => { + _getDesktop().kbfsNotification(action.payload.params.notification, (title, opts, onClick) => { + NotifyPopup(title, opts, -1, undefined, onClick) + }) + }, + 'keybase.1.NotifyPGP.pgpKeyInSecretStoreFile': () => { + const f = async () => { + try { + await T.RPCGen.pgpPgpStorageDismissRpcPromise() + } catch (err) { + console.warn('Error in sending pgpPgpStorageDismissRpc:', err) } - case 'chat.1.chatUi.chatWatchPosition': - ignorePromise(onChatWatchPosition(action)) - break - case 'chat.1.chatUi.chatClearWatch': - ignorePromise(onChatClearWatch()) - break - default: } - } else { - const {isWindows, kbfsNotification} = _getDesktop() - switch (action.type) { - case 'keybase.1.logsend.prepareLogsend': { - const f = async () => { - const response = action.payload.response - try { - await dumpLogs() - } finally { - response.result() - } - } - ignorePromise(f()) - break - } - case 'keybase.1.NotifyApp.exit': - console.log('App exit requested') - _getDesktop().KB2.functions.exitApp?.(0) - break - case 'keybase.1.NotifyFS.FSActivity': - kbfsNotification(action.payload.params.notification, (title, opts, onClick) => { NotifyPopup(title, opts, -1, undefined, onClick) }) - break - case 'keybase.1.NotifyPGP.pgpKeyInSecretStoreFile': { - const f = async () => { - try { - await T.RPCGen.pgpPgpStorageDismissRpcPromise() - } catch (err) { - console.warn('Error in sending pgpPgpStorageDismissRpc:', err) - } - } - ignorePromise(f()) - break - } - case 'keybase.1.NotifyService.shutdown': { - const {code} = action.payload.params - if (isWindows && code !== (T.RPCGen.ExitCode.restart as number)) { - console.log('Quitting due to service shutdown with code: ', code) - // Quit just the app, not the service - _getDesktop().KB2.functions.quitApp?.() - } - break - } - case 'keybase.1.logUi.log': { - const {params} = action.payload - const {level, text} = params - logger.info('keybase.1.logUi.log:', params.text.data) - if (level >= T.RPCGen.LogLevel.error) { - NotifyPopup(text.data) - } - break - } - case 'keybase.1.NotifySession.clientOutOfDate': { - const {upgradeTo, upgradeURI, upgradeMsg} = action.payload.params - const body = upgradeMsg || `Please update to ${upgradeTo} by going to ${upgradeURI}` - NotifyPopup('Client out of date!', {body}, 60 * 60) - // This is from the API server. Consider notifications from server always critical. - useConfigState - .getState() - .dispatch.setOutOfDate({critical: true, message: upgradeMsg, outOfDate: true, updating: false}) - break - } - case 'keybase.1.NotifySession.loggedOut': { - if (useConfigState.getState().userSwitching) { - logger.info('Resetting renderer engine for account switch logout') - getEngine().reset() - } - break - } - case 'keybase.1.NotifySession.loggedIn': { - if (useConfigState.getState().userSwitching) { - logger.info('Refreshing renderer session registration for account switch login') - getEngine().reset() - onSharedEngineConnected() - } - break + ignorePromise(f()) + }, + 'keybase.1.NotifyService.shutdown': (action: EngineGen.ActionOf<'keybase.1.NotifyService.shutdown'>) => { + const {code} = action.payload.params + if (_getDesktop().isWindows && code !== (T.RPCGen.ExitCode.restart as number)) { + console.log('Quitting due to service shutdown with code: ', code) + // Quit just the app, not the service + _getDesktop().KB2.functions.quitApp?.() + } + }, + 'keybase.1.NotifySession.clientOutOfDate': ( + action: EngineGen.ActionOf<'keybase.1.NotifySession.clientOutOfDate'> + ) => { + const {upgradeTo, upgradeURI, upgradeMsg} = action.payload.params + const body = upgradeMsg || `Please update to ${upgradeTo} by going to ${upgradeURI}` + NotifyPopup('Client out of date!', {body}, 60 * 60) + // This is from the API server. Consider notifications from server always critical. + useConfigState + .getState() + .dispatch.setOutOfDate({critical: true, message: upgradeMsg, outOfDate: true, updating: false}) + }, + 'keybase.1.NotifySession.loggedIn': () => { + if (useConfigState.getState().userSwitching) { + logger.info('Refreshing renderer session registration for account switch login') + getEngine().reset() + onSharedEngineConnected() + } + }, + 'keybase.1.NotifySession.loggedOut': () => { + if (useConfigState.getState().userSwitching) { + logger.info('Resetting renderer engine for account switch logout') + getEngine().reset() + } + }, + 'keybase.1.logUi.log': onLogUI, + 'keybase.1.logsend.prepareLogsend': ( + action: EngineGen.ActionOf<'keybase.1.logsend.prepareLogsend'> + ) => { + const f = async () => { + const response = action.payload.response + try { + await dumpLogs() + } finally { + response.result() } - default: } - } + ignorePromise(f()) + }, } +registerEngineHandlers(isMobile ? nativeEngineHandlers : desktopEngineHandlers, { + id: 'constants/init/platform', + priority: EnginePriority.platform, +}) + // ─── initPlatformListener ───────────────────────────────────────────────────── const _platformUnsubs: Array<() => void> = __DEV__ diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 85394323a62e..ccf3f5f8decc 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -1,7 +1,4 @@ -import type * as EngineGen from '@/constants/rpc' import * as T from '../types' -import * as S from '@/constants/strings' -import isEqual from 'lodash/isEqual' import logger from '@/logger' import * as Tabs from '@/constants/tabs' declare global { @@ -14,37 +11,36 @@ declare global { var __hmr_TBstores: Map | undefined } import {useBlockButtonsState} from '@/chat/blocking/block-buttons-state' -import {useNotifState} from '@/stores/notifications' -import {notifyEngineActionListeners} from '@/engine/action-listener' + +// Engine handler manifest. Each of these modules registers its own handlers for +// the engine actions it owns when it is first imported; importing them here is +// what guarantees that happens before the engine starts delivering. +import '@/chat/blocking/block-buttons-state' +import '@/chat/inbox/badge-state' +import '@/chat/inbox/engine' +import '@/chat/inbox/metadata' +import '@/common-adapters/avatar/store' +import '@/router-v2/deep-link-emitter' +import '@/stores/config' +import '@/stores/followers-engine' +import '@/stores/notifications' +import '@/stores/settings-email' +import '@/stores/settings-phone' +import '@/stores/users' import {serviceStaticConfigToStaticConfig} from '@/constants/chat/static-config' -import {emitDeepLink} from '@/router-v2/linking' import {ignorePromise, timeoutPromise} from '../utils' import {isPhone, serverConfigFileName} from '../platform' -import {useAvatarState} from '@/common-adapters/avatar/store' import {useInboxLayoutState} from '@/chat/inbox/layout-state' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useDaemonState, type BootstrapStep} from '@/stores/daemon' import {useDarkModeState} from '@/stores/darkmode' -import {useFollowerState} from '@/stores/followers' import {useShellState} from '@/stores/shell' -import {useSettingsEmailState} from '@/stores/settings-email' -import {useSettingsPhoneState} from '@/stores/settings-phone' import {useSettingsContactsState} from '@/stores/settings-contacts' import {useUsersState} from '@/stores/users' -import {useWaitingState} from '@/stores/waiting' import {useRouterState} from '@/stores/router' import * as Util from '@/constants/router' -import {handleConvoEngineIncoming} from '@/chat/inbox/engine' -import { - onChatRouteChanged, - onChatInboxSynced, - onGetInboxConvsUnboxed, - onGetInboxUnverifiedConvs, - onInboxLayoutChanged, - onIncomingInboxUIItem, -} from '@/chat/inbox/metadata' -import {syncInboxBadgeState} from '@/chat/inbox/badge-state' +import {onChatRouteChanged} from '@/chat/inbox/metadata' import {clearSignupEmail} from '@/people/signup-email' import {clearSignupDeviceNameDraft} from '@/signup/device-name-draft' import {clearNavBadges} from '@/teams/actions' @@ -322,177 +318,3 @@ export const initSharedSubscriptions = (platformBootstrapSteps: Array s.navState, onNavStateChanged) ) } - -// This is to defer loading stores we don't need immediately. -export const _onEngineIncoming = (action: EngineGen.Actions) => { - const routeConvoEngineIncoming = (engineAction: EngineGen.Actions) => { - const result = handleConvoEngineIncoming(engineAction) - if (result.inboxUIItem) { - onIncomingInboxUIItem(result.inboxUIItem) - } - if (result.userReacjis) { - useDaemonState.getState().dispatch.updateUserReacjis(result.userReacjis) - } - } - - switch (action.type) { - case 'keybase.1.NotifyBadges.badgeState': - { - const {badgeState} = action.payload.params - syncInboxBadgeState(badgeState) - useNotifState.getState().dispatch.onEngineIncomingImpl(action) - } - break - case 'keybase.1.gregorUI.pushState': { - const {state} = action.payload.params - const items = state.items || [] - const goodState = items.reduce>( - (arr, {md, item}) => { - if (md && item) { - arr.push({item, md}) - } - return arr - }, - [] - ) - if (goodState.length !== items.length) { - logger.warn('Lost some messages in filtering out nonNull gregor items') - } - useBlockButtonsState.getState().dispatch.updateFromGregorItems(state.items) - - useNotifState.getState().dispatch.onEngineIncomingImpl(action) - break - } - case 'chat.1.NotifyChat.ChatSetTeamRetention': - { - routeConvoEngineIncoming(action) - } - break - case 'keybase.1.NotifyEmailAddress.emailAddressVerified': - { - const emailAddress = action.payload.params.emailAddress - if (emailAddress) { - useSettingsEmailState.getState().dispatch.notifyEmailVerified(emailAddress) - } - clearSignupEmail() - } - break - case 'keybase.1.NotifyPhoneNumber.phoneNumbersChanged': { - const {list} = action.payload.params - useSettingsPhoneState.getState().dispatch.notifyPhoneNumberPhoneNumbersChanged(list ?? undefined) - break - } - case 'keybase.1.NotifyEmailAddress.emailsChanged': { - const list = action.payload.params.list ?? [] - useSettingsEmailState.getState().dispatch.notifyEmailAddressEmailsChanged(list) - break - } - case 'chat.1.chatUi.chatInboxFailed': - case 'chat.1.NotifyChat.ChatSetConvSettings': - case 'chat.1.NotifyChat.ChatAttachmentUploadStart': - case 'chat.1.NotifyChat.ChatPromptUnfurl': - case 'chat.1.NotifyChat.ChatPaymentInfo': - case 'chat.1.NotifyChat.ChatRequestInfo': - case 'chat.1.NotifyChat.ChatAttachmentDownloadProgress': - case 'chat.1.NotifyChat.ChatAttachmentDownloadComplete': - case 'chat.1.NotifyChat.ChatAttachmentUploadProgress': - case 'chat.1.chatUi.chatCommandMarkdown': - case 'chat.1.chatUi.chatGiphyToggleResultWindow': - case 'chat.1.chatUi.chatCommandStatus': - case 'chat.1.chatUi.chatGiphySearchResults': - case 'chat.1.NotifyChat.ChatParticipantsInfo': - case 'chat.1.NotifyChat.ChatConvUpdate': - case 'chat.1.chatUi.chatCoinFlipStatus': - case 'chat.1.NotifyChat.ChatThreadsStale': - case 'chat.1.NotifyChat.ChatSubteamRename': - case 'chat.1.NotifyChat.ChatTLFFinalize': - case 'chat.1.NotifyChat.NewChatActivity': - case 'chat.1.NotifyChat.ChatTypingUpdate': - case 'chat.1.NotifyChat.ChatSetConvRetention': - routeConvoEngineIncoming(action) - break - case 'chat.1.NotifyChat.ChatIdentifyUpdate': { - const {update} = action.payload.params - const usernames = update.CanonicalName.split(',') - const broken = (update.breaks.breaks || []).map(b => b.user.username) - const updates = usernames.map(name => ({info: {broken: broken.includes(name)}, name})) - useUsersState.getState().dispatch.updates(updates) - break - } - case 'chat.1.NotifyChat.ChatInboxStale': - ignorePromise(useInboxLayoutState.getState().dispatch.refresh('inboxStale')) - break - case 'chat.1.chatUi.chatInboxUnverified': - onGetInboxUnverifiedConvs(action) - break - case 'chat.1.NotifyChat.ChatInboxSyncStarted': - useWaitingState.getState().dispatch.increment(S.waitingKeyChatInboxSyncStarted) - break - case 'chat.1.NotifyChat.ChatInboxSynced': - useWaitingState.getState().dispatch.clear(S.waitingKeyChatInboxSyncStarted) - ignorePromise( - onChatInboxSynced(action, async reason => useInboxLayoutState.getState().dispatch.refresh(reason)) - ) - break - case 'chat.1.chatUi.chatInboxLayout': { - const {hasLoaded, dispatch} = useInboxLayoutState.getState() - dispatch.updateLayout(action.payload.params.layout) - const {layout} = useInboxLayoutState.getState() - if (layout) { - onInboxLayoutChanged(layout, hasLoaded) - } - break - } - case 'chat.1.chatUi.chatInboxConversation': - onGetInboxConvsUnboxed(action) - break - case 'keybase.1.NotifyService.handleKeybaseLink': - { - const {link, deferred} = action.payload.params - if (deferred && !link.startsWith('keybase://team-invite-link/')) { - return - } - // Route through the linking config; it falls back to handleAppLink - // for URL patterns not handled declaratively. - const fullUrl = link.startsWith('keybase://') ? link : `keybase://${link}` - emitDeepLink(fullUrl) - } - break - case 'keybase.1.NotifyTeam.avatarUpdated': { - const {name} = action.payload.params - useAvatarState.getState().dispatch.updated(name) - break - } - case 'keybase.1.NotifyTracking.trackingChanged': { - const {isTracking, username} = action.payload.params - useFollowerState.getState().dispatch.updateFollowing(username, isTracking) - break - } - case 'keybase.1.NotifyTracking.trackingInfo': { - const {uid, followers: _newFollowers, followees: _newFollowing} = action.payload.params - if (useCurrentUserState.getState().uid !== uid) { - break - } - const newFollowers = new Set(_newFollowers) - const newFollowing = new Set(_newFollowing) - const {following: oldFollowing, followers: oldFollowers, dispatch} = useFollowerState.getState() - const following = isEqual(newFollowing, oldFollowing) ? oldFollowing : newFollowing - const followers = isEqual(newFollowers, oldFollowers) ? oldFollowers : newFollowers - dispatch.replace(followers, following) - break - } - case 'keybase.1.NotifyTracking.notifyUserBlocked': - { - useUsersState.getState().dispatch.onEngineIncomingImpl(action) - } - break - case 'keybase.1.NotifyUsers.identifyUpdate': - { - useUsersState.getState().dispatch.onEngineIncomingImpl(action) - } - break - default: - } - useConfigState.getState().dispatch.onEngineIncoming(action) - notifyEngineActionListeners(action) -} diff --git a/shared/devices/index.test.tsx b/shared/devices/index.test.tsx index 1b5e02997c63..21e7df07b9ca 100644 --- a/shared/devices/index.test.tsx +++ b/shared/devices/index.test.tsx @@ -67,7 +67,10 @@ jest.mock('@/util/use-local-badging', () => { useLocalBadging: () => ({badged: mockBadged}), } }) -jest.mock('@/engine/action-listener', () => ({useEngineActionListener: () => {}})) +jest.mock('@/engine/action-listener', () => ({ + ...jest.requireActual('@/engine/action-listener'), + useEngineActionListener: () => {}, +})) jest.mock('@react-navigation/native', () => ({useNavigation: () => ({setOptions: () => {}})})) let mockRPCResults: Array = [] diff --git a/shared/engine/action-listener.test.ts b/shared/engine/action-listener.test.ts index 1242ef301b1f..c3c5608af7b4 100644 --- a/shared/engine/action-listener.test.ts +++ b/shared/engine/action-listener.test.ts @@ -3,15 +3,28 @@ import {resetAllStores} from '@/util/zustand' import { clearAllEngineActionListeners, + EnginePriority, notifyEngineActionListeners, + registerEngineHandlers, subscribeToEngineAction, } from './action-listener' +const homeUIRefresh = {payload: {params: {}}, type: 'keybase.1.homeUI.homeUIRefresh'} as never + afterEach(() => { jest.restoreAllMocks() resetAllStores() + clearAllEngineActionListeners() + unregisterAll() }) +// registerEngineHandlers deliberately survives resetAllStores, so this file has +// to take its own registrations back down between tests +const registrations: Array<() => void> = [] +const unregisterAll = () => { + for (const unregister of registrations.splice(0, registrations.length)) unregister() +} + test('engine action listeners only fire for matching action types', () => { const homeListener = jest.fn() const badgeListener = jest.fn() @@ -84,3 +97,60 @@ test('a stale unsubscribe from before a reset leaves later subscribers alone', ( expect(after).toHaveBeenCalledTimes(1) expect(before).not.toHaveBeenCalled() }) + +// Case-arm position used to decide this; a registration has to say it out loud. +test('handlers run in priority order, then registration order', () => { + const order: Array = [] + subscribeToEngineAction('keybase.1.homeUI.homeUIRefresh', () => order.push('component')) + registrations.push(registerEngineHandlers( + {'keybase.1.homeUI.homeUIRefresh': () => order.push('platform')}, + {priority: EnginePriority.platform} + )) + registrations.push(registerEngineHandlers( + {'keybase.1.homeUI.homeUIRefresh': () => order.push('sharedSecond')}, + {priority: EnginePriority.shared} + )) + registrations.push(registerEngineHandlers( + {'keybase.1.homeUI.homeUIRefresh': () => order.push('sharedFirst')}, + {priority: EnginePriority.sharedFirst} + )) + registrations.push(registerEngineHandlers( + {'keybase.1.homeUI.homeUIRefresh': () => order.push('config')}, + {priority: EnginePriority.config} + )) + + notifyEngineActionListeners(homeUIRefresh) + + expect(order).toEqual(['sharedFirst', 'sharedSecond', 'config', 'component', 'platform']) +}) + +// Module init is what installs these, and nothing re-runs it after a sign-out. +test('a sign-out reset keeps module registrations and drops component subscriptions', () => { + const registered = jest.fn() + const subscribed = jest.fn() + registrations.push(registerEngineHandlers({'keybase.1.homeUI.homeUIRefresh': registered})) + subscribeToEngineAction('keybase.1.homeUI.homeUIRefresh', subscribed) + + resetAllStores() + notifyEngineActionListeners(homeUIRefresh) + + expect(registered).toHaveBeenCalledTimes(1) + expect(subscribed).not.toHaveBeenCalled() +}) + +test('unregistering removes every type the registration covered', () => { + const home = jest.fn() + const badge = jest.fn() + const unregister = registerEngineHandlers({ + 'keybase.1.NotifyBadges.badgeState': badge, + 'keybase.1.homeUI.homeUIRefresh': home, + }) + registrations.push(unregister) + + unregister() + notifyEngineActionListeners(homeUIRefresh) + notifyEngineActionListeners({payload: {params: {}}, type: 'keybase.1.NotifyBadges.badgeState'} as never) + + expect(home).not.toHaveBeenCalled() + expect(badge).not.toHaveBeenCalled() +}) diff --git a/shared/engine/action-listener.tsx b/shared/engine/action-listener.tsx index 7c552f915e7c..016154f02837 100644 --- a/shared/engine/action-listener.tsx +++ b/shared/engine/action-listener.tsx @@ -5,40 +5,128 @@ import {registerExternalResetter} from '@/util/zustand' type AnyListener = (action: EngineGen.Actions) => void +type Registration = { + fn: AnyListener + // lower runs first; ties break on registration order + priority: number + seq: number + // module-init registrations outlive a sign-out; component subscriptions do not + permanent: boolean +} + declare global { - var __hmr_engineActionListeners: Map> | undefined + var __hmr_engineActionListeners: Map> | undefined + + var __hmr_engineHandlerRegistrations: Map void> | undefined + + var __hmr_engineListenerSeq: {next: number} | undefined } -const listenersByType: Map> = __DEV__ +const listenersByType: Map> = __DEV__ ? (globalThis.__hmr_engineActionListeners ??= new Map()) : new Map() -const getListeners = (type: EngineGen.ActionType) => { - let listeners = listenersByType.get(type) - if (!listeners) { - listeners = new Set() - listenersByType.set(type, listeners) +// The three central switches this replaced ran before any component +// subscription, and the platform switch ran after all of them. Ordering that +// used to be case-arm position is now stated here. +export const EnginePriority = { + /** the shared init switch: store-level fan-out, before anything reads the result */ + shared: -300, + /** ...and inside it, work a later handler for the same action depends on */ + sharedFirst: -400, + /** the config store's own switch, which ran right after the shared one */ + config: -200, + /** default: component subscriptions and anything with no ordering opinion */ + default: 0, + /** the platform switch, which ran after everything else */ + platform: 100, +} as const + +// Rides on globalThis with the map it stamps: if this module alone hot-reloads, +// a counter that restarted at 0 would sort every new registration ahead of the +// surviving ones, silently inverting the order priorities exist to pin. +const seq = __DEV__ ? (globalThis.__hmr_engineListenerSeq ??= {next: 0}) : {next: 0} + +const insert = (type: EngineGen.ActionType, registration: Registration) => { + let registrations = listenersByType.get(type) + if (!registrations) { + registrations = [] + listenersByType.set(type, registrations) + } + const at = registrations.findIndex( + r => r.priority > registration.priority || (r.priority === registration.priority && r.seq > registration.seq) + ) + if (at === -1) { + registrations.push(registration) + } else { + registrations.splice(at, 0, registration) + } + return () => { + const live = listenersByType.get(type) + // Only touch the array the registration actually went into. A reset replaces + // the array for a type, so an unsubscribe left over from before the reset + // would otherwise splice a live entry out by index or delete the live array, + // silently unsubscribing everybody who registered after the reset. + if (live !== registrations) { + return + } + const idx = registrations.indexOf(registration) + if (idx !== -1) { + registrations.splice(idx, 1) + } + if (!registrations.length) { + listenersByType.delete(type) + } } - return listeners } export const subscribeToEngineAction = ( type: T, listener: (action: EngineGen.ActionOf) => void +) => + insert(type, { + fn: listener as unknown as AnyListener, + permanent: false, + priority: EnginePriority.default, + seq: seq.next++, + }) + +export type EngineHandlers = { + [T in EngineGen.ActionType]?: (action: EngineGen.ActionOf) => void +} + +/** + * Register a feature's own handlers for incoming engine actions, once, at module + * init. Unlike subscribeToEngineAction these survive a sign-out reset, because + * nothing re-runs module init to put them back. + * + * `id` makes a re-registration (HMR re-executing the module) replace the previous + * one instead of doubling it. + */ +export const registerEngineHandlers = ( + handlers: EngineHandlers, + options?: {id?: string; priority?: number} ) => { - const listeners = getListeners(type) - const untypedListener = listener as unknown as AnyListener - listeners.add(untypedListener) - return () => { - listeners.delete(untypedListener) - // Only drop the entry if the map still holds THIS set. A reset replaces the - // set for a type, so an unsubscribe left over from before the reset would - // otherwise see its own detached, now-empty set and delete the live one, - // silently unsubscribing everybody who registered after the reset. - if (!listeners.size && listenersByType.get(type) === listeners) { - listenersByType.delete(type) - } + const {id, priority = EnginePriority.default} = options ?? {} + if (__DEV__ && id) { + const previous = (globalThis.__hmr_engineHandlerRegistrations ??= new Map()).get(id) + previous?.() + } + const unsubs = Object.entries(handlers).map(([type, fn]) => + insert(type as EngineGen.ActionType, { + fn: fn as AnyListener, + permanent: true, + priority, + seq: seq.next++, + }) + ) + const unregister = () => { + for (const unsub of unsubs) unsub() } + if (__DEV__ && id) { + globalThis.__hmr_engineHandlerRegistrations?.set(id, unregister) + } + return unregister } export const useEngineActionListener = ( @@ -56,21 +144,34 @@ export const useEngineActionListener = ( } export const notifyEngineActionListeners = (action: EngineGen.Actions) => { - const listeners = listenersByType.get(action.type) - if (!listeners?.size) { + const registrations = listenersByType.get(action.type) + if (!registrations?.length) { return } - for (const listener of [...listeners]) { + for (const {fn} of [...registrations]) { try { - listener(action) + fn(action) } catch (error) { logger.error(`Error in engine action listener for ${action.type}`, error) } } } +// Sign-out drops what components subscribed, not what modules registered at +// init: nothing re-runs module init to put those back. export const clearAllEngineActionListeners = () => { - listenersByType.clear() + // spliced in place rather than replaced, so the unregister a module-init + // registration is holding still points at the array its entry lives in + for (const [type, registrations] of [...listenersByType]) { + for (let i = registrations.length - 1; i >= 0; i--) { + if (!registrations[i]?.permanent) { + registrations.splice(i, 1) + } + } + if (!registrations.length) { + listenersByType.delete(type) + } + } } registerExternalResetter('engine-action-listeners', clearAllEngineActionListeners) diff --git a/shared/router-v2/deep-link-emitter.tsx b/shared/router-v2/deep-link-emitter.tsx index e3a39b62f4f2..07e5ce8165f1 100644 --- a/shared/router-v2/deep-link-emitter.tsx +++ b/shared/router-v2/deep-link-emitter.tsx @@ -2,6 +2,7 @@ import { type NavigationIntentOptions, useNavigationIntentsState, } from '@/stores/navigation-intents' +import {EnginePriority, registerEngineHandlers} from '@/engine/action-listener' // Deep-link emission + URL normalization. Kept separate from './linking' // (which imports the config/push/current-user stores) so stores/push can enqueue @@ -71,3 +72,22 @@ export const emitDeepLink = (url: string, options?: NavigationIntentOptions) => if (!normalized) return useNavigationIntentsState.getState().dispatch.enqueue(normalized, options) } + +registerEngineHandlers( + { + 'keybase.1.NotifyService.handleKeybaseLink': action => { + const {link, deferred} = action.payload.params + // Only this handler is skipped. The central switch this replaced returned + // out of the whole dispatch here, so it also suppressed every other + // listener for the action; nothing else subscribes to it, and suppressing + // unrelated listeners was never the intent. + if (deferred && !link.startsWith('keybase://team-invite-link/')) { + return + } + // Route through the linking config; it falls back to handleAppLink + // for URL patterns not handled declaratively. + emitDeepLink(link.startsWith('keybase://') ? link : `keybase://${link}`) + }, + }, + {id: 'router-v2/deep-link-emitter', priority: EnginePriority.shared} +) diff --git a/shared/stores/config.tsx b/shared/stores/config.tsx index f0ab46999359..1ef94026ee1c 100644 --- a/shared/stores/config.tsx +++ b/shared/stores/config.tsx @@ -1,8 +1,8 @@ import * as T from '@/constants/types' import {ignorePromise, timeoutPromise} from '@/constants/utils' import {waitingKeyConfigLogin, waitingKeyConfigLoginAsOther} from '@/constants/strings' -import type * as EngineGen from '@/constants/rpc' import * as Z from '@/util/zustand' +import {EnginePriority, registerEngineHandlers} from '@/engine/action-listener' import {noConversationIDKey} from '@/constants/types/chat/common' import isEqual from 'lodash/isEqual' import logger from '@/logger' @@ -102,7 +102,6 @@ export type State = Store & { logoutToLoggedOutFlow: () => void logoutAndTryToLogInAs: (username: string) => void onEngineConnected: () => void - onEngineIncoming: (action: EngineGen.Actions) => void powerMonitorEvent: (event: string) => void resetState: (isDebug?: boolean) => void resetRevokedSelf: () => void @@ -161,32 +160,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { }) } - const setGregorPushState = (state: T.RPCGen.Gregor1.State) => { - const items = state.items || [] - const goodState = items.reduce>( - (arr, {md, item}) => { - if (md && item) { - arr.push({item, md}) - } - return arr - }, - [] - ) - if (goodState.length !== items.length) { - logger.warn('Lost some messages in filtering out nonNull gregor items') - } - set(s => { - s.gregorPushState = T.castDraft(goodState) - s.allowAnimatedEmojis = !goodState.find(i => i.item.category === 'emojianimations') - }) - } - - const updateRuntimeStats = (stats?: T.RPCGen.RuntimeStats) => { - set(s => { - s.runtimeStats = stats ? T.castDraft({...s.runtimeStats, ...stats}) : undefined - }) - } - const dispatch: State['dispatch'] = { checkForUpdate: () => { const f = async () => { @@ -347,62 +320,6 @@ export const useConfigState = Z.createZustand('config', (set, get) => { onEngineConnectedInPlatform() }, - onEngineIncoming: action => { - switch (action.type) { - case 'keybase.1.NotifyAudit.rootAuditError': - get().dispatch.setGlobalError( - new Error(`Keybase is buggy, please report this: ${action.payload.params.message}`) - ) - break - case 'keybase.1.NotifyAudit.boxAuditError': - get().dispatch.setGlobalError( - new Error( - `Keybase had a problem loading a team, please report this with \`keybase log send\`: ${action.payload.params.message}` - ) - ) - break - case 'keybase.1.NotifyBadges.badgeState': - get().dispatch.setBadgeState(action.payload.params.badgeState) - break - case 'keybase.1.gregorUI.pushState': { - const {state} = action.payload.params - setGregorPushState(state) - break - } - case 'keybase.1.NotifyRuntimeStats.runtimeStatsUpdate': { - updateRuntimeStats(action.payload.params.stats ?? undefined) - break - } - case 'keybase.1.NotifyService.HTTPSrvInfoUpdate': { - get().dispatch.setHTTPSrvInfo(action.payload.params.info.address, action.payload.params.info.token) - break - } - case 'keybase.1.NotifySession.loggedIn': { - logger.info('keybase.1.NotifySession.loggedIn') - // only send this if we think we're not logged in - const {loggedIn, dispatch} = get() - if (!loggedIn) { - dispatch.setLoggedIn(true) - } - break - } - case 'keybase.1.NotifySession.loggedOut': { - logger.info('keybase.1.NotifySession.loggedOut') - const {loggedIn, dispatch} = get() - // only send this if we think we're logged in (errors on provison can trigger this and mess things up) - if (loggedIn) { - dispatch.setLoggedIn(false) - } - break - } - case 'keybase.1.reachability.reachabilityChanged': - if (get().loggedIn) { - get().dispatch.setGregorReachable(action.payload.params.reachability.reachable) - } - break - default: - } - }, powerMonitorEvent: event => { const f = async () => { await T.RPCGen.appStatePowerMonitorEventRpcPromise({event}) @@ -608,3 +525,83 @@ export const useConfigState = Z.createZustand('config', (set, get) => { dispatch, } }) + +const setGregorPushState = (state: T.RPCGen.Gregor1.State) => { + const items = state.items || [] + const goodState = items.reduce>( + (arr, {md, item}) => { + if (md && item) { + arr.push({item, md}) + } + return arr + }, + [] + ) + if (goodState.length !== items.length) { + logger.warn('Lost some messages in filtering out nonNull gregor items') + } + useConfigState.setState(s => { + s.gregorPushState = T.castDraft(goodState) + s.allowAnimatedEmojis = !goodState.find(i => i.item.category === 'emojianimations') + }) +} + +const updateRuntimeStats = (stats?: T.RPCGen.RuntimeStats) => { + useConfigState.setState(s => { + s.runtimeStats = stats ? T.castDraft({...s.runtimeStats, ...stats}) : undefined + }) +} + +// Module scope on purpose: registering inside the zustand creator would close +// over the store instance createZustand throws away on a hot reload, leaving +// these writing into an orphan while the app renders from the surviving one. +registerEngineHandlers( + { + 'keybase.1.NotifyAudit.boxAuditError': action => { + useConfigState.getState().dispatch.setGlobalError( + new Error( + `Keybase had a problem loading a team, please report this with \`keybase log send\`: ${action.payload.params.message}` + ) + ) + }, + 'keybase.1.NotifyAudit.rootAuditError': action => { + useConfigState.getState().dispatch.setGlobalError( + new Error(`Keybase is buggy, please report this: ${action.payload.params.message}`) + ) + }, + 'keybase.1.NotifyBadges.badgeState': action => { + useConfigState.getState().dispatch.setBadgeState(action.payload.params.badgeState) + }, + 'keybase.1.NotifyRuntimeStats.runtimeStatsUpdate': action => { + updateRuntimeStats(action.payload.params.stats ?? undefined) + }, + 'keybase.1.NotifyService.HTTPSrvInfoUpdate': action => { + useConfigState.getState().dispatch.setHTTPSrvInfo(action.payload.params.info.address, action.payload.params.info.token) + }, + 'keybase.1.NotifySession.loggedIn': () => { + logger.info('keybase.1.NotifySession.loggedIn') + // only send this if we think we're not logged in + const {loggedIn, dispatch} = useConfigState.getState() + if (!loggedIn) { + dispatch.setLoggedIn(true) + } + }, + 'keybase.1.NotifySession.loggedOut': () => { + logger.info('keybase.1.NotifySession.loggedOut') + const {loggedIn, dispatch} = useConfigState.getState() + // only send this if we think we're logged in (errors on provison can trigger this and mess things up) + if (loggedIn) { + dispatch.setLoggedIn(false) + } + }, + 'keybase.1.gregorUI.pushState': action => { + setGregorPushState(action.payload.params.state) + }, + 'keybase.1.reachability.reachabilityChanged': action => { + if (useConfigState.getState().loggedIn) { + useConfigState.getState().dispatch.setGregorReachable(action.payload.params.reachability.reachable) + } + }, + }, + {id: 'stores/config', priority: EnginePriority.config} +) diff --git a/shared/stores/followers-engine.tsx b/shared/stores/followers-engine.tsx new file mode 100644 index 000000000000..d4aca98f3667 --- /dev/null +++ b/shared/stores/followers-engine.tsx @@ -0,0 +1,29 @@ +import isEqual from 'lodash/isEqual' +import {EnginePriority, registerEngineHandlers} from '@/engine/action-listener' +import {useCurrentUserState} from '@/stores/current-user' +import {useFollowerState} from '@/stores/followers' + +// The follower store is imported by other stores and so deliberately depends on +// nothing; its engine wiring, which needs the current user, lives out here. +registerEngineHandlers( + { + 'keybase.1.NotifyTracking.trackingChanged': action => { + const {isTracking, username} = action.payload.params + useFollowerState.getState().dispatch.updateFollowing(username, isTracking) + }, + 'keybase.1.NotifyTracking.trackingInfo': action => { + const {uid, followers: _newFollowers, followees: _newFollowing} = action.payload.params + if (useCurrentUserState.getState().uid !== uid) { + return + } + const newFollowers = new Set(_newFollowers) + const newFollowing = new Set(_newFollowing) + const {following: oldFollowing, followers: oldFollowers, dispatch} = useFollowerState.getState() + dispatch.replace( + isEqual(newFollowers, oldFollowers) ? oldFollowers : newFollowers, + isEqual(newFollowing, oldFollowing) ? oldFollowing : newFollowing + ) + }, + }, + {id: 'stores/followers-engine', priority: EnginePriority.shared} +) diff --git a/shared/stores/notifications.test.tsx b/shared/stores/notifications.test.tsx index f91334f4da1a..58553d3384fc 100644 --- a/shared/stores/notifications.test.tsx +++ b/shared/stores/notifications.test.tsx @@ -1,4 +1,5 @@ /// +import {notifyEngineActionListeners} from '@/engine/action-listener' import type * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' import {useNotifState} from './notifications' @@ -52,8 +53,7 @@ describe('notifications store identity stability', () => { }) it('keeps team map identities stable across badgeStates with unchanged team data', () => { - const dispatch = useNotifState.getState().dispatch - dispatch.onEngineIncomingImpl( + notifyEngineActionListeners( badgeAction( makeBadgeState({ inboxVers: 10, @@ -68,7 +68,7 @@ describe('notifications store identity stability', () => { expect(before.navBadges.get('tabs.chatTab' as never) ?? 0).toBeGreaterThanOrEqual(0) // same team data, only chat badge count moved (an incoming message) - dispatch.onEngineIncomingImpl( + notifyEngineActionListeners( badgeAction( makeBadgeState({ inboxVers: 11, @@ -87,8 +87,7 @@ describe('notifications store identity stability', () => { }) it('keeps newTeamRequests identity stable across equal gregor pushStates', () => { - const dispatch = useNotifState.getState().dispatch - dispatch.onEngineIncomingImpl( + notifyEngineActionListeners( gregorAction([ {body: JSON.stringify({id: 'teamA', username: 'testuser'}), category: 'team.request_access:teamA'}, ]) @@ -96,7 +95,7 @@ describe('notifications store identity stability', () => { const before = useNotifState.getState().newTeamRequests expect(before.get('teamA' as never)?.has('testuser')).toBe(true) - dispatch.onEngineIncomingImpl( + notifyEngineActionListeners( gregorAction([ {body: JSON.stringify({id: 'teamA', username: 'testuser'}), category: 'team.request_access:teamA'}, ]) diff --git a/shared/stores/notifications.tsx b/shared/stores/notifications.tsx index 0e5ca0e67cc5..d79e69dc4aea 100644 --- a/shared/stores/notifications.tsx +++ b/shared/stores/notifications.tsx @@ -8,6 +8,7 @@ import * as Tabs from '@/constants/tabs' import logger from '@/logger' import {mapGetEnsureValue} from '@/util/map' import {useCurrentUserState} from '@/stores/current-user' +import {EnginePriority, registerEngineHandlers} from '@/engine/action-listener' export type BadgeType = 'regular' | 'update' | 'error' | 'uploading' export type NotificationKeys = 'kbfsUploading' | 'outOfSpace' @@ -42,7 +43,6 @@ const initialStore: Store = { export type State = Store & { dispatch: { clearDeviceBadges: () => void - onEngineIncomingImpl: (action: EngineGen.Actions) => void resetState: () => void badgeApp: (key: NotificationKeys, on: boolean) => void setBadgeCounts: (counts: Map) => void @@ -138,85 +138,6 @@ export const useNotifState = Z.createZustand('notifications', (set, get) s.deviceBadges = new Set() }) }, - onEngineIncomingImpl: action => { - switch (action.type) { - case 'keybase.1.NotifyBadges.badgeState': { - const badgeState = action.payload.params.badgeState - // device badges track the latest server state even when the inbox - // version guard below skips the rest - set(s => { - s.deviceBadges = new Set([ - ...(badgeState.newDevices ?? []), - ...(badgeState.revokedDevices ?? []), - ]) - }) - const currentBadgeVersion = get().badgeVersion - if (currentBadgeVersion > badgeState.inboxVers) { - break - } - // badgeState fires on every incoming message; keep identities stable when the - // team data didn't change so subscribers (TeamsRoot etc) can bail. Compare - // against committed state, not the draft (immer 11 breaks lodash isEqual on drafts). - { - const prev = get() - const deletedTeams = badgeState.deletedTeams ?? [] - const newTeams = new Set(badgeState.newTeams ?? []) - const teamIDToResetUsers = badgeStateToTeamIDToResetUsers(badgeState) - set(s => { - if (!isEqual(prev.deletedTeams, deletedTeams)) { - s.deletedTeams = T.castDraft(deletedTeams) - } - if (!isEqual(prev.newTeams, newTeams)) { - s.newTeams = newTeams - } - if (!isEqual(prev.teamIDToResetUsers, teamIDToResetUsers)) { - s.teamIDToResetUsers = teamIDToResetUsers - } - }) - } - if (currentBadgeVersion === badgeState.inboxVers) { - // Teams badge detail can change without advancing inboxVers, so keep the - // Teams tab badge in sync with the latest server-owned badge state. - get().dispatch.setBadgeCounts( - new Map([[Tabs.teamsTab, badgeStateToBadgeCounts(badgeState).get(Tabs.teamsTab) ?? 0]]) - ) - break - } - set(s => { - s.badgeVersion = badgeState.inboxVers - }) - const counts = badgeStateToBadgeCounts(badgeState) - get().dispatch.setBadgeCounts(counts) - break - } - case 'keybase.1.gregorUI.pushState': { - const {state} = action.payload.params - const items = state.items || [] - const goodState = items.reduce>( - (arr, {md, item}) => { - if (md && item) { - arr.push({item, md}) - } - return arr - }, - [] - ) - if (goodState.length !== items.length) { - logger.warn('Lost some messages in filtering out nonNull gregor items') - } - { - const newTeamRequests = gregorItemsToNewTeamRequests(goodState) - if (!isEqual(get().newTeamRequests, newTeamRequests)) { - set(s => { - s.newTeamRequests = newTeamRequests - }) - } - } - break - } - default: - } - }, resetState: Z.defaultReset, setBadgeCounts: counts => { set(s => { @@ -259,3 +180,86 @@ export const useNotifState = Z.createZustand('notifications', (set, get) dispatch, } }) + +const onBadgeState = (action: EngineGen.ActionOf<'keybase.1.NotifyBadges.badgeState'>) => { + const get = () => useNotifState.getState() + const set = (fn: Parameters[0]) => { + useNotifState.setState(fn) + } + const badgeState = action.payload.params.badgeState + // device badges track the latest server state even when the inbox + // version guard below skips the rest + set(s => { + s.deviceBadges = new Set([...(badgeState.newDevices ?? []), ...(badgeState.revokedDevices ?? [])]) + }) + const currentBadgeVersion = get().badgeVersion + if (currentBadgeVersion > badgeState.inboxVers) { + return + } + // badgeState fires on every incoming message; keep identities stable when the + // team data didn't change so subscribers (TeamsRoot etc) can bail. Compare + // against committed state, not the draft (immer 11 breaks lodash isEqual on drafts). + { + const prev = get() + const deletedTeams = badgeState.deletedTeams ?? [] + const newTeams = new Set(badgeState.newTeams ?? []) + const teamIDToResetUsers = badgeStateToTeamIDToResetUsers(badgeState) + set(s => { + if (!isEqual(prev.deletedTeams, deletedTeams)) { + s.deletedTeams = T.castDraft(deletedTeams) + } + if (!isEqual(prev.newTeams, newTeams)) { + s.newTeams = newTeams + } + if (!isEqual(prev.teamIDToResetUsers, teamIDToResetUsers)) { + s.teamIDToResetUsers = teamIDToResetUsers + } + }) + } + if (currentBadgeVersion === badgeState.inboxVers) { + // Teams badge detail can change without advancing inboxVers, so keep the + // Teams tab badge in sync with the latest server-owned badge state. + get().dispatch.setBadgeCounts( + new Map([[Tabs.teamsTab, badgeStateToBadgeCounts(badgeState).get(Tabs.teamsTab) ?? 0]]) + ) + return + } + set(s => { + s.badgeVersion = badgeState.inboxVers + }) + get().dispatch.setBadgeCounts(badgeStateToBadgeCounts(badgeState)) +} + +const onGregorPushState = (action: EngineGen.ActionOf<'keybase.1.gregorUI.pushState'>) => { + const get = () => useNotifState.getState() + const set = (fn: Parameters[0]) => { + useNotifState.setState(fn) + } + const items = action.payload.params.state.items || [] + const goodState = items.reduce>( + (arr, {md, item}) => { + if (md && item) { + arr.push({item, md}) + } + return arr + }, + [] + ) + if (goodState.length !== items.length) { + logger.warn('Lost some messages in filtering out nonNull gregor items') + } + const newTeamRequests = gregorItemsToNewTeamRequests(goodState) + if (!isEqual(get().newTeamRequests, newTeamRequests)) { + set(s => { + s.newTeamRequests = newTeamRequests + }) + } +} + +registerEngineHandlers( + { + 'keybase.1.NotifyBadges.badgeState': onBadgeState, + 'keybase.1.gregorUI.pushState': onGregorPushState, + }, + {id: 'stores/notifications', priority: EnginePriority.shared} +) diff --git a/shared/stores/settings-email.tsx b/shared/stores/settings-email.tsx index 2e89664488ac..c74e62abc315 100644 --- a/shared/stores/settings-email.tsx +++ b/shared/stores/settings-email.tsx @@ -1,4 +1,6 @@ import * as Z from '@/util/zustand' +import {EnginePriority, registerEngineHandlers} from '@/engine/action-listener' +import {clearSignupEmail} from '@/people/signup-email' import type * as T from '@/constants/types' import logger from '@/logger' @@ -66,3 +68,21 @@ export const useSettingsEmailState = Z.createZustand('settings-email', se dispatch, } }) + +registerEngineHandlers( + { + 'keybase.1.NotifyEmailAddress.emailAddressVerified': action => { + const {emailAddress} = action.payload.params + if (emailAddress) { + useSettingsEmailState.getState().dispatch.notifyEmailVerified(emailAddress) + } + clearSignupEmail() + }, + 'keybase.1.NotifyEmailAddress.emailsChanged': action => { + useSettingsEmailState + .getState() + .dispatch.notifyEmailAddressEmailsChanged(action.payload.params.list ?? []) + }, + }, + {id: 'stores/settings-email', priority: EnginePriority.shared} +) diff --git a/shared/stores/settings-phone.tsx b/shared/stores/settings-phone.tsx index 047ccb9a46d7..59d920202341 100644 --- a/shared/stores/settings-phone.tsx +++ b/shared/stores/settings-phone.tsx @@ -1,6 +1,7 @@ import type * as T from '@/constants/types' import * as RPCGen from '@/constants/rpc/rpc-gen' import * as Z from '@/util/zustand' +import {EnginePriority, registerEngineHandlers} from '@/engine/action-listener' import type {RPCError} from '@/util/errors' import {e164ToDisplay} from '@/util/phone-numbers' @@ -89,3 +90,14 @@ export const useSettingsPhoneState = Z.createZustand('settings-phone', se dispatch, } }) + +registerEngineHandlers( + { + 'keybase.1.NotifyPhoneNumber.phoneNumbersChanged': action => { + useSettingsPhoneState + .getState() + .dispatch.notifyPhoneNumberPhoneNumbersChanged(action.payload.params.list ?? undefined) + }, + }, + {id: 'stores/settings-phone', priority: EnginePriority.shared} +) diff --git a/shared/stores/tests/config.test.ts b/shared/stores/tests/config.test.ts index 1df0b762284f..67f8f5a57285 100644 --- a/shared/stores/tests/config.test.ts +++ b/shared/stores/tests/config.test.ts @@ -1,5 +1,6 @@ /// import {noConversationIDKey} from '../../constants/types/chat/common' +import {notifyEngineActionListeners} from '@/engine/action-listener' import {useConfigState} from '../config' const resetConfigState = () => { @@ -75,17 +76,16 @@ test('setOutOfDate merges fields and setGlobalError normalizes unknown input', ( expect(state.globalError?.message).toBe('Unknown error: "boom"') }) -test('onEngineIncoming owns audit errors and badge state', () => { - const {dispatch} = useConfigState.getState() +test('config owns audit errors and badge state off the engine action stream', () => { const badgeState = {inboxVers: 7} as any - dispatch.onEngineIncoming({ + notifyEngineActionListeners({ payload: {params: {badgeState}}, type: 'keybase.1.NotifyBadges.badgeState', } as any) expect(useConfigState.getState().badgeState).toEqual(badgeState) - dispatch.onEngineIncoming({ + notifyEngineActionListeners({ payload: {params: {message: 'root bad'}}, type: 'keybase.1.NotifyAudit.rootAuditError', } as any) @@ -93,7 +93,7 @@ test('onEngineIncoming owns audit errors and badge state', () => { 'Keybase is buggy, please report this: root bad' ) - dispatch.onEngineIncoming({ + notifyEngineActionListeners({ payload: {params: {message: 'box bad'}}, type: 'keybase.1.NotifyAudit.boxAuditError', } as any) diff --git a/shared/stores/tests/notifications.test.ts b/shared/stores/tests/notifications.test.ts index fc1f0c5dc38f..3716357ce2c5 100644 --- a/shared/stores/tests/notifications.test.ts +++ b/shared/stores/tests/notifications.test.ts @@ -1,8 +1,12 @@ /// import * as Tabs from '@/constants/tabs' +import {notifyEngineActionListeners} from '@/engine/action-listener' import {resetAllStores} from '@/util/zustand' import {useCurrentUserState} from '../current-user' import {useNotifState} from '../notifications' +import {useConfigState} from '../config' +import {useInboxBadgeState} from '@/chat/inbox/badge-state' +import * as T from '@/constants/types' beforeEach(() => { useCurrentUserState.getState().dispatch.setBootstrap({ @@ -55,7 +59,7 @@ test('badge engine updates badge counts', () => { unverifiedPhones: 2, } as any - store.getState().dispatch.onEngineIncomingImpl({ + notifyEngineActionListeners({ payload: {params: {badgeState}}, type: 'keybase.1.NotifyBadges.badgeState', } as any) @@ -76,7 +80,7 @@ test('badge engine updates badge counts', () => { test('stale badgeState events do not regress badge counts', () => { const store = useNotifState - store.getState().dispatch.onEngineIncomingImpl({ + notifyEngineActionListeners({ payload: { params: { badgeState: { @@ -93,7 +97,7 @@ test('stale badgeState events do not regress badge counts', () => { type: 'keybase.1.NotifyBadges.badgeState', } as any) - store.getState().dispatch.onEngineIncomingImpl({ + notifyEngineActionListeners({ payload: { params: { badgeState: { @@ -123,7 +127,7 @@ test('stale badgeState events do not regress badge counts', () => { test('same-version badgeState updates teams detail and teams badge count', () => { const store = useNotifState - store.getState().dispatch.onEngineIncomingImpl({ + notifyEngineActionListeners({ payload: { params: { badgeState: { @@ -140,7 +144,7 @@ test('same-version badgeState updates teams detail and teams badge count', () => type: 'keybase.1.NotifyBadges.badgeState', } as any) - store.getState().dispatch.onEngineIncomingImpl({ + notifyEngineActionListeners({ payload: { params: { badgeState: { @@ -173,7 +177,7 @@ test('gregor push state populates per-team access requests', () => { const store = useNotifState const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)) - store.getState().dispatch.onEngineIncomingImpl({ + notifyEngineActionListeners({ payload: { params: { state: { @@ -209,3 +213,33 @@ test('gregor push state populates per-team access requests', () => { expect(store.getState().newTeamRequests.get('team-1')).toEqual(new Set(['alice', 'bob'])) expect(store.getState().newTeamRequests.get('team-2')).toEqual(new Set(['charlie'])) }) + +// One badgeState now fans out to three separately registered handlers instead of +// one central switch arm; the inbox conversation map is written first, because +// what reads the tab counts renders off it. +test('one badgeState reaches the inbox map, the tab counts and config', () => { + const convID = new Uint8Array([1, 2, 3, 4]) + notifyEngineActionListeners({ + payload: { + params: { + badgeState: { + bigTeamBadgeCount: 4, + conversations: [{badgeCount: 2, convID, unreadMessages: 5}], + homeTodoItems: 2, + inboxVers: 3, + newTeamAccessRequestCount: 0, + smallTeamBadgeCount: 3, + unverifiedEmails: 0, + unverifiedPhones: 0, + }, + }, + }, + type: 'keybase.1.NotifyBadges.badgeState', + } as any) + + expect( + useInboxBadgeState.getState().counts.get(T.Chat.conversationIDToKey(convID as never)) + ).toEqual({badgeCount: 2, unreadCount: 5}) + expect(useNotifState.getState().navBadges.get(Tabs.chatTab)).toBe(7) + expect(useConfigState.getState().badgeState?.inboxVers).toBe(3) +}) diff --git a/shared/stores/users.tsx b/shared/stores/users.tsx index 87bfffbb38ce..cb8fb1e4d1a0 100644 --- a/shared/stores/users.tsx +++ b/shared/stores/users.tsx @@ -1,5 +1,5 @@ -import type * as EngineGen from '@/constants/rpc' import * as Z from '@/util/zustand' +import {EnginePriority, registerEngineHandlers} from '@/engine/action-listener' import logger from '@/logger' import * as T from '@/constants/types' import {mapGetEnsureValue} from '@/util/map' @@ -20,7 +20,6 @@ export type State = Store & { dispatch: { getBio: (username: string) => void getBlockState: (usernames: ReadonlyArray) => void - onEngineIncomingImpl: (action: EngineGen.Actions) => void resetState: () => void replace: (infoMap: State['infoMap'], blockMap?: State['blockMap']) => void updates: (infos: ReadonlyArray<{name: string; info: Partial}>) => void @@ -63,34 +62,6 @@ export const useUsersState = Z.createZustand('users', (set, get) => { } ignorePromise(f()) }, - onEngineIncomingImpl: action => { - switch (action.type) { - case 'keybase.1.NotifyUsers.identifyUpdate': { - const {brokenUsernames, okUsernames} = action.payload.params - const combined = [ - ...(brokenUsernames ?? []).map(name => ({info: {broken: true}, name})), - ...(okUsernames ?? []).map(name => ({info: {broken: false}, name})), - ] - if (combined.length) { - get().dispatch.updates(combined) - } - break - } - case 'keybase.1.NotifyTracking.notifyUserBlocked': { - const {blocks} = action.payload.params.b - set(s => { - for (const [username, bs] of Object.entries(blocks ?? {})) { - s.blockMap.set(username, { - chatBlocked: bs?.find(item => item.blockType === T.RPCGen.UserBlockType.chat)?.blocked ?? false, - followBlocked: bs?.find(item => item.blockType === T.RPCGen.UserBlockType.follow)?.blocked ?? false, - }) - } - }) - break - } - default: - } - }, replace: (infoMap, blockMap) => { set(s => { s.infoMap = T.castDraft(infoMap) @@ -122,3 +93,31 @@ export const useUsersState = Z.createZustand('users', (set, get) => { dispatch, } }) + +registerEngineHandlers( + { + 'keybase.1.NotifyTracking.notifyUserBlocked': action => { + const {blocks} = action.payload.params.b + useUsersState.setState(s => { + for (const [username, bs] of Object.entries(blocks ?? {})) { + s.blockMap.set(username, { + chatBlocked: bs?.find(item => item.blockType === T.RPCGen.UserBlockType.chat)?.blocked ?? false, + followBlocked: + bs?.find(item => item.blockType === T.RPCGen.UserBlockType.follow)?.blocked ?? false, + }) + } + }) + }, + 'keybase.1.NotifyUsers.identifyUpdate': action => { + const {brokenUsernames, okUsernames} = action.payload.params + const combined = [ + ...(brokenUsernames ?? []).map(name => ({info: {broken: true}, name})), + ...(okUsernames ?? []).map(name => ({info: {broken: false}, name})), + ] + if (combined.length) { + useUsersState.getState().dispatch.updates(combined) + } + }, + }, + {id: 'stores/users', priority: EnginePriority.shared} +)