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} +)