From e12b33d55c379eb8edc753f4fc973b82aa0666df Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 15 Apr 2026 18:42:49 -0400 Subject: [PATCH 01/25] WIP --- plans/chat-split.md | 162 ++++++++++++++++++ .../conversation/header-area/index.native.tsx | 15 +- shared/chat/inbox/use-inbox-state.test.ts | 16 +- shared/chat/inbox/use-inbox-state.tsx | 19 +- shared/chat/make-chat-screen.tsx | 72 ++++++++ shared/chat/routes.tsx | 47 ++--- shared/constants/router.tsx | 4 +- shared/menubar/remote-proxy.desktop.tsx | 3 +- shared/stores/chat.tsx | 115 +------------ shared/stores/convo-registry.tsx | 24 +++ shared/stores/convostate.tsx | 19 +- shared/teams/routes.tsx | 6 +- 12 files changed, 330 insertions(+), 172 deletions(-) create mode 100644 plans/chat-split.md create mode 100644 shared/chat/make-chat-screen.tsx create mode 100644 shared/stores/convo-registry.tsx diff --git a/plans/chat-split.md b/plans/chat-split.md new file mode 100644 index 000000000000..2f03a66d63d8 --- /dev/null +++ b/plans/chat-split.md @@ -0,0 +1,162 @@ +# Chat / Convo Split Plan + +## Goal + +Make `shared/stores/chat.tsx` a true global chat store and remove its knowledge of per-conversation store internals. + +Target boundary: + +- `chat.tsx` owns global chat state only +- `convostate.tsx` owns per-conversation state only +- `chat.tsx` does not touch convo stores directly or indirectly +- no `convostate` re-export from `chat.tsx` +- route/provider helpers do not live in `chat.tsx` + +## Current State + +These cleanup steps are already done: + +- `blockButtons` dismissal moved to chat-global ownership +- `RefreshReason` moved to `shared/stores/chat-shared.tsx` +- `convostate` is no longer re-exported from `shared/stores/chat.tsx` +- convo hooks/helpers are imported from `@/stores/convostate` directly +- `makeChatScreen` moved out of `chat.tsx` into `shared/chat/make-chat-screen.tsx` +- aggregate reader helpers moved out of `chat.tsx`; callers now scan convo state directly + +This means the remaining work is about removing the actual `chat -> convo` logic, not import barrels. + +## Non-Goals + +- Do not add a store coordinator module +- Do not keep a fake separation where `chat` still drives convo state through a registry/helper hop +- Do not silently change behavior during the split + +## Remaining `chat -> convo` Logic Buckets + +### 1. Global Reset / Clear Fanout + +These are global chat actions that currently clear per-convo state: + +- `clearMetas` +- `inboxRefresh(... 'inboxSyncedClear')` message clearing +- `resetState -> clearChatStores()` + +Desired end state: + +- `chat.resetState` resets only chat-global state +- convo reset/clear is invoked separately from the owning layer +- full chat teardown is composed by the caller, not hidden inside `chat.tsx` + +### 2. Route-Selection Side Effects + +These are router-driven actions currently fired by `chat.onRouteChanged`: + +- `selectedConversation` +- `tabSelected` + +Desired end state: + +- route subscriptions call convo-owned selection helpers directly +- `chat.tsx` does not decide active convo state + +### 3. Convo Hydration / Bootstrap + +These are chat-global flows that currently materialize or mutate convo state: + +- `metasReceived` +- `updateInboxLayout` first-load hydration +- `ensureWidgetMetas` +- `queueMetaToRequest` +- `queueMetaHandle` +- `unboxRows` + +Desired end state: + +- `chat` owns inbox/global source data +- convo hydration and trusted/untrusted materialization move to convo ownership +- `chat` no longer pushes metadata into convo stores + +### 4. Create Conversation Flow + +`createConversation` currently: + +- performs the RPC +- seeds participants/meta +- navigates pending/new convo state +- populates pending error convo state + +Desired end state: + +- conversation-creation flow lives with the feature or pending-convo ownership +- `chat.tsx` does not navigate threads or write pending convo state + +### 5. Engine Notification Fanout + +Most of `onEngineIncomingImpl` is a dispatcher into specific convo stores. + +Keep in `chat` only: + +- inbox layout +- global badge totals +- maybe mentions +- block buttons +- user reacjis +- global refresh triggers + +Move out of `chat`: + +- incoming messages +- typing +- reactions +- messages updated +- stale thread reload handling +- participant info +- coin flip status +- retention updates +- per-convo command/giphy UI notifications + +Desired end state: + +- convo-targeted notifications are handled by convo-owned entrypoints +- `chat.tsx` only handles truly global notifications + +### 6. Badge / Unread Ownership + +This is last because it is the riskiest ownership decision. + +Current state: + +- global badge totals live in `chat` +- per-convo badge/unread also get updated from `chat` + +Possible end states: + +- derive per-convo badge/unread from global inbox/badge data +- or move per-convo badge ownership fully to convo state + +Do not decide this early. Resolve simpler buckets first. + +## Recommended Order + +1. Global reset / clear fanout +2. Route-selection side effects +3. Convo hydration / bootstrap +4. Create conversation flow +5. Engine notification fanout +6. Badge / unread ownership + +## Acceptance Criteria + +The split is done when: + +- `chat.tsx` contains only global chat state and global chat actions +- `chat.tsx` does not iterate convo stores +- `chat.tsx` does not call `getConvoState(...)` +- convo-targeted routing is owned outside `chat.tsx` +- `chat.tsx` can be understood as a standalone global store file + +## Notes For Follow-Up Work + +- Prefer deleting logic over relocating it to another indirection layer +- For each bucket, identify call sites first, then decide the new owner +- Keep each bucket as a separate change so regressions are easier to review diff --git a/shared/chat/conversation/header-area/index.native.tsx b/shared/chat/conversation/header-area/index.native.tsx index 9ae699695a9b..447322afcc59 100644 --- a/shared/chat/conversation/header-area/index.native.tsx +++ b/shared/chat/conversation/header-area/index.native.tsx @@ -1,5 +1,6 @@ import * as C from '@/constants' import * as Chat from '@/stores/chat' +import {chatStores} from '@/stores/convo-registry' import * as ConvoState from '@/stores/convostate' import {getConvoState} from '@/stores/convostate' import * as Kb from '@/common-adapters' @@ -13,6 +14,7 @@ import {useSafeAreaFrame} from 'react-native-safe-area-context' import {useUsersState} from '@/stores/users' import {useCurrentUserState} from '@/stores/current-user' import {navToProfile} from '@/constants/router' +import * as React from 'react' export const HeaderAreaRight = () => { const conversationIDKey = ConvoState.useChatContext(s => s.id) @@ -159,7 +161,18 @@ export const useBackBadge = () => { const visiblePath = C.Router2.getVisiblePath() const onTopOfInbox = visiblePath[visiblePath.length - 2]?.name === 'chatRoot' const conversationIDKey = ConvoState.useChatContext(s => s.id) - const badgeNumber = Chat.useChatState(s => s.getBackCount(conversationIDKey)) + const badgeStateVersion = Chat.useChatState(s => s.badgeStateVersion) + const badgeNumber = React.useMemo(() => { + void badgeStateVersion + let count = 0 + for (const store of chatStores.values()) { + const {badge, id} = store.getState() + if (id !== conversationIDKey) { + count += badge + } + } + return count + }, [badgeStateVersion, conversationIDKey]) if (!onTopOfInbox) return 0 return badgeNumber } diff --git a/shared/chat/inbox/use-inbox-state.test.ts b/shared/chat/inbox/use-inbox-state.test.ts index fa635993f9b5..fb721a85d87e 100644 --- a/shared/chat/inbox/use-inbox-state.test.ts +++ b/shared/chat/inbox/use-inbox-state.test.ts @@ -2,12 +2,12 @@ /// type MockChatState = { + badgeStateVersion: number dispatch: { inboxRefresh: jest.Mock queueMetaToRequest: jest.Mock setInboxRetriedOnCurrentEmpty: jest.Mock } - getUnreadIndicies: () => Map inboxHasLoaded: boolean inboxLayout: undefined inboxRetriedOnCurrentEmpty: boolean @@ -42,15 +42,19 @@ jest.mock('@/constants', () => { }) jest.mock('@/stores/chat', () => ({ + getSelectedConversation: () => '', + isSplit: false, + noConversationIDKey: '', + useChatState: (selector: (state: MockChatState) => T) => selector(mockChatState), +})) + +jest.mock('@/stores/convostate', () => ({ getConvoState: () => ({ + badge: 0, dispatch: { tabSelected: jest.fn(), }, }), - getSelectedConversation: () => '', - isSplit: false, - noConversationIDKey: '', - useChatState: (selector: (state: MockChatState) => T) => selector(mockChatState), })) jest.mock('@/stores/config', () => ({ @@ -83,12 +87,12 @@ beforeEach(() => { mockQueueMetaToRequest = jest.fn() mockSetInboxRetriedOnCurrentEmpty = jest.fn() mockChatState = { + badgeStateVersion: 0, dispatch: { inboxRefresh: mockInboxRefresh, queueMetaToRequest: mockQueueMetaToRequest, setInboxRetriedOnCurrentEmpty: mockSetInboxRetriedOnCurrentEmpty, }, - getUnreadIndicies: () => new Map(), inboxHasLoaded: true, inboxLayout: undefined, inboxRetriedOnCurrentEmpty: true, diff --git a/shared/chat/inbox/use-inbox-state.tsx b/shared/chat/inbox/use-inbox-state.tsx index c6c971bbf360..d6b83ef78bb2 100644 --- a/shared/chat/inbox/use-inbox-state.tsx +++ b/shared/chat/inbox/use-inbox-state.tsx @@ -16,6 +16,7 @@ export function useInboxState(conversationIDKey?: string, isSearching = false) { const chatState = Chat.useChatState( C.useShallow(s => ({ + badgeStateVersion: s.badgeStateVersion, inboxHasLoaded: s.inboxHasLoaded, inboxLayout: s.inboxLayout, inboxRefresh: s.dispatch.inboxRefresh, @@ -25,6 +26,7 @@ export function useInboxState(conversationIDKey?: string, isSearching = false) { })) ) const { + badgeStateVersion, inboxHasLoaded, inboxLayout, inboxRefresh, @@ -177,11 +179,20 @@ export function useInboxState(conversationIDKey?: string, isSearching = false) { return inboxRows.map(r => (r.type === 'big' ? r.conversationIDKey : '')) }, [inboxRows]) - const unreadIndices = Chat.useChatState( - C.useShallow(s => { - return s.getUnreadIndicies(bigConvIds) + const unreadIndices = React.useMemo(() => { + void badgeStateVersion + const next: Map = new Map() + bigConvIds.forEach((conversationIDKey, idx) => { + if (!conversationIDKey) { + return + } + const badge = ConvoState.getConvoState(conversationIDKey).badge + if (badge > 0) { + next.set(idx, badge) + } }) - ) + return next + }, [badgeStateVersion, bigConvIds]) let unreadTotal = 0 unreadIndices.forEach(count => { diff --git a/shared/chat/make-chat-screen.tsx b/shared/chat/make-chat-screen.tsx new file mode 100644 index 000000000000..593366ec4879 --- /dev/null +++ b/shared/chat/make-chat-screen.tsx @@ -0,0 +1,72 @@ +import type {GetOptionsRet, RouteDef} from '@/constants/types/router' +import type * as T from '@/constants/types' +import {ProviderScreen} from '@/stores/convostate' +import type {StaticScreenProps} from '@react-navigation/core' +import * as React from 'react' + +// See constants/router.tsx IsExactlyRecord for explanation +type IsExactlyRecord = string extends keyof T ? true : false + +type NavigatorParamsFromProps

= + P extends Record + ? IsExactlyRecord

extends true + ? undefined + : keyof P extends never + ? undefined + : P + : undefined + +type AddConversationIDKey

= + P extends Record + ? Omit & {conversationIDKey?: T.Chat.ConversationIDKey} + : {conversationIDKey?: T.Chat.ConversationIDKey} + +type LazyInnerComponent> = + COM extends React.LazyExoticComponent ? Inner : never + +type ChatScreenParams> = NavigatorParamsFromProps< + AddConversationIDKey>> +> + +type ChatScreenProps> = StaticScreenProps> +type ChatScreenComponent> = ( + p: ChatScreenProps +) => React.ReactElement + +export function makeChatScreen>( + Component: COM, + options?: { + getOptions?: GetOptionsRet | ((props: ChatScreenProps) => GetOptionsRet) + skipProvider?: boolean + canBeNullConvoID?: boolean + } +): RouteDef, ChatScreenParams> { + const getOptionsOption = options?.getOptions + const getOptions = + typeof getOptionsOption === 'function' + ? (p: ChatScreenProps) => + // getOptions can run before params are materialized on the route object. + getOptionsOption({ + ...p, + route: { + ...p.route, + params: ((p.route as {params?: ChatScreenParams}).params ?? {}) as ChatScreenParams, + }, + }) + : getOptionsOption + return { + ...options, + getOptions, + screen: function Screen(p: ChatScreenProps) { + const Comp = Component as any + const params = ((p.route as {params?: ChatScreenParams}).params ?? {}) as ChatScreenParams + return options?.skipProvider ? ( + + ) : ( + + + + ) + }, + } +} diff --git a/shared/chat/routes.tsx b/shared/chat/routes.tsx index 0a92387617a2..d6fba32a3f11 100644 --- a/shared/chat/routes.tsx +++ b/shared/chat/routes.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import * as C from '@/constants' import * as Kb from '@/common-adapters' import * as Chat from '@/stores/chat' +import {makeChatScreen} from './make-chat-screen' import * as FS from '@/constants/fs' import type * as T from '@/constants/types' import chatNewChat from '../team-building/page' @@ -140,7 +141,7 @@ const SendToChatHeaderLeft = ({canBack}: {canBack?: boolean}) => { } export const newRoutes = defineRouteMap({ - chatConversation: Chat.makeChatScreen(Convo, { + chatConversation: makeChatScreen(Convo, { canBeNullConvoID: true, getOptions: p => ({ ...headerNavigationOptions(p.route), @@ -152,14 +153,14 @@ export const newRoutes = defineRouteMap({ }, chatRoot: Chat.isSplit ? { - ...Chat.makeChatScreen(React.lazy(async () => import('./inbox-and-conversation')), { + ...makeChatScreen(React.lazy(async () => import('./inbox-and-conversation')), { getOptions: inboxAndConvoGetOptions, skipProvider: true, }), initialParams: emptyChatRootRouteParams, } : { - ...Chat.makeChatScreen(React.lazy(async () => import('./inbox')), { + ...makeChatScreen(React.lazy(async () => import('./inbox')), { getOptions: inboxGetOptions, skipProvider: true, }), @@ -168,7 +169,7 @@ export const newRoutes = defineRouteMap({ }) export const newModalRoutes = defineRouteMap({ - chatAddToChannel: Chat.makeChatScreen( + chatAddToChannel: makeChatScreen( React.lazy(async () => import('./conversation/info-panel/add-to-channel')), { getOptions: ({route}) => ({ @@ -177,7 +178,7 @@ export const newModalRoutes = defineRouteMap({ }), } ), - chatAttachmentFullscreen: Chat.makeChatScreen( + chatAttachmentFullscreen: makeChatScreen( React.lazy(async () => import('./conversation/attachment-fullscreen/screen')), { getOptions: { @@ -195,12 +196,12 @@ export const newModalRoutes = defineRouteMap({ }, } ), - chatAttachmentGetTitles: Chat.makeChatScreen( + chatAttachmentGetTitles: makeChatScreen( React.lazy(async () => import('./conversation/attachment-get-titles')), {getOptions: {modalStyle: {height: 660, maxHeight: 660}}} ), chatBlockingModal: { - ...Chat.makeChatScreen( + ...makeChatScreen( React.lazy(async () => import('./blocking/block-modal')), { getOptions: { @@ -212,36 +213,36 @@ export const newModalRoutes = defineRouteMap({ ), initialParams: emptyChatBlockingRouteParams, }, - chatChooseEmoji: Chat.makeChatScreen( + chatChooseEmoji: makeChatScreen( React.lazy(async () => import('./emoji-picker/container')), { getOptions: {headerShown: false}, } ), - chatConfirmNavigateExternal: Chat.makeChatScreen( + chatConfirmNavigateExternal: makeChatScreen( React.lazy(async () => import('./punycode-link-warning')), {skipProvider: true} ), - chatConfirmRemoveBot: Chat.makeChatScreen( + chatConfirmRemoveBot: makeChatScreen( React.lazy(async () => import('./conversation/bot/confirm')), {canBeNullConvoID: true} ), - chatCreateChannel: Chat.makeChatScreen( + chatCreateChannel: makeChatScreen( React.lazy(async () => import('./create-channel')), {skipProvider: true} ), - chatDeleteHistoryWarning: Chat.makeChatScreen(React.lazy(async () => import('./delete-history-warning'))), - chatForwardMsgPick: Chat.makeChatScreen( + chatDeleteHistoryWarning: makeChatScreen(React.lazy(async () => import('./delete-history-warning'))), + chatForwardMsgPick: makeChatScreen( React.lazy(async () => import('./conversation/fwd-msg')), { getOptions: {headerTitle: () => }, } ), - chatInfoPanel: Chat.makeChatScreen( + chatInfoPanel: makeChatScreen( React.lazy(async () => import('./conversation/info-panel')), {getOptions: C.isMobile ? undefined : {modalStyle: {height: '80%', width: '80%'}}} ), - chatInstallBot: Chat.makeChatScreen( + chatInstallBot: makeChatScreen( React.lazy(async () => import('./conversation/bot/install')), { getOptions: { @@ -252,22 +253,22 @@ export const newModalRoutes = defineRouteMap({ skipProvider: true, } ), - chatInstallBotPick: Chat.makeChatScreen( + chatInstallBotPick: makeChatScreen( React.lazy(async () => import('./conversation/bot/team-picker')), {getOptions: {title: 'Add to team or chat'}, skipProvider: true} ), - chatLocationPreview: Chat.makeChatScreen( + chatLocationPreview: makeChatScreen( React.lazy(async () => import('./conversation/input-area/location-popup')), {getOptions: {title: 'Location'}} ), - chatMessagePopup: Chat.makeChatScreen( + chatMessagePopup: makeChatScreen( React.lazy(async () => { const {MessagePopupModal} = await import('./conversation/messages/message-popup') return {default: MessagePopupModal} }) ), chatNewChat, - chatPDF: Chat.makeChatScreen( + chatPDF: makeChatScreen( React.lazy(async () => import('./pdf')), { getOptions: p => ({ @@ -279,7 +280,7 @@ export const newModalRoutes = defineRouteMap({ } ), chatSearchBots: { - ...Chat.makeChatScreen( + ...makeChatScreen( React.lazy(async () => import('./conversation/bot/search')), { canBeNullConvoID: true, @@ -288,7 +289,7 @@ export const newModalRoutes = defineRouteMap({ ), initialParams: emptyChatSearchBotsRouteParams, }, - chatSendToChat: Chat.makeChatScreen( + chatSendToChat: makeChatScreen( React.lazy(async () => import('./send-to-chat')), { getOptions: ({route}) => ({ @@ -299,10 +300,10 @@ export const newModalRoutes = defineRouteMap({ } ), chatShowNewTeamDialog: { - ...Chat.makeChatScreen(React.lazy(async () => import('./new-team-dialog-container'))), + ...makeChatScreen(React.lazy(async () => import('./new-team-dialog-container'))), initialParams: emptyChatShowNewTeamDialogRouteParams, }, - chatUnfurlMapPopup: Chat.makeChatScreen( + chatUnfurlMapPopup: makeChatScreen( React.lazy(async () => import('./conversation/messages/text/unfurl/unfurl-list/map-popup')), {getOptions: {title: 'Location'}} ), diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index 0097616d5ca6..cbe2302a7571 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -1,6 +1,6 @@ import type * as React from 'react' import * as T from './types' -import type * as ConvoStateType from '@/stores/convostate' +import type * as ConvoRegistryType from '@/stores/convo-registry' import * as Tabs from './tabs' import { StackActions, @@ -352,7 +352,7 @@ export const previewConversation = (p: PreviewConversationParams) => { const {participants, teamname, highlightMessageID} = p if (teamname || !participants) return - const {chatStores} = require('@/stores/convostate') as typeof ConvoStateType + const {chatStores} = require('@/stores/convo-registry') as typeof ConvoRegistryType const toFind = [...participants].sort().join(',') const toFindN = participants.length for (const cs of chatStores.values()) { diff --git a/shared/menubar/remote-proxy.desktop.tsx b/shared/menubar/remote-proxy.desktop.tsx index 1fa6da48210d..624eb257f844 100644 --- a/shared/menubar/remote-proxy.desktop.tsx +++ b/shared/menubar/remote-proxy.desktop.tsx @@ -2,6 +2,7 @@ import * as C from '@/constants' import * as Chat from '@/stores/chat' import * as ConvoState from '@/stores/convostate' +import {chatStores} from '@/stores/convo-registry' import type {ConvoState as ConvoStateType} from '@/stores/convostate' import {useConfigState} from '@/stores/config' import * as T from '@/constants/types' @@ -148,7 +149,7 @@ const useWidgetConversationList = ( const unsubs = widgetList.map(widget => { ConvoState.getConvoState(widget.convID) - return ConvoState.chatStores.get(widget.convID)?.subscribe((state, oldState) => { + return chatStores.get(widget.convID)?.subscribe((state, oldState) => { if (convoDiff(state, oldState)) { onStoreChange() } diff --git a/shared/stores/chat.tsx b/shared/stores/chat.tsx index ca093403e25d..7b5011abbed5 100644 --- a/shared/stores/chat.tsx +++ b/shared/stores/chat.tsx @@ -11,13 +11,10 @@ import isEqual from 'lodash/isEqual' import logger from '@/logger' import type * as Router2 from '@/constants/router' import type {RefreshReason} from '@/stores/chat-shared' -import {ProviderScreen} from '@/stores/convostate' -import type {GetOptionsRet, RouteDef} from '@/constants/types/router' import {RPCError} from '@/util/errors' import {bodyToJSON} from '@/constants/rpc-utils' -import {clearChatStores, chatStores} from '@/stores/convostate' +import {clearChatStores, chatStores} from '@/stores/convo-registry' import {flushInboxRowUpdates} from '@/stores/inbox-rows' -import type {StaticScreenProps} from '@react-navigation/core' import {ignorePromise, timeoutPromise} from '@/constants/utils' import {isPhone} from '@/constants/platform' import {getModalStack, getTab, getVisibleScreen, navigateToInbox} from '@/constants/router' @@ -213,12 +210,6 @@ export type State = Store & { items: ReadonlyArray<{md: T.RPCGen.Gregor1.Metadata; item: T.RPCGen.Gregor1.Item}> ) => void } - getBackCount: (conversationIDKey: T.Chat.ConversationIDKey) => number - getBadgeHiddenCount: (ids: ReadonlySet) => { - badgeCount: number - hiddenCount: number - } - getUnreadIndicies: (ids: ReadonlyArray) => Map } // Only get the untrusted conversations out @@ -1249,113 +1240,9 @@ export const useChatState = Z.createZustand('chat', (set, get) => { return { ...initialStore, dispatch, - getBackCount: conversationIDKey => { - let count = 0 - chatStores.forEach(s => { - const {id, badge} = s.getState() - // only show sum of badges that aren't for the current conversation - if (id !== conversationIDKey) { - count += badge - } - }) - return count - }, - getBadgeHiddenCount: ids => { - let badgeCount = 0 - let hiddenCount = 0 - ids.forEach(id => { - const store = chatStores.get(id) - if (store) { - badgeCount -= store.getState().badge - hiddenCount -= 1 - } - }) - return {badgeCount, hiddenCount} - }, - getUnreadIndicies: ids => { - const unreadIndices: Map = new Map() - ids.forEach((cur, idx) => { - if (!cur) return - const store = chatStores.get(cur) - if (store) { - const badge = store.getState().badge - if (badge > 0) { - unreadIndices.set(idx, badge) - } - } - }) - return unreadIndices - }, } }) -// See constants/router.tsx IsExactlyRecord for explanation -type IsExactlyRecord = string extends keyof T ? true : false - -type NavigatorParamsFromProps

= - P extends Record - ? IsExactlyRecord

extends true - ? undefined - : keyof P extends never - ? undefined - : P - : undefined - -type AddConversationIDKey

= - P extends Record - ? Omit & {conversationIDKey?: T.Chat.ConversationIDKey} - : {conversationIDKey?: T.Chat.ConversationIDKey} - -type LazyInnerComponent> = - COM extends React.LazyExoticComponent ? Inner : never - -type ChatScreenParams> = NavigatorParamsFromProps< - AddConversationIDKey>> -> - -type ChatScreenProps> = StaticScreenProps> -type ChatScreenComponent> = ( - p: ChatScreenProps -) => React.ReactElement - -export function makeChatScreen>( - Component: COM, - options?: { - getOptions?: GetOptionsRet | ((props: ChatScreenProps) => GetOptionsRet) - skipProvider?: boolean - canBeNullConvoID?: boolean - } -): RouteDef, ChatScreenParams> { - const getOptionsOption = options?.getOptions - const getOptions = - typeof getOptionsOption === 'function' - ? (p: ChatScreenProps) => - // getOptions can run before params are materialized on the route object. - getOptionsOption({ - ...p, - route: { - ...p.route, - params: ((p.route as {params?: ChatScreenParams}).params ?? {}) as ChatScreenParams, - }, - }) - : getOptionsOption - return { - ...options, - getOptions, - screen: function Screen(p: ChatScreenProps) { - const Comp = Component as any - const params = ((p.route as {params?: ChatScreenParams}).params ?? {}) as ChatScreenParams - return options?.skipProvider ? ( - - ) : ( - - - - ) - }, - } -} - export * from '@/stores/inbox-rows' export type {RefreshReason} from '@/stores/chat-shared' export * from '@/constants/chat/common' diff --git a/shared/stores/convo-registry.tsx b/shared/stores/convo-registry.tsx new file mode 100644 index 000000000000..2a5a30c3fbd6 --- /dev/null +++ b/shared/stores/convo-registry.tsx @@ -0,0 +1,24 @@ +import type * as T from '@/constants/types' +import {registerDebugClear} from '@/util/debug' +import type {StoreApi, UseBoundStore} from 'zustand' +import type {ConvoState, ConvoUIState} from '@/stores/convostate' + +type MadeStore = UseBoundStore> +type MadeUIStore = UseBoundStore> + +export const chatStores: Map = __DEV__ + ? ((globalThis.__hmr_chatStores ??= new Map()) as Map) + : new Map() + +export const convoUIStores: Map = __DEV__ + ? (((globalThis as any).__hmr_convoUIStores ??= new Map()) as Map) + : new Map() + +export const clearChatStores = () => { + chatStores.clear() + convoUIStores.clear() +} + +registerDebugClear(() => { + clearChatStores() +}) diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index ff58f7a8f78a..3d21028a2be1 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -42,12 +42,12 @@ import KB2 from '@/util/electron' import {NotifyPopup} from '@/util/misc' import {hexToUint8Array} from '@/util/uint8array' import {clearChatTimeCache} from '@/util/timestamp' -import {registerDebugClear} from '@/util/debug' import * as Config from '@/constants/config' import {isMobile} from '@/constants/platform' import {enumKeys, ignorePromise, shallowEqual} from '@/constants/utils' import {queueInboxRowUpdate} from './inbox-rows' import * as Strings from '@/constants/strings' +import {chatStores, clearChatStores, convoUIStores} from './convo-registry' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' @@ -3495,23 +3495,6 @@ const createConvoUISlice = }, }) -export const chatStores: Map = __DEV__ - ? ((globalThis.__hmr_chatStores ??= new Map()) as Map) - : new Map() - -export const convoUIStores: Map = __DEV__ - ? (((globalThis as any).__hmr_convoUIStores ??= new Map()) as Map) - : new Map() - -export const clearChatStores = () => { - chatStores.clear() - convoUIStores.clear() -} - -registerDebugClear(() => { - clearChatStores() -}) - const createConvoStore = (id: T.Chat.ConversationIDKey) => { const existing = chatStores.get(id) if (existing) return existing diff --git a/shared/teams/routes.tsx b/shared/teams/routes.tsx index b81622e91f36..fb0597135f45 100644 --- a/shared/teams/routes.tsx +++ b/shared/teams/routes.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import * as C from '@/constants' import * as Kb from '@/common-adapters' -import * as Chat from '@/stores/chat' +import {makeChatScreen} from '@/chat/make-chat-screen' import * as T from '@/constants/types' import * as Teams from '@/stores/teams' import {ModalTitle} from './common' @@ -191,7 +191,7 @@ export const newRoutes = defineRouteMap({ React.lazy(async () => import('./team')), {getOptions: {headerShadowVisible: false, headerTitle: ''}} ), - teamChannel: Chat.makeChatScreen( + teamChannel: makeChatScreen( React.lazy(async () => import('./channel')), {getOptions: {headerShadowVisible: false, headerTitle: ''}} ), @@ -225,7 +225,7 @@ export const newModalRoutes = defineRouteMap({ teamAddEmoji: C.makeScreen(React.lazy(async () => import('./emojis/add-emoji')), { getOptions: {headerLeft: Kb.Styles.isMobile ? () => : undefined, title: 'Add emoji'}, }), - teamAddEmojiAlias: Chat.makeChatScreen(React.lazy(async () => import('./emojis/add-alias')), { + teamAddEmojiAlias: makeChatScreen(React.lazy(async () => import('./emojis/add-alias')), { getOptions: {headerLeft: Kb.Styles.isMobile ? () => : undefined, title: 'Add an alias'}, }), teamAddToChannels: C.makeScreen(React.lazy(async () => import('./team/member/add-to-channels')), { From 1cd11aa80aeafef5a1551d87adfd32fc363beb20 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 15 Apr 2026 18:54:10 -0400 Subject: [PATCH 02/25] WIP --- plans/chat-split.md | 51 ++------ shared/constants/init/shared.tsx | 6 +- shared/constants/router.tsx | 3 +- shared/stores/chat.tsx | 198 ++++++------------------------- shared/stores/convo-registry.tsx | 3 + shared/stores/convostate.tsx | 98 ++++++++++++++- 6 files changed, 155 insertions(+), 204 deletions(-) diff --git a/plans/chat-split.md b/plans/chat-split.md index 2f03a66d63d8..f241f20e6012 100644 --- a/plans/chat-split.md +++ b/plans/chat-split.md @@ -22,6 +22,11 @@ These cleanup steps are already done: - convo hooks/helpers are imported from `@/stores/convostate` directly - `makeChatScreen` moved out of `chat.tsx` into `shared/chat/make-chat-screen.tsx` - aggregate reader helpers moved out of `chat.tsx`; callers now scan convo state directly +- `chat.resetState` now resets only chat-global state; convo registry teardown is composed by global store reset +- `inboxSyncedClear` convo clearing is no longer hidden inside `chat.inboxRefresh` +- route subscriptions now call convo-owned selection handling directly; `chat.tsx` no longer owns route selection +- `metasReceived` now applies convo meta updates from `convostate` +- first-layout inbox hydration moved out of `chat.updateInboxLayout` This means the remaining work is about removing the actual `chat -> convo` logic, not import barrels. @@ -33,38 +38,10 @@ This means the remaining work is about removing the actual `chat -> convo` logic ## Remaining `chat -> convo` Logic Buckets -### 1. Global Reset / Clear Fanout - -These are global chat actions that currently clear per-convo state: - -- `clearMetas` -- `inboxRefresh(... 'inboxSyncedClear')` message clearing -- `resetState -> clearChatStores()` - -Desired end state: - -- `chat.resetState` resets only chat-global state -- convo reset/clear is invoked separately from the owning layer -- full chat teardown is composed by the caller, not hidden inside `chat.tsx` - -### 2. Route-Selection Side Effects - -These are router-driven actions currently fired by `chat.onRouteChanged`: - -- `selectedConversation` -- `tabSelected` - -Desired end state: - -- route subscriptions call convo-owned selection helpers directly -- `chat.tsx` does not decide active convo state - -### 3. Convo Hydration / Bootstrap +### 1. Convo Hydration / Bootstrap These are chat-global flows that currently materialize or mutate convo state: -- `metasReceived` -- `updateInboxLayout` first-load hydration - `ensureWidgetMetas` - `queueMetaToRequest` - `queueMetaHandle` @@ -76,7 +53,7 @@ Desired end state: - convo hydration and trusted/untrusted materialization move to convo ownership - `chat` no longer pushes metadata into convo stores -### 4. Create Conversation Flow +### 2. Create Conversation Flow `createConversation` currently: @@ -90,7 +67,7 @@ Desired end state: - conversation-creation flow lives with the feature or pending-convo ownership - `chat.tsx` does not navigate threads or write pending convo state -### 5. Engine Notification Fanout +### 3. Engine Notification Fanout Most of `onEngineIncomingImpl` is a dispatcher into specific convo stores. @@ -120,7 +97,7 @@ Desired end state: - convo-targeted notifications are handled by convo-owned entrypoints - `chat.tsx` only handles truly global notifications -### 6. Badge / Unread Ownership +### 4. Badge / Unread Ownership This is last because it is the riskiest ownership decision. @@ -138,12 +115,10 @@ Do not decide this early. Resolve simpler buckets first. ## Recommended Order -1. Global reset / clear fanout -2. Route-selection side effects -3. Convo hydration / bootstrap -4. Create conversation flow -5. Engine notification fanout -6. Badge / unread ownership +1. Convo hydration / bootstrap +2. Create conversation flow +3. Engine notification fanout +4. Badge / unread ownership ## Acceptance Criteria diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index c3843c3673c9..2e1759048bf4 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -49,7 +49,7 @@ import {useSettingsContactsState} from '@/stores/settings-contacts' import {useTeamsState} from '@/stores/teams' import {useRouterState} from '@/stores/router' import * as Util from '@/constants/router' -import {setConvoDefer} from '@/stores/convostate' +import {metasReceived as convoMetasReceived, onRouteChanged as onConvoRouteChanged, setConvoDefer} from '@/stores/convostate' import {clearSignupEmail} from '@/people/signup-email' import {clearSignupDeviceNameDraft} from '@/signup/device-name-draft' @@ -175,7 +175,7 @@ export const initSharedSubscriptions = () => { chatInboxLayoutSmallTeamsFirstConvID: () => storeRegistry.getState('chat').inboxLayout?.smallTeams?.[0]?.convID, chatInboxRefresh: reason => storeRegistry.getState('chat').dispatch.inboxRefresh(reason), - chatMetasReceived: metas => storeRegistry.getState('chat').dispatch.metasReceived(metas), + chatMetasReceived: metas => convoMetasReceived(metas), chatUnboxRows: (convIDs, force) => storeRegistry.getState('chat').dispatch.unboxRows(convIDs, force), }) _sharedUnsubs.push( @@ -426,7 +426,7 @@ export const initSharedSubscriptions = () => { storeRegistry.getState('settings-email').dispatch.resetAddedEmail() } - storeRegistry.getState('chat').dispatch.onRouteChanged(prev, next) + onConvoRouteChanged(prev, next) }) ) initTeamBuildingCallbacks() diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index cbe2302a7571..66d4f20479b5 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -431,7 +431,8 @@ export const previewConversation = (p: PreviewConversationParams) => { }) const meta = Meta.inboxUIItemToConversationMeta(results2.conv) if (meta) { - storeRegistry.getState('chat').dispatch.metasReceived([meta]) + const {metasReceived} = require('@/stores/convostate') as typeof import('@/stores/convostate') + metasReceived([meta]) } storeRegistry diff --git a/shared/stores/chat.tsx b/shared/stores/chat.tsx index 7b5011abbed5..82b2432f7c63 100644 --- a/shared/stores/chat.tsx +++ b/shared/stores/chat.tsx @@ -4,20 +4,18 @@ import * as Message from '@/constants/chat/message' import * as Meta from '@/constants/chat/meta' import * as S from '@/constants/strings' import * as T from '@/constants/types' -import * as Tabs from '@/constants/tabs' import * as TeamConstants from '@/constants/teams' import * as Z from '@/util/zustand' import isEqual from 'lodash/isEqual' import logger from '@/logger' -import type * as Router2 from '@/constants/router' import type {RefreshReason} from '@/stores/chat-shared' import {RPCError} from '@/util/errors' import {bodyToJSON} from '@/constants/rpc-utils' -import {clearChatStores, chatStores} from '@/stores/convo-registry' -import {flushInboxRowUpdates} from '@/stores/inbox-rows' +import {chatStores} from '@/stores/convo-registry' +import {hydrateInboxLayout, metasReceived as convoMetasReceived} from '@/stores/convostate' import {ignorePromise, timeoutPromise} from '@/constants/utils' import {isPhone} from '@/constants/platform' -import {getModalStack, getTab, getVisibleScreen, navigateToInbox} from '@/constants/router' +import {navigateToInbox} from '@/constants/router' import {storeRegistry} from '@/stores/store-registry' import {uint8ArrayToString} from '@/util/uint8array' import {useConfigState} from '@/stores/config' @@ -178,7 +176,6 @@ const initialStore: Store = { export type State = Store & { dispatch: { badgesUpdated: (badgeState?: T.RPCGen.BadgeState) => void - clearMetas: () => void createConversation: (participants: ReadonlyArray, highlightMessageID?: T.Chat.MessageID) => void dismissBlockButtons: (teamID: T.RPCGen.TeamID) => void dismissBlockButtonsIfPresent: (teamID: T.RPCGen.TeamID) => void @@ -187,17 +184,12 @@ export type State = Store & { setInboxRetriedOnCurrentEmpty: (retried: boolean) => void loadStaticConfig: () => void maybeChangeSelectedConv: () => void - metasReceived: ( - metas: ReadonlyArray, - removals?: ReadonlyArray // convs to remove - ) => void onChatThreadStale: (action: EngineGen.EngineAction<'chat.1.NotifyChat.ChatThreadsStale'>) => void onEngineIncomingImpl: (action: EngineGen.Actions) => void onChatInboxSynced: (action: EngineGen.EngineAction<'chat.1.NotifyChat.ChatInboxSynced'>) => void onGetInboxConvsUnboxed: (action: EngineGen.EngineAction<'chat.1.chatUi.chatInboxConversation'>) => void onGetInboxUnverifiedConvs: (action: EngineGen.EngineAction<'chat.1.chatUi.chatInboxUnverified'>) => void onIncomingInboxUIItem: (inboxUIItem?: T.RPCChat.InboxUIItem) => void - onRouteChanged: (prev: T.Immutable, next: T.Immutable) => void onTeamBuildingFinished: (users: ReadonlySet) => void queueMetaToRequest: (ids: ReadonlyArray) => void queueMetaHandle: () => void @@ -221,6 +213,21 @@ export const useChatState = Z.createZustand('chat', (set, get) => { // We keep a set of conversations to unbox let metaQueue = new Set() + const requestInboxLayout = async (reason: RefreshReason) => { + const {username} = useCurrentUserState.getState() + const {loggedIn} = useConfigState.getState() + if (!loggedIn || !username) { + return + } + + logger.info(`Inbox refresh due to ${reason}`) + const reselectMode = + get().inboxHasLoaded || isPhone + ? T.RPCChat.InboxLayoutReselectMode.default + : T.RPCChat.InboxLayoutReselectMode.force + await T.RPCChat.localRequestInboxLayoutRpcPromise({reselectMode}) + } + const dispatch: State['dispatch'] = { badgesUpdated: b => { if (!b) return @@ -242,11 +249,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { s.bigTeamBadgeCount = bigTeamBadgeCount }) }, - clearMetas: () => { - for (const [, cs] of chatStores) { - cs.getState().dispatch.setMeta() - } - }, createConversation: (participants, highlightMessageID) => { // TODO This will break if you try to make 2 new conversations at the same time because there is // only one pending conversation state. @@ -275,7 +277,7 @@ export const useChatState = Z.createZustand('chat', (set, get) => { } else { const meta = Meta.inboxUIItemToConversationMeta(uiConv) if (meta) { - get().dispatch.metasReceived([meta]) + convoMetasReceived([meta]) } const participantInfo: T.Chat.ParticipantInfo = Common.uiParticipantsToParticipantInfo( @@ -350,31 +352,7 @@ export const useChatState = Z.createZustand('chat', (set, get) => { get().dispatch.unboxRows(missing, true) }, inboxRefresh: reason => { - const f = async () => { - const {username} = useCurrentUserState.getState() - const {loggedIn} = useConfigState.getState() - if (!loggedIn || !username) { - return - } - const clearExistingMetas = reason === 'inboxSyncedClear' - const clearExistingMessages = reason === 'inboxSyncedClear' - - logger.info(`Inbox refresh due to ${reason}`) - const reselectMode = - get().inboxHasLoaded || isPhone - ? T.RPCChat.InboxLayoutReselectMode.default - : T.RPCChat.InboxLayoutReselectMode.force - await T.RPCChat.localRequestInboxLayoutRpcPromise({reselectMode}) - if (clearExistingMetas) { - get().dispatch.clearMetas() - } - if (clearExistingMessages) { - for (const [, cs] of chatStores) { - cs.getState().dispatch.messagesClear() - } - } - } - ignorePromise(f()) + ignorePromise(requestInboxLayout(reason)) }, loadStaticConfig: () => { if (get().staticConfig) { @@ -467,30 +445,25 @@ export const useChatState = Z.createZustand('chat', (set, get) => { storeRegistry.getConvoState(newConvID).dispatch.navigateToThread('findNewestConversation') } }, - metasReceived: (metas, removals) => { - removals?.forEach(r => { - storeRegistry.getConvoState(r).dispatch.setMeta() - }) - metas.forEach(m => { - const {meta: oldMeta, dispatch, isMetaGood} = storeRegistry.getConvoState(m.conversationIDKey) - if (isMetaGood()) { - dispatch.updateMeta(Meta.updateMeta(oldMeta, m)) - } else { - dispatch.setMeta(m) - } - }) - }, onChatInboxSynced: action => { const {syncRes} = action.payload.params const {clear} = useWaitingState.getState().dispatch - const {inboxRefresh} = get().dispatch clear(S.waitingKeyChatInboxSyncStarted) switch (syncRes.syncType) { // Just clear it all - case T.RPCChat.SyncInboxResType.clear: - inboxRefresh('inboxSyncedClear') + case T.RPCChat.SyncInboxResType.clear: { + const f = async () => { + await requestInboxLayout('inboxSyncedClear') + for (const [, cs] of chatStores) { + const {dispatch} = cs.getState() + dispatch.setMeta() + dispatch.messagesClear() + } + } + ignorePromise(f()) break + } // We're up to date case T.RPCChat.SyncInboxResType.current: break @@ -509,7 +482,7 @@ export const useChatState = Z.createZustand('chat', (set, get) => { const removals = syncRes.incremental.removals?.map(T.Chat.stringToConversationIDKey) // Update new untrusted if (metas.length || removals?.length) { - get().dispatch.metasReceived(metas, removals) + convoMetasReceived(metas, removals) } get().dispatch.unboxRows( @@ -613,7 +586,7 @@ export const useChatState = Z.createZustand('chat', (set, get) => { const {conv} = action.payload.params if (conv) { const meta = Meta.inboxUIItemToConversationMeta(conv) - meta && get().dispatch.metasReceived([meta]) + meta && convoMetasReceived([meta]) } break } @@ -906,7 +879,7 @@ export const useChatState = Z.createZustand('chat', (set, get) => { ) } if (metas.length > 0) { - get().dispatch.metasReceived(metas) + convoMetasReceived(metas) } }, onGetInboxUnverifiedConvs: action => { @@ -920,7 +893,7 @@ export const useChatState = Z.createZustand('chat', (set, get) => { return arr }, []) // Check if some of our existing stored metas might no longer be valid - get().dispatch.metasReceived(metas) + convoMetasReceived(metas) }, onIncomingInboxUIItem: conv => { if (!conv) return @@ -940,80 +913,8 @@ export const useChatState = Z.createZustand('chat', (set, get) => { ) if (meta) { - get().dispatch.metasReceived([meta]) - } - }, - onRouteChanged: (prev, next) => { - const maybeChangeChatSelection = () => { - const wasModal = prev && getModalStack(prev).length > 0 - const isModal = next && getModalStack(next).length > 0 - // ignore if changes involve a modal - if (wasModal || isModal) { - return - } - const p = getVisibleScreen(prev) - const n = getVisibleScreen(next) - const wasChat = p?.name === Common.threadRouteName - const isChat = n?.name === Common.threadRouteName - // nothing to do with chat - if (!wasChat && !isChat) { - return - } - const pParams = p?.params as undefined | {conversationIDKey?: T.Chat.ConversationIDKey} - const nParams = n?.params as undefined | {conversationIDKey?: T.Chat.ConversationIDKey} - const wasID = pParams?.conversationIDKey - const isID = nParams?.conversationIDKey - - logger.info('maybeChangeChatSelection ', {isChat, isID, wasChat, wasID}) - - // same? ignore - if (wasChat && isChat && wasID === isID) { - // if we've never loaded anything, keep going so we load it - if (!isID || storeRegistry.getConvoState(isID).loaded) { - return - } - } - - // deselect if there was one - const deselectAction = () => { - if (wasChat && wasID && T.Chat.isValidConversationIDKey(wasID)) { - get().dispatch.unboxRows([wasID], true) - // needed? - // storeRegistry.getConvoState(wasID).dispatch.clearOrangeLine('deselected') - } - } - - // still chatting? just select new one - if (wasChat && isChat && isID && T.Chat.isValidConversationIDKey(isID)) { - deselectAction() - storeRegistry.getConvoState(isID).dispatch.selectedConversation() - return - } - - // leaving a chat - if (wasChat && !isChat) { - deselectAction() - return - } - - // going into a chat - if (isChat && isID && T.Chat.isValidConversationIDKey(isID)) { - deselectAction() - storeRegistry.getConvoState(isID).dispatch.selectedConversation() - return - } + convoMetasReceived([meta]) } - - const maybeChatTabSelected = () => { - if (getTab(prev) !== Tabs.chatTab && getTab(next) === Tabs.chatTab) { - const n = getVisibleScreen(next) - const nParams = n?.params as undefined | {conversationIDKey?: T.Chat.ConversationIDKey} - const isID = nParams?.conversationIDKey - isID && storeRegistry.getConvoState(isID).dispatch.tabSelected() - } - } - maybeChangeChatSelection() - maybeChatTabSelected() }, onTeamBuildingFinished: users => { const f = async () => { @@ -1063,8 +964,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { dispatch: s.dispatch, staticConfig: s.staticConfig, })) - // also blow away convoState - clearChatStores() }, setInboxRetriedOnCurrentEmpty: retried => { set(s => { @@ -1139,30 +1038,7 @@ export const useChatState = Z.createZustand('chat', (set, get) => { s.inboxRetriedOnCurrentEmpty = false } if (!inboxHasLoaded) { - // on first layout, initialize any drafts and muted status - // After the first layout, any other updates will come in the form of meta updates. - layout.smallTeams?.forEach(t => { - const cs = storeRegistry.getConvoState(t.convID) - cs.dispatch.updateFromUIInboxLayout({ - ...t, - layoutName: t.name || '', - snippet: t.snippet ?? undefined, - teamname: t.isTeam ? t.name || '' : '', - time: t.time || 0, - }) - }) - layout.bigTeams?.forEach(t => { - if (t.state === T.RPCChat.UIInboxBigTeamRowTyp.channel) { - const cs = storeRegistry.getConvoState(t.channel.convID) - cs.dispatch.updateFromUIInboxLayout({ - ...t.channel, - snippet: undefined, - time: undefined, - }) - } - }) - // Flush inbox row updates synchronously to prevent flash of empty content - flushInboxRowUpdates() + hydrateInboxLayout(layout) } } catch (e) { logger.info('failed to JSON parse inbox layout: ' + e) diff --git a/shared/stores/convo-registry.tsx b/shared/stores/convo-registry.tsx index 2a5a30c3fbd6..f3db01b02745 100644 --- a/shared/stores/convo-registry.tsx +++ b/shared/stores/convo-registry.tsx @@ -1,5 +1,6 @@ import type * as T from '@/constants/types' import {registerDebugClear} from '@/util/debug' +import {registerExternalResetter} from '@/util/zustand' import type {StoreApi, UseBoundStore} from 'zustand' import type {ConvoState, ConvoUIState} from '@/stores/convostate' @@ -22,3 +23,5 @@ export const clearChatStores = () => { registerDebugClear(() => { clearChatStores() }) + +registerExternalResetter('convo-registry', clearChatStores) diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 3d21028a2be1..69a23d6bf701 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -4,6 +4,7 @@ import * as TeamsUtil from '@/constants/teams' import * as PlatformSpecific from '@/util/platform-specific' import { clearModals, + getTab, navigateAppend, navigateToInbox, navigateUp, @@ -15,6 +16,7 @@ import { navToThread, setChatRootParams, } from '@/constants/router' +import type * as Router2 from '@/constants/router' import {isIOS} from '@/constants/platform' import {updateImmer} from '@/constants/utils' import * as T from '@/constants/types' @@ -45,7 +47,7 @@ import {clearChatTimeCache} from '@/util/timestamp' import * as Config from '@/constants/config' import {isMobile} from '@/constants/platform' import {enumKeys, ignorePromise, shallowEqual} from '@/constants/utils' -import {queueInboxRowUpdate} from './inbox-rows' +import {flushInboxRowUpdates, queueInboxRowUpdate} from './inbox-rows' import * as Strings from '@/constants/strings' import {chatStores, clearChatStores, convoUIStores} from './convo-registry' @@ -452,6 +454,100 @@ export const setConvoDefer = (impl: ConvoState['dispatch']['defer']) => { } } +export const onRouteChanged = (prev: T.Immutable, next: T.Immutable) => { + const wasModal = prev && getModalStack(prev).length > 0 + const isModal = next && getModalStack(next).length > 0 + // ignore if changes involve a modal + if (!wasModal && !isModal) { + const p = getVisibleScreen(prev) + const n = getVisibleScreen(next) + const wasChat = p?.name === Common.threadRouteName + const isChat = n?.name === Common.threadRouteName + // nothing to do with chat + if (wasChat || isChat) { + const pParams = p?.params as undefined | {conversationIDKey?: T.Chat.ConversationIDKey} + const nParams = n?.params as undefined | {conversationIDKey?: T.Chat.ConversationIDKey} + const wasID = pParams?.conversationIDKey + const isID = nParams?.conversationIDKey + + logger.info('maybeChangeChatSelection ', {isChat, isID, wasChat, wasID}) + + // same? ignore + if (!(wasChat && isChat && wasID === isID && (!isID || getConvoState(isID).loaded))) { + const deselectAction = () => { + if (wasChat && wasID && T.Chat.isValidConversationIDKey(wasID)) { + getConvoState(wasID).dispatch.defer.chatUnboxRows([wasID], true) + // needed? + // getConvoState(wasID).dispatch.clearOrangeLine('deselected') + } + } + + // still chatting? just select new one + if (wasChat && isChat && isID && T.Chat.isValidConversationIDKey(isID)) { + deselectAction() + getConvoState(isID).dispatch.selectedConversation() + } else if (wasChat && !isChat) { + // leaving a chat + deselectAction() + } else if (isChat && isID && T.Chat.isValidConversationIDKey(isID)) { + // going into a chat + deselectAction() + getConvoState(isID).dispatch.selectedConversation() + } + } + } + } + + if (getTab(prev) !== Tabs.chatTab && getTab(next) === Tabs.chatTab) { + const n = getVisibleScreen(next) + const nParams = n?.params as undefined | {conversationIDKey?: T.Chat.ConversationIDKey} + const isID = nParams?.conversationIDKey + isID && getConvoState(isID).dispatch.tabSelected() + } +} + +export const metasReceived = ( + metas: ReadonlyArray, + removals?: ReadonlyArray +) => { + removals?.forEach(r => { + getConvoState(r).dispatch.setMeta() + }) + metas.forEach(m => { + const {meta: oldMeta, dispatch, isMetaGood} = getConvoState(m.conversationIDKey) + if (isMetaGood()) { + dispatch.updateMeta(Meta.updateMeta(oldMeta, m)) + } else { + dispatch.setMeta(m) + } + }) +} + +export const hydrateInboxLayout = (layout: T.RPCChat.UIInboxLayout) => { + layout.smallTeams?.forEach(t => { + const cs = getConvoState(t.convID) + cs.dispatch.updateFromUIInboxLayout({ + ...t, + layoutName: t.name || '', + snippet: t.snippet ?? undefined, + teamname: t.isTeam ? t.name || '' : '', + time: t.time || 0, + }) + }) + layout.bigTeams?.forEach(t => { + if (t.state === T.RPCChat.UIInboxBigTeamRowTyp.channel) { + const cs = getConvoState(t.channel.convID) + cs.dispatch.updateFromUIInboxLayout({ + ...t.channel, + snippet: undefined, + time: undefined, + }) + } + }) + // Flush inbox row updates synchronously to prevent flash of empty content + flushInboxRowUpdates() +} + const formatTextForQuoting = (text: string) => text .split('\n') From 320d581070ce8a2804fdb24d37fc45fb6c1305f7 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 15 Apr 2026 19:01:46 -0400 Subject: [PATCH 03/25] WIP --- plans/chat-split.md | 29 ++--- .../conversation/input-area/normal/index.tsx | 2 +- shared/chat/inbox/use-inbox-search.tsx | 5 +- shared/chat/inbox/use-inbox-state.test.ts | 5 +- shared/chat/inbox/use-inbox-state.tsx | 4 +- shared/constants/init/shared.tsx | 1 - shared/menubar/remote-proxy.desktop.tsx | 12 +- shared/stores/chat.tsx | 114 ++---------------- shared/stores/convostate.tsx | 114 ++++++++++++++++-- shared/teams/channel/index.tsx | 4 +- 10 files changed, 137 insertions(+), 153 deletions(-) diff --git a/plans/chat-split.md b/plans/chat-split.md index f241f20e6012..9867eddb7101 100644 --- a/plans/chat-split.md +++ b/plans/chat-split.md @@ -27,6 +27,7 @@ These cleanup steps are already done: - route subscriptions now call convo-owned selection handling directly; `chat.tsx` no longer owns route selection - `metasReceived` now applies convo meta updates from `convostate` - first-layout inbox hydration moved out of `chat.updateInboxLayout` +- `ensureWidgetMetas`, meta queueing, and `unboxRows` now live in `convostate` This means the remaining work is about removing the actual `chat -> convo` logic, not import barrels. @@ -38,22 +39,7 @@ This means the remaining work is about removing the actual `chat -> convo` logic ## Remaining `chat -> convo` Logic Buckets -### 1. Convo Hydration / Bootstrap - -These are chat-global flows that currently materialize or mutate convo state: - -- `ensureWidgetMetas` -- `queueMetaToRequest` -- `queueMetaHandle` -- `unboxRows` - -Desired end state: - -- `chat` owns inbox/global source data -- convo hydration and trusted/untrusted materialization move to convo ownership -- `chat` no longer pushes metadata into convo stores - -### 2. Create Conversation Flow +### 1. Create Conversation Flow `createConversation` currently: @@ -67,7 +53,7 @@ Desired end state: - conversation-creation flow lives with the feature or pending-convo ownership - `chat.tsx` does not navigate threads or write pending convo state -### 3. Engine Notification Fanout +### 2. Engine Notification Fanout Most of `onEngineIncomingImpl` is a dispatcher into specific convo stores. @@ -97,7 +83,7 @@ Desired end state: - convo-targeted notifications are handled by convo-owned entrypoints - `chat.tsx` only handles truly global notifications -### 4. Badge / Unread Ownership +### 3. Badge / Unread Ownership This is last because it is the riskiest ownership decision. @@ -115,10 +101,9 @@ Do not decide this early. Resolve simpler buckets first. ## Recommended Order -1. Convo hydration / bootstrap -2. Create conversation flow -3. Engine notification fanout -4. Badge / unread ownership +1. Create conversation flow +2. Engine notification fanout +3. Badge / unread ownership ## Acceptance Criteria diff --git a/shared/chat/conversation/input-area/normal/index.tsx b/shared/chat/conversation/input-area/normal/index.tsx index d4a5d0079beb..171da7ec3a7c 100644 --- a/shared/chat/conversation/input-area/normal/index.tsx +++ b/shared/chat/conversation/input-area/normal/index.tsx @@ -228,7 +228,7 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() { React.useEffect(() => { const rows = [loadIDOnUnloadRef.current] return () => { - Chat.useChatState.getState().dispatch.unboxRows(rows) + ConvoState.unboxRows(rows) } }, [loadIDOnUnloadRef]) diff --git a/shared/chat/inbox/use-inbox-search.tsx b/shared/chat/inbox/use-inbox-search.tsx index 4cb4f9dd36de..e79686b7ea8d 100644 --- a/shared/chat/inbox/use-inbox-search.tsx +++ b/shared/chat/inbox/use-inbox-search.tsx @@ -4,7 +4,6 @@ import logger from '@/logger' import {useConfigState} from '@/stores/config' import {RPCError} from '@/util/errors' import {isMobile} from '@/constants/platform' -import * as Chat from '@/stores/chat' import * as ConvoState from '@/stores/convostate' import * as React from 'react' @@ -260,7 +259,7 @@ export function useInboxSearch(): InboxSearchController { return arr }, []) if (missingMetas.length > 0) { - Chat.useChatState.getState().dispatch.unboxRows(missingMetas, true) + ConvoState.unboxRows(missingMetas, true) } } @@ -316,7 +315,7 @@ export function useInboxSearch(): InboxSearchController { if ( ConvoState.getConvoState(result.conversationIDKey).meta.conversationIDKey === T.Chat.noConversationIDKey ) { - Chat.useChatState.getState().dispatch.unboxRows([result.conversationIDKey], true) + ConvoState.unboxRows([result.conversationIDKey], true) } } diff --git a/shared/chat/inbox/use-inbox-state.test.ts b/shared/chat/inbox/use-inbox-state.test.ts index fb721a85d87e..a8217bc88361 100644 --- a/shared/chat/inbox/use-inbox-state.test.ts +++ b/shared/chat/inbox/use-inbox-state.test.ts @@ -5,7 +5,6 @@ type MockChatState = { badgeStateVersion: number dispatch: { inboxRefresh: jest.Mock - queueMetaToRequest: jest.Mock setInboxRetriedOnCurrentEmpty: jest.Mock } inboxHasLoaded: boolean @@ -55,6 +54,7 @@ jest.mock('@/stores/convostate', () => ({ tabSelected: jest.fn(), }, }), + queueMetaToRequest: jest.fn(), })) jest.mock('@/stores/config', () => ({ @@ -78,19 +78,16 @@ import {useInboxState} from './use-inbox-state' let mockLoadInboxNumSmallRows: jest.Mock let mockInboxRefresh: jest.Mock -let mockQueueMetaToRequest: jest.Mock let mockSetInboxRetriedOnCurrentEmpty: jest.Mock beforeEach(() => { mockLoadInboxNumSmallRows = jest.fn() mockInboxRefresh = jest.fn() - mockQueueMetaToRequest = jest.fn() mockSetInboxRetriedOnCurrentEmpty = jest.fn() mockChatState = { badgeStateVersion: 0, dispatch: { inboxRefresh: mockInboxRefresh, - queueMetaToRequest: mockQueueMetaToRequest, setInboxRetriedOnCurrentEmpty: mockSetInboxRetriedOnCurrentEmpty, }, inboxHasLoaded: true, diff --git a/shared/chat/inbox/use-inbox-state.tsx b/shared/chat/inbox/use-inbox-state.tsx index d6b83ef78bb2..fc6f854158aa 100644 --- a/shared/chat/inbox/use-inbox-state.tsx +++ b/shared/chat/inbox/use-inbox-state.tsx @@ -21,7 +21,6 @@ export function useInboxState(conversationIDKey?: string, isSearching = false) { inboxLayout: s.inboxLayout, inboxRefresh: s.dispatch.inboxRefresh, inboxRetriedOnCurrentEmpty: s.inboxRetriedOnCurrentEmpty, - queueMetaToRequest: s.dispatch.queueMetaToRequest, setInboxRetriedOnCurrentEmpty: s.dispatch.setInboxRetriedOnCurrentEmpty, })) ) @@ -31,7 +30,6 @@ export function useInboxState(conversationIDKey?: string, isSearching = false) { inboxLayout, inboxRefresh, inboxRetriedOnCurrentEmpty, - queueMetaToRequest, setInboxRetriedOnCurrentEmpty, } = chatState const [inboxNumSmallRows, setInboxNumSmallRowsState] = React.useState(5) @@ -221,7 +219,7 @@ export function useInboxState(conversationIDKey?: string, isSearching = false) { isSearching, neverLoaded: !inboxHasLoaded, onNewChat: appendNewChatBuilder, - onUntrustedInboxVisible: queueMetaToRequest, + onUntrustedInboxVisible: ConvoState.queueMetaToRequest, rows: inboxRows, selectedConversationIDKey, setInboxNumSmallRows, diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 2e1759048bf4..28cbda496529 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -176,7 +176,6 @@ export const initSharedSubscriptions = () => { storeRegistry.getState('chat').inboxLayout?.smallTeams?.[0]?.convID, chatInboxRefresh: reason => storeRegistry.getState('chat').dispatch.inboxRefresh(reason), chatMetasReceived: metas => convoMetasReceived(metas), - chatUnboxRows: (convIDs, force) => storeRegistry.getState('chat').dispatch.unboxRows(convIDs, force), }) _sharedUnsubs.push( useConfigState.subscribe((s, old) => { diff --git a/shared/menubar/remote-proxy.desktop.tsx b/shared/menubar/remote-proxy.desktop.tsx index 624eb257f844..b44e2e4668f3 100644 --- a/shared/menubar/remote-proxy.desktop.tsx +++ b/shared/menubar/remote-proxy.desktop.tsx @@ -181,8 +181,7 @@ function useEnsureWidgetData( loggedIn: boolean, inboxHasLoaded: boolean, widgetList: ReadonlyArray<{convID: T.Chat.ConversationIDKey}> | undefined, - inboxRefresh: (reason: Chat.RefreshReason) => void, - ensureWidgetMetas: () => void + inboxRefresh: (reason: Chat.RefreshReason) => void ) { React.useEffect(() => { if (loggedIn && inboxHasLoaded && !widgetList) { @@ -192,9 +191,9 @@ function useEnsureWidgetData( React.useEffect(() => { if (widgetList) { - ensureWidgetMetas() + ConvoState.ensureWidgetMetas(widgetList) } - }, [widgetList, ensureWidgetMetas]) + }, [widgetList]) } function useMenubarRemoteProps(): Props { @@ -212,15 +211,14 @@ function useMenubarRemoteProps(): Props { }) ) const navBadgesMap = useNotifState(s => s.navBadges) - const {widgetList, inboxHasLoaded, inboxRefresh, ensureWidgetMetas} = Chat.useChatState( + const {widgetList, inboxHasLoaded, inboxRefresh} = Chat.useChatState( C.useShallow(s => ({ - ensureWidgetMetas: s.dispatch.ensureWidgetMetas, inboxHasLoaded: s.inboxHasLoaded, inboxRefresh: s.dispatch.inboxRefresh, widgetList: s.inboxLayout?.widgetList ?? undefined, })) ) - useEnsureWidgetData(loggedIn, inboxHasLoaded, widgetList, inboxRefresh, ensureWidgetMetas) + useEnsureWidgetData(loggedIn, inboxHasLoaded, widgetList, inboxRefresh) const conversationsToSend = useWidgetConversationList(widgetList) const isDarkMode = useColorScheme() === 'dark' const {diskSpaceStatus, showingBanner} = overallSyncStatus diff --git a/shared/stores/chat.tsx b/shared/stores/chat.tsx index 82b2432f7c63..33e606e62630 100644 --- a/shared/stores/chat.tsx +++ b/shared/stores/chat.tsx @@ -12,7 +12,12 @@ import type {RefreshReason} from '@/stores/chat-shared' import {RPCError} from '@/util/errors' import {bodyToJSON} from '@/constants/rpc-utils' import {chatStores} from '@/stores/convo-registry' -import {hydrateInboxLayout, metasReceived as convoMetasReceived} from '@/stores/convostate' +import { + ensureWidgetMetas as ensureConvoWidgetMetas, + hydrateInboxLayout, + metasReceived as convoMetasReceived, + unboxRows as convoUnboxRows, +} from '@/stores/convostate' import {ignorePromise, timeoutPromise} from '@/constants/utils' import {isPhone} from '@/constants/platform' import {navigateToInbox} from '@/constants/router' @@ -179,7 +184,6 @@ export type State = Store & { createConversation: (participants: ReadonlyArray, highlightMessageID?: T.Chat.MessageID) => void dismissBlockButtons: (teamID: T.RPCGen.TeamID) => void dismissBlockButtonsIfPresent: (teamID: T.RPCGen.TeamID) => void - ensureWidgetMetas: () => void inboxRefresh: (reason: RefreshReason) => void setInboxRetriedOnCurrentEmpty: (retried: boolean) => void loadStaticConfig: () => void @@ -191,11 +195,8 @@ export type State = Store & { onGetInboxUnverifiedConvs: (action: EngineGen.EngineAction<'chat.1.chatUi.chatInboxUnverified'>) => void onIncomingInboxUIItem: (inboxUIItem?: T.RPCChat.InboxUIItem) => void onTeamBuildingFinished: (users: ReadonlySet) => void - queueMetaToRequest: (ids: ReadonlyArray) => void - queueMetaHandle: () => void resetState: () => void setMaybeMentionInfo: (name: string, info: T.RPCChat.UIMaybeMentionInfo) => void - unboxRows: (ids: ReadonlyArray, force?: boolean) => void updateInboxLayout: (layout: string) => void updateUserReacjis: (userReacjis: T.RPCGen.UserReacjis) => void updatedGregor: ( @@ -204,15 +205,8 @@ export type State = Store & { } } -// Only get the untrusted conversations out -const untrustedConversationIDKeys = (ids: ReadonlyArray) => - ids.filter(id => storeRegistry.getConvoState(id).meta.trustedState === 'untrusted') - // generic chat store export const useChatState = Z.createZustand('chat', (set, get) => { - // We keep a set of conversations to unbox - let metaQueue = new Set() - const requestInboxLayout = async (reason: RefreshReason) => { const {username} = useCurrentUserState.getState() const {loggedIn} = useConfigState.getState() @@ -335,22 +329,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { get().dispatch.dismissBlockButtons(teamID) } }, - ensureWidgetMetas: () => { - const {inboxLayout} = get() - if (!inboxLayout?.widgetList) { - return - } - const missing = inboxLayout.widgetList.reduce>((l, v) => { - if (!storeRegistry.getConvoState(v.convID).isMetaGood()) { - l.push(v.convID) - } - return l - }, []) - if (missing.length === 0) { - return - } - get().dispatch.unboxRows(missing, true) - }, inboxRefresh: reason => { ignorePromise(requestInboxLayout(reason)) }, @@ -485,7 +463,7 @@ export const useChatState = Z.createZustand('chat', (set, get) => { convoMetasReceived(metas, removals) } - get().dispatch.unboxRows( + convoUnboxRows( items.filter(i => i.shouldUnbox).map(i => T.Chat.stringToConversationIDKey(i.conv.convID)), true ) @@ -520,7 +498,7 @@ export const useChatState = Z.createZustand('chat', (set, get) => { logger.info( `onChatThreadStale: dispatching thread reload actions for ${conversationIDKeys.length} convs of type ${key}` ) - get().dispatch.unboxRows(conversationIDKeys, true) + convoUnboxRows(conversationIDKeys, true) if (T.RPCChat.StaleUpdateType[key] === T.RPCChat.StaleUpdateType.clear) { conversationIDKeys.forEach(convID => { // For the selected conversation, skip immediate clear — the deferred @@ -613,11 +591,11 @@ export const useChatState = Z.createZustand('chat', (set, get) => { case 'chat.1.NotifyChat.ChatSubteamRename': { const {convs} = action.payload.params const conversationIDKeys = (convs ?? []).map(c => T.Chat.stringToConversationIDKey(c.convID)) - get().dispatch.unboxRows(conversationIDKeys, true) + convoUnboxRows(conversationIDKeys, true) break } case 'chat.1.NotifyChat.ChatTLFFinalize': - get().dispatch.unboxRows([T.Chat.conversationIDToKey(action.payload.params.convID)]) + convoUnboxRows([T.Chat.conversationIDToKey(action.payload.params.convID)]) break case 'chat.1.NotifyChat.ChatIdentifyUpdate': { // Some participants are broken/fixed now @@ -641,7 +619,7 @@ export const useChatState = Z.createZustand('chat', (set, get) => { case 'chat.1.chatUi.chatInboxLayout': get().dispatch.updateInboxLayout(action.payload.params.layout) get().dispatch.maybeChangeSelectedConv() - get().dispatch.ensureWidgetMetas() + ensureConvoWidgetMetas(get().inboxLayout?.widgetList) break case 'chat.1.NotifyChat.ChatInboxStale': get().dispatch.inboxRefresh('inboxStale') @@ -699,7 +677,7 @@ export const useChatState = Z.createZustand('chat', (set, get) => { break } case T.RPCChat.ChatActivityType.membersUpdate: - get().dispatch.unboxRows([T.Chat.conversationIDToKey(activity.membersUpdate.convID)], true) + convoUnboxRows([T.Chat.conversationIDToKey(activity.membersUpdate.convID)], true) break case T.RPCChat.ChatActivityType.setAppNotificationSettings: { const {setAppNotificationSettings} = activity @@ -927,36 +905,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { } ignorePromise(f()) }, - queueMetaHandle: () => { - // Watch the meta queue and take up to 10 items. Choose the last items first since they're likely still visible - const f = async () => { - const maxToUnboxAtATime = 10 - const ar = [...metaQueue] - const maybeUnbox = ar.slice(0, maxToUnboxAtATime) - metaQueue = new Set(ar.slice(maxToUnboxAtATime)) - const conversationIDKeys = untrustedConversationIDKeys(maybeUnbox) - if (conversationIDKeys.length) { - get().dispatch.unboxRows(conversationIDKeys) - } - if (metaQueue.size && conversationIDKeys.length) { - await timeoutPromise(100) - } - if (metaQueue.size) { - get().dispatch.queueMetaHandle() - } - } - ignorePromise(f()) - }, - queueMetaToRequest: ids => { - const prevSize = metaQueue.size - untrustedConversationIDKeys(ids).forEach(k => metaQueue.add(k)) - if (metaQueue.size > prevSize) { - // only unboxMore if something changed - get().dispatch.queueMetaHandle() - } else { - logger.info('skipping meta queue run, queue unchanged') - } - }, resetState: () => { set(s => ({ ...s, @@ -976,44 +924,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { maybeMentionMap.set(name, T.castDraft(info)) }) }, - unboxRows: (ids, force) => { - // We want to unbox rows that have scroll into view - const f = async () => { - if (!useConfigState.getState().loggedIn) { - return - } - - // Get valid keys that we aren't already loading or have loaded - const conversationIDKeys = ids.reduce((arr: Array, id) => { - if (id && T.Chat.isValidConversationIDKey(id)) { - const cs = storeRegistry.getConvoState(id) - const trustedState = cs.meta.trustedState - if (force || (trustedState !== 'requesting' && trustedState !== 'trusted')) { - arr.push(id) - cs.dispatch.updateMeta({trustedState: 'requesting'}) - } - } - return arr - }, []) - - if (!conversationIDKeys.length) { - return - } - logger.info( - `unboxRows: unboxing len: ${conversationIDKeys.length} convs: ${conversationIDKeys.join(',')}` - ) - try { - await T.RPCChat.localRequestInboxUnboxRpcPromise({ - convIDs: conversationIDKeys.map(k => T.Chat.keyToConversationID(k)), - }) - } catch (error) { - if (error instanceof RPCError) { - logger.info(`unboxRows: failed ${error.desc}`) - } - } - } - ignorePromise(f()) - }, updateInboxLayout: str => { set(s => { try { diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 69a23d6bf701..705f2cccff01 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -44,9 +44,10 @@ import KB2 from '@/util/electron' import {NotifyPopup} from '@/util/misc' import {hexToUint8Array} from '@/util/uint8array' import {clearChatTimeCache} from '@/util/timestamp' +import {registerExternalResetter} from '@/util/zustand' import * as Config from '@/constants/config' import {isMobile} from '@/constants/platform' -import {enumKeys, ignorePromise, shallowEqual} from '@/constants/utils' +import {enumKeys, ignorePromise, shallowEqual, timeoutPromise} from '@/constants/utils' import {flushInboxRowUpdates, queueInboxRowUpdate} from './inbox-rows' import * as Strings from '@/constants/strings' import {chatStores, clearChatStores, convoUIStores} from './convo-registry' @@ -258,7 +259,6 @@ export interface ConvoState extends ConvoStore { chatInboxLayoutSmallTeamsFirstConvID: () => T.Chat.ConversationIDKey | undefined chatInboxRefresh: (reason: RefreshReason) => void chatMetasReceived: (metas: ReadonlyArray) => void - chatUnboxRows: (convIDs: ReadonlyArray, force: boolean) => void } dismissBottomBanner: () => void dismissJourneycard: (cardType: T.RPCChat.JourneycardType, ordinal: T.Chat.Ordinal) => void @@ -430,9 +430,6 @@ const stubDefer: ConvoState['dispatch']['defer'] = { chatMetasReceived: () => { throw new Error('convostate defer not initialized') }, - chatUnboxRows: () => { - throw new Error('convostate defer not initialized') - }, } let convoDeferImpl: ConvoState['dispatch']['defer'] | undefined = __DEV__ @@ -476,7 +473,7 @@ export const onRouteChanged = (prev: T.Immutable, next: T.Immu if (!(wasChat && isChat && wasID === isID && (!isID || getConvoState(isID).loaded))) { const deselectAction = () => { if (wasChat && wasID && T.Chat.isValidConversationIDKey(wasID)) { - getConvoState(wasID).dispatch.defer.chatUnboxRows([wasID], true) + unboxRows([wasID], true) // needed? // getConvoState(wasID).dispatch.clearOrangeLine('deselected') } @@ -548,6 +545,109 @@ export const hydrateInboxLayout = (layout: T.RPCChat.UIInboxLayout) => { flushInboxRowUpdates() } +let metaQueue: Set = __DEV__ + ? (((globalThis as {__hmr_convoMetaQueue?: Set}).__hmr_convoMetaQueue ??= + new Set()) as Set) + : new Set() + +const clearMetaQueue = () => { + metaQueue.clear() +} + +registerExternalResetter('convo-meta-queue', clearMetaQueue) + +const untrustedConversationIDKeys = (ids: ReadonlyArray) => + ids.filter(id => getConvoState(id).meta.trustedState === 'untrusted') + +export const unboxRows = (ids: ReadonlyArray, force?: boolean) => { + const f = async () => { + if (!useConfigState.getState().loggedIn) { + return + } + + const conversationIDKeys = ids.reduce>((arr, id) => { + if (id && T.Chat.isValidConversationIDKey(id)) { + const cs = getConvoState(id) + const trustedState = cs.meta.trustedState + if (force || (trustedState !== 'requesting' && trustedState !== 'trusted')) { + arr.push(id) + cs.dispatch.updateMeta({trustedState: 'requesting'}) + } + } + return arr + }, []) + + if (!conversationIDKeys.length) { + return + } + logger.info(`unboxRows: unboxing len: ${conversationIDKeys.length} convs: ${conversationIDKeys.join(',')}`) + try { + await T.RPCChat.localRequestInboxUnboxRpcPromise({ + convIDs: conversationIDKeys.map(k => T.Chat.keyToConversationID(k)), + }) + } catch (error) { + if (error instanceof RPCError) { + logger.info(`unboxRows: failed ${error.desc}`) + } + } + } + ignorePromise(f()) +} + +export const queueMetaHandle = () => { + const f = async () => { + const maxToUnboxAtATime = 10 + const ar = [...metaQueue] + const maybeUnbox = ar.slice(0, maxToUnboxAtATime) + metaQueue = new Set(ar.slice(maxToUnboxAtATime)) + if (__DEV__) { + ;(globalThis as {__hmr_convoMetaQueue?: Set}).__hmr_convoMetaQueue = metaQueue + } + const conversationIDKeys = untrustedConversationIDKeys(maybeUnbox) + if (conversationIDKeys.length) { + unboxRows(conversationIDKeys) + } + if (metaQueue.size && conversationIDKeys.length) { + await timeoutPromise(100) + } + if (metaQueue.size) { + queueMetaHandle() + } + } + ignorePromise(f()) +} + +export const queueMetaToRequest = (ids: ReadonlyArray) => { + const prevSize = metaQueue.size + untrustedConversationIDKeys(ids).forEach(k => metaQueue.add(k)) + if (__DEV__) { + ;(globalThis as {__hmr_convoMetaQueue?: Set}).__hmr_convoMetaQueue = metaQueue + } + if (metaQueue.size > prevSize) { + queueMetaHandle() + } else { + logger.info('skipping meta queue run, queue unchanged') + } +} + +export const ensureWidgetMetas = ( + widgetList: ReadonlyArray<{convID: T.Chat.ConversationIDKey}> | undefined +) => { + if (!widgetList) { + return + } + const missing = widgetList.reduce>((l, v) => { + if (!getConvoState(v.convID).isMetaGood()) { + l.push(v.convID) + } + return l + }, []) + if (missing.length === 0) { + return + } + unboxRows(missing, true) +} + const formatTextForQuoting = (text: string) => text .split('\n') @@ -2903,7 +3003,7 @@ const createSlice = const participantInfo = get().participants const force = !get().isMetaGood() || participantInfo.all.length === 0 - get().dispatch.defer.chatUnboxRows([conversationIDKey], force) + unboxRows([conversationIDKey], force) set(s => { s.threadLoadStatus = T.RPCChat.UIChatThreadStatusTyp.none }) diff --git a/shared/teams/channel/index.tsx b/shared/teams/channel/index.tsx index edd29af9c35f..b438365642bd 100644 --- a/shared/teams/channel/index.tsx +++ b/shared/teams/channel/index.tsx @@ -34,17 +34,15 @@ const useLoadDataForChannelPage = ( ) => { const prevSelectedTabRef = React.useRef(selectedTab) const getBlockState = useUsersState(s => s.dispatch.getBlockState) - const unboxRows = Chat.useChatState(s => s.dispatch.unboxRows) useLoadTeamMembers(teamID, ['bots', 'members', 'settings'].includes(selectedTab)) React.useEffect(() => { if (selectedTab !== prevSelectedTabRef.current && selectedTab === 'members') { if (meta.conversationIDKey === 'EMPTY') { - unboxRows([conversationIDKey]) + ConvoState.unboxRows([conversationIDKey]) } getBlockState(participants) } }, [ - unboxRows, getBlockState, selectedTab, conversationIDKey, From 779e31f5fcd7d135924c0f96bfb6282bfa821607 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 15 Apr 2026 19:05:15 -0400 Subject: [PATCH 04/25] WIP --- plans/chat-split.md | 24 ++---- .../messages/special-top-message.tsx | 4 +- shared/constants/init/shared.tsx | 9 +- shared/constants/router.tsx | 3 +- shared/stores/chat.tsx | 84 +------------------ shared/stores/convostate.tsx | 84 +++++++++++++++++++ 6 files changed, 100 insertions(+), 108 deletions(-) diff --git a/plans/chat-split.md b/plans/chat-split.md index 9867eddb7101..2bcc1120f5ad 100644 --- a/plans/chat-split.md +++ b/plans/chat-split.md @@ -28,6 +28,7 @@ These cleanup steps are already done: - `metasReceived` now applies convo meta updates from `convostate` - first-layout inbox hydration moved out of `chat.updateInboxLayout` - `ensureWidgetMetas`, meta queueing, and `unboxRows` now live in `convostate` +- conversation creation and team-building handoff now live in `convostate` This means the remaining work is about removing the actual `chat -> convo` logic, not import barrels. @@ -39,21 +40,7 @@ This means the remaining work is about removing the actual `chat -> convo` logic ## Remaining `chat -> convo` Logic Buckets -### 1. Create Conversation Flow - -`createConversation` currently: - -- performs the RPC -- seeds participants/meta -- navigates pending/new convo state -- populates pending error convo state - -Desired end state: - -- conversation-creation flow lives with the feature or pending-convo ownership -- `chat.tsx` does not navigate threads or write pending convo state - -### 2. Engine Notification Fanout +### 1. Engine Notification Fanout Most of `onEngineIncomingImpl` is a dispatcher into specific convo stores. @@ -83,7 +70,7 @@ Desired end state: - convo-targeted notifications are handled by convo-owned entrypoints - `chat.tsx` only handles truly global notifications -### 3. Badge / Unread Ownership +### 2. Badge / Unread Ownership This is last because it is the riskiest ownership decision. @@ -101,9 +88,8 @@ Do not decide this early. Resolve simpler buckets first. ## Recommended Order -1. Create conversation flow -2. Engine notification fanout -3. Badge / unread ownership +1. Engine notification fanout +2. Badge / unread ownership ## Acceptance Criteria diff --git a/shared/chat/conversation/messages/special-top-message.tsx b/shared/chat/conversation/messages/special-top-message.tsx index ebb812290b2f..e8bf10cb57ea 100644 --- a/shared/chat/conversation/messages/special-top-message.tsx +++ b/shared/chat/conversation/messages/special-top-message.tsx @@ -1,5 +1,4 @@ import * as C from '@/constants' -import * as Chat from '@/stores/chat' import * as ConvoState from '@/stores/convostate' import * as T from '@/constants/types' import * as Kb from '@/common-adapters' @@ -17,10 +16,9 @@ import {useCurrentUserState} from '@/stores/current-user' const ErrorMessage = () => { const createConversationError = useChatThreadRouteParams()?.createConversationError - const createConversation = Chat.useChatState(s => s.dispatch.createConversation) const _onCreateWithoutThem = (allowedUsers: ReadonlyArray) => { - createConversation(allowedUsers) + ConvoState.createConversation(allowedUsers) } const navigateToInbox = C.Router2.navigateToInbox diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 28cbda496529..deddcf2b971e 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -49,7 +49,12 @@ import {useSettingsContactsState} from '@/stores/settings-contacts' import {useTeamsState} from '@/stores/teams' import {useRouterState} from '@/stores/router' import * as Util from '@/constants/router' -import {metasReceived as convoMetasReceived, onRouteChanged as onConvoRouteChanged, setConvoDefer} from '@/stores/convostate' +import { + metasReceived as convoMetasReceived, + onRouteChanged as onConvoRouteChanged, + onTeamBuildingFinished as onConvoTeamBuildingFinished, + setConvoDefer, +} from '@/stores/convostate' import {clearSignupEmail} from '@/people/signup-email' import {clearSignupDeviceNameDraft} from '@/signup/device-name-draft' @@ -133,7 +138,7 @@ export const initTeamBuildingCallbacks = () => { ...(namespace === 'chat' ? { onFinishedTeamBuildingChat: users => { - storeRegistry.getState('chat').dispatch.onTeamBuildingFinished(users) + onConvoTeamBuildingFinished(users) }, } : {}), diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index 66d4f20479b5..fb84dbe9bdec 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -370,7 +370,8 @@ export const previewConversation = (p: PreviewConversationParams) => { storeRegistry .getConvoState(T.Chat.pendingWaitingConversationIDKey) .dispatch.navigateToThread('justCreated') - storeRegistry.getState('chat').dispatch.createConversation(participants, highlightMessageID) + const {createConversation} = require('@/stores/convostate') as typeof import('@/stores/convostate') + createConversation(participants, highlightMessageID) } const previewConversationTeam = async () => { diff --git a/shared/stores/chat.tsx b/shared/stores/chat.tsx index 33e606e62630..804d6b463805 100644 --- a/shared/stores/chat.tsx +++ b/shared/stores/chat.tsx @@ -18,7 +18,7 @@ import { metasReceived as convoMetasReceived, unboxRows as convoUnboxRows, } from '@/stores/convostate' -import {ignorePromise, timeoutPromise} from '@/constants/utils' +import {ignorePromise} from '@/constants/utils' import {isPhone} from '@/constants/platform' import {navigateToInbox} from '@/constants/router' import {storeRegistry} from '@/stores/store-registry' @@ -181,7 +181,6 @@ const initialStore: Store = { export type State = Store & { dispatch: { badgesUpdated: (badgeState?: T.RPCGen.BadgeState) => void - createConversation: (participants: ReadonlyArray, highlightMessageID?: T.Chat.MessageID) => void dismissBlockButtons: (teamID: T.RPCGen.TeamID) => void dismissBlockButtonsIfPresent: (teamID: T.RPCGen.TeamID) => void inboxRefresh: (reason: RefreshReason) => void @@ -194,7 +193,6 @@ export type State = Store & { onGetInboxConvsUnboxed: (action: EngineGen.EngineAction<'chat.1.chatUi.chatInboxConversation'>) => void onGetInboxUnverifiedConvs: (action: EngineGen.EngineAction<'chat.1.chatUi.chatInboxUnverified'>) => void onIncomingInboxUIItem: (inboxUIItem?: T.RPCChat.InboxUIItem) => void - onTeamBuildingFinished: (users: ReadonlySet) => void resetState: () => void setMaybeMentionInfo: (name: string, info: T.RPCChat.UIMaybeMentionInfo) => void updateInboxLayout: (layout: string) => void @@ -243,75 +241,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { s.bigTeamBadgeCount = bigTeamBadgeCount }) }, - createConversation: (participants, highlightMessageID) => { - // TODO This will break if you try to make 2 new conversations at the same time because there is - // only one pending conversation state. - // The fix involves being able to make multiple pending conversations - const f = async () => { - const username = useCurrentUserState.getState().username - if (!username) { - logger.error('Making a convo while logged out?') - return - } - try { - const result = await T.RPCChat.localNewConversationLocalRpcPromise( - { - identifyBehavior: T.RPCGen.TLFIdentifyBehavior.chatGui, - membersType: T.RPCChat.ConversationMembersType.impteamnative, - tlfName: [...new Set([username, ...participants])].join(','), - tlfVisibility: T.RPCGen.TLFVisibility.private, - topicType: T.RPCChat.TopicType.chat, - }, - S.waitingKeyChatCreating - ) - const {conv, uiConv} = result - const conversationIDKey = T.Chat.conversationIDToKey(conv.info.id) - if (!conversationIDKey) { - logger.warn("Couldn't make a new conversation?") - } else { - const meta = Meta.inboxUIItemToConversationMeta(uiConv) - if (meta) { - convoMetasReceived([meta]) - } - - const participantInfo: T.Chat.ParticipantInfo = Common.uiParticipantsToParticipantInfo( - uiConv.participants ?? [] - ) - if (participantInfo.all.length > 0) { - storeRegistry - .getConvoState(T.Chat.stringToConversationIDKey(uiConv.convID)) - .dispatch.setParticipants(participantInfo) - } - storeRegistry - .getConvoState(conversationIDKey) - .dispatch.navigateToThread('justCreated', highlightMessageID) - get().dispatch.inboxRefresh('joinedAConversation') - } - } catch (error) { - if (error instanceof RPCError) { - const f = error.fields as Array<{key?: string}> | undefined - const errUsernames = f?.filter(elem => elem.key === 'usernames') as - | undefined - | Array<{key: string; value: string}> - let disallowedUsers: Array = [] - if (errUsernames?.length) { - const {value} = errUsernames[0] ?? {value: ''} - disallowedUsers = value.split(',') - } - const allowedUsers = participants.filter(x => !disallowedUsers.includes(x)) - storeRegistry - .getConvoState(T.Chat.pendingErrorConversationIDKey) - .dispatch.navigateToThread('justCreated', highlightMessageID, undefined, undefined, { - allowedUsers, - code: error.code, - disallowedUsers, - message: error.desc, - }) - } - } - } - ignorePromise(f()) - }, dismissBlockButtons: teamID => { const f = async () => { try { @@ -894,17 +823,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { convoMetasReceived([meta]) } }, - onTeamBuildingFinished: users => { - const f = async () => { - // need to let the mdoal hide first else its thrashy - await timeoutPromise(500) - storeRegistry - .getConvoState(T.Chat.pendingWaitingConversationIDKey) - .dispatch.navigateToThread('justCreated') - get().dispatch.createConversation([...users].map(u => u.id)) - } - ignorePromise(f()) - }, resetState: () => { set(s => ({ ...s, diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 705f2cccff01..50e2a854218e 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -648,6 +648,90 @@ export const ensureWidgetMetas = ( unboxRows(missing, true) } +export const createConversation = ( + participants: ReadonlyArray, + highlightMessageID?: T.Chat.MessageID +) => { + // TODO This will break if you try to make 2 new conversations at the same time because there is + // only one pending conversation state. + // The fix involves being able to make multiple pending conversations. + const f = async () => { + const username = useCurrentUserState.getState().username + if (!username) { + logger.error('Making a convo while logged out?') + return + } + try { + const result = await T.RPCChat.localNewConversationLocalRpcPromise( + { + identifyBehavior: T.RPCGen.TLFIdentifyBehavior.chatGui, + membersType: T.RPCChat.ConversationMembersType.impteamnative, + tlfName: [...new Set([username, ...participants])].join(','), + tlfVisibility: T.RPCGen.TLFVisibility.private, + topicType: T.RPCChat.TopicType.chat, + }, + Strings.waitingKeyChatCreating + ) + const {conv, uiConv} = result + const conversationIDKey = T.Chat.conversationIDToKey(conv.info.id) + if (!conversationIDKey) { + logger.warn("Couldn't make a new conversation?") + return + } + + const meta = Meta.inboxUIItemToConversationMeta(uiConv) + if (meta) { + metasReceived([meta]) + } + + const participantInfo: T.Chat.ParticipantInfo = Common.uiParticipantsToParticipantInfo( + uiConv.participants ?? [] + ) + if (participantInfo.all.length > 0) { + getConvoState(T.Chat.stringToConversationIDKey(uiConv.convID)).dispatch.setParticipants(participantInfo) + } + getConvoState(conversationIDKey).dispatch.navigateToThread('justCreated', highlightMessageID) + getConvoState(conversationIDKey).dispatch.defer.chatInboxRefresh('joinedAConversation') + } catch (error) { + if (error instanceof RPCError) { + const f = error.fields as Array<{key?: string}> | undefined + const errUsernames = f?.filter(elem => elem.key === 'usernames') as + | undefined + | Array<{key: string; value: string}> + let disallowedUsers: Array = [] + if (errUsernames?.length) { + const {value} = errUsernames[0] ?? {value: ''} + disallowedUsers = value.split(',') + } + const allowedUsers = participants.filter(x => !disallowedUsers.includes(x)) + getConvoState(T.Chat.pendingErrorConversationIDKey).dispatch.navigateToThread( + 'justCreated', + highlightMessageID, + undefined, + undefined, + { + allowedUsers, + code: error.code, + disallowedUsers, + message: error.desc, + } + ) + } + } + } + ignorePromise(f()) +} + +export const onTeamBuildingFinished = (users: ReadonlySet) => { + const f = async () => { + // need to let the modal hide first else its thrashy + await timeoutPromise(500) + getConvoState(T.Chat.pendingWaitingConversationIDKey).dispatch.navigateToThread('justCreated') + createConversation([...users].map(u => u.id)) + } + ignorePromise(f()) +} + const formatTextForQuoting = (text: string) => text .split('\n') From 3c6ab4658f9c5ea5fc3cfaf9c507e8dc2c0e0c02 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 16 Apr 2026 11:00:15 -0400 Subject: [PATCH 05/25] WIP --- plans/chat-split.md | 13 +- shared/stores/chat.tsx | 299 +++-------------------------------- shared/stores/convostate.tsx | 289 ++++++++++++++++++++++++++++++++- 3 files changed, 318 insertions(+), 283 deletions(-) diff --git a/plans/chat-split.md b/plans/chat-split.md index 2bcc1120f5ad..a2e78bd2956c 100644 --- a/plans/chat-split.md +++ b/plans/chat-split.md @@ -29,6 +29,7 @@ These cleanup steps are already done: - first-layout inbox hydration moved out of `chat.updateInboxLayout` - `ensureWidgetMetas`, meta queueing, and `unboxRows` now live in `convostate` - conversation creation and team-building handoff now live in `convostate` +- convo-targeted engine notifications now route through `convostate.handleConvoEngineIncoming`; `chat.onEngineIncomingImpl` keeps only global branches This means the remaining work is about removing the actual `chat -> convo` logic, not import barrels. @@ -42,6 +43,16 @@ This means the remaining work is about removing the actual `chat -> convo` logic ### 1. Engine Notification Fanout +Status: + +- done for `onEngineIncomingImpl`; convo-targeted engine actions now dispatch from `convostate` + +Remaining direct `chat -> convo` calls outside that engine switch: + +- selected-convo navigation / reload helpers +- inbox conversation hydration that still sets convo participants from `chat` +- gregor exploding-mode fanout + Most of `onEngineIncomingImpl` is a dispatcher into specific convo stores. Keep in `chat` only: @@ -88,7 +99,7 @@ Do not decide this early. Resolve simpler buckets first. ## Recommended Order -1. Engine notification fanout +1. Residual non-engine `chat -> convo` fanout 2. Badge / unread ownership ## Acceptance Criteria diff --git a/shared/stores/chat.tsx b/shared/stores/chat.tsx index 804d6b463805..11b93170a56c 100644 --- a/shared/stores/chat.tsx +++ b/shared/stores/chat.tsx @@ -14,6 +14,7 @@ import {bodyToJSON} from '@/constants/rpc-utils' import {chatStores} from '@/stores/convo-registry' import { ensureWidgetMetas as ensureConvoWidgetMetas, + handleConvoEngineIncoming, hydrateInboxLayout, metasReceived as convoMetasReceived, unboxRows as convoUnboxRows, @@ -187,7 +188,6 @@ export type State = Store & { setInboxRetriedOnCurrentEmpty: (retried: boolean) => void loadStaticConfig: () => void maybeChangeSelectedConv: () => void - onChatThreadStale: (action: EngineGen.EngineAction<'chat.1.NotifyChat.ChatThreadsStale'>) => void onEngineIncomingImpl: (action: EngineGen.Actions) => void onChatInboxSynced: (action: EngineGen.EngineAction<'chat.1.NotifyChat.ChatInboxSynced'>) => void onGetInboxConvsUnboxed: (action: EngineGen.EngineAction<'chat.1.chatUi.chatInboxConversation'>) => void @@ -402,88 +402,18 @@ export const useChatState = Z.createZustand('chat', (set, get) => { inboxRefresh('inboxSyncedUnknown') } }, - onChatThreadStale: action => { - const {updates} = action.payload.params - const keys = ['clear', 'newactivity'] as const - if (__DEV__) { - if (keys.length * 2 !== Object.keys(T.RPCChat.StaleUpdateType).length) { - throw new Error('onChatThreadStale invalid enum') + onEngineIncomingImpl: action => { + const convoResult = handleConvoEngineIncoming(action, get().staticConfig) + if (convoResult.handled) { + if (convoResult.inboxUIItem) { + get().dispatch.onIncomingInboxUIItem(convoResult.inboxUIItem) } - } - const selectedConversation = Common.getSelectedConversation() - const shouldLoadMore = (updates || []).some( - u => T.Chat.conversationIDToKey(u.convID) === selectedConversation - ) - keys.forEach(key => { - const conversationIDKeys = (updates || []).reduce>((arr, u) => { - const cid = T.Chat.conversationIDToKey(u.convID) - if (u.updateType === T.RPCChat.StaleUpdateType[key]) { - arr.push(cid) - } - return arr - }, []) - // load the inbox instead - if (conversationIDKeys.length > 0) { - logger.info( - `onChatThreadStale: dispatching thread reload actions for ${conversationIDKeys.length} convs of type ${key}` - ) - convoUnboxRows(conversationIDKeys, true) - if (T.RPCChat.StaleUpdateType[key] === T.RPCChat.StaleUpdateType.clear) { - conversationIDKeys.forEach(convID => { - // For the selected conversation, skip immediate clear — the deferred - // atomic clear+add in loadMoreMessages avoids a blank flash - if (convID !== selectedConversation) { - storeRegistry.getConvoState(convID).dispatch.messagesClear() - } - }) - } + if (convoResult.userReacjis) { + get().dispatch.updateUserReacjis(convoResult.userReacjis) } - }) - if (shouldLoadMore) { - storeRegistry.getConvoState(selectedConversation).dispatch.loadMoreMessages({ - reason: 'got stale', - }) + return } - }, - onEngineIncomingImpl: action => { switch (action.type) { - case 'chat.1.chatUi.chatInboxFailed': // fallthrough - case 'chat.1.NotifyChat.ChatSetConvSettings': // fallthrough - case 'chat.1.NotifyChat.ChatAttachmentUploadStart': // fallthrough - case 'chat.1.NotifyChat.ChatPromptUnfurl': // fallthrough - case 'chat.1.NotifyChat.ChatPaymentInfo': // fallthrough - case 'chat.1.NotifyChat.ChatRequestInfo': // fallthrough - case 'chat.1.NotifyChat.ChatAttachmentDownloadProgress': //fallthrough - case 'chat.1.NotifyChat.ChatAttachmentDownloadComplete': //fallthrough - case 'chat.1.NotifyChat.ChatAttachmentUploadProgress': { - const {convID} = action.payload.params - const conversationIDKey = T.Chat.conversationIDToKey(convID) - storeRegistry.getConvoState(conversationIDKey).dispatch.onEngineIncoming(action) - break - } - case 'chat.1.chatUi.chatCommandMarkdown': //fallthrough - case 'chat.1.chatUi.chatGiphyToggleResultWindow': // fallthrough - case 'chat.1.chatUi.chatCommandStatus': // fallthrough - case 'chat.1.chatUi.chatBotCommandsUpdateStatus': //fallthrough - case 'chat.1.chatUi.chatGiphySearchResults': { - const {convID} = action.payload.params - const conversationIDKey = T.Chat.stringToConversationIDKey(convID) - storeRegistry.getConvoState(conversationIDKey).dispatch.onEngineIncoming(action) - break - } - case 'chat.1.NotifyChat.ChatParticipantsInfo': { - const {participants: participantMap} = action.payload.params - Object.keys(participantMap ?? {}).forEach(convIDStr => { - const participants = participantMap?.[convIDStr] - const conversationIDKey = T.Chat.stringToConversationIDKey(convIDStr) - if (participants) { - storeRegistry - .getConvoState(conversationIDKey) - .dispatch.setParticipants(Common.uiParticipantsToParticipantInfo(participants)) - } - }) - break - } case 'chat.1.chatUi.chatMaybeMentionUpdate': { const {teamName, channel, info} = action.payload.params get().dispatch.setMaybeMentionInfo(getTeamMentionName(teamName, channel), info) @@ -493,39 +423,12 @@ export const useChatState = Z.createZustand('chat', (set, get) => { const {conv} = action.payload.params if (conv) { const meta = Meta.inboxUIItemToConversationMeta(conv) - meta && convoMetasReceived([meta]) - } - break - } - case 'chat.1.chatUi.chatCoinFlipStatus': { - const {statuses} = action.payload.params - const statusesByConvo = new Map>() - statuses?.forEach(status => { - const conversationIDKey = T.Chat.stringToConversationIDKey(status.convID) - const convoStatuses = statusesByConvo.get(conversationIDKey) - if (convoStatuses) { - convoStatuses.push(status) - } else { - statusesByConvo.set(conversationIDKey, [status]) + if (meta) { + convoMetasReceived([meta]) } - }) - statusesByConvo.forEach((convoStatuses, conversationIDKey) => { - storeRegistry.getConvoState(conversationIDKey).dispatch.updateCoinFlipStatuses(convoStatuses) - }) - break - } - case 'chat.1.NotifyChat.ChatThreadsStale': - get().dispatch.onChatThreadStale(action) - break - case 'chat.1.NotifyChat.ChatSubteamRename': { - const {convs} = action.payload.params - const conversationIDKeys = (convs ?? []).map(c => T.Chat.stringToConversationIDKey(c.convID)) - convoUnboxRows(conversationIDKeys, true) + } break } - case 'chat.1.NotifyChat.ChatTLFFinalize': - convoUnboxRows([T.Chat.conversationIDToKey(action.payload.params.convID)]) - break case 'chat.1.NotifyChat.ChatIdentifyUpdate': { // Some participants are broken/fixed now const {update} = action.payload.params @@ -556,176 +459,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { case 'chat.1.chatUi.chatInboxConversation': get().dispatch.onGetInboxConvsUnboxed(action) break - case 'chat.1.NotifyChat.NewChatActivity': { - const {activity} = action.payload.params - switch (activity.activityType) { - case T.RPCChat.ChatActivityType.incomingMessage: { - const {incomingMessage} = activity - const conversationIDKey = T.Chat.conversationIDToKey(incomingMessage.convID) - storeRegistry.getConvoState(conversationIDKey).dispatch.onIncomingMessage(incomingMessage) - get().dispatch.onIncomingInboxUIItem(incomingMessage.conv ?? undefined) - break - } - case T.RPCChat.ChatActivityType.setStatus: - get().dispatch.onIncomingInboxUIItem(activity.setStatus.conv ?? undefined) - break - case T.RPCChat.ChatActivityType.readMessage: - get().dispatch.onIncomingInboxUIItem(activity.readMessage.conv ?? undefined) - break - case T.RPCChat.ChatActivityType.newConversation: - get().dispatch.onIncomingInboxUIItem(activity.newConversation.conv ?? undefined) - break - case T.RPCChat.ChatActivityType.failedMessage: { - const {failedMessage} = activity - get().dispatch.onIncomingInboxUIItem(failedMessage.conv ?? undefined) - const {outboxRecords} = failedMessage - if (!outboxRecords) return - for (const outboxRecord of outboxRecords) { - const s = outboxRecord.state - if (s.state !== T.RPCChat.OutboxStateType.error) return - const {error} = s - const conversationIDKey = T.Chat.conversationIDToKey(outboxRecord.convID) - const outboxID = T.Chat.rpcOutboxIDToOutboxID(outboxRecord.outboxID) - // This is temp until fixed by CORE-7112. We get this error but not the call to let us show the red banner - const reason = Message.rpcErrorToString(error) - storeRegistry - .getConvoState(conversationIDKey) - .dispatch.onMessageErrored(outboxID, reason, error.typ) - - if (error.typ === T.RPCChat.OutboxErrorType.identify) { - // Find out the user who failed identify - const match = error.message.match(/"(.*)"/) - const tempForceRedBox = match?.[1] - if (tempForceRedBox) { - storeRegistry - .getState('users') - .dispatch.updates([{info: {broken: true}, name: tempForceRedBox}]) - } - } - } - break - } - case T.RPCChat.ChatActivityType.membersUpdate: - convoUnboxRows([T.Chat.conversationIDToKey(activity.membersUpdate.convID)], true) - break - case T.RPCChat.ChatActivityType.setAppNotificationSettings: { - const {setAppNotificationSettings} = activity - const conversationIDKey = T.Chat.conversationIDToKey(setAppNotificationSettings.convID) - const settings = setAppNotificationSettings.settings - const cs = storeRegistry.getConvoState(conversationIDKey) - if (cs.isMetaGood()) { - cs.dispatch.updateMeta(Meta.parseNotificationSettings(settings)) - } - break - } - case T.RPCChat.ChatActivityType.expunge: { - // Get actions to update messagemap / metamap when retention policy expunge happens - const {expunge} = activity - const conversationIDKey = T.Chat.conversationIDToKey(expunge.convID) - const staticConfig = get().staticConfig - // The types here are askew. It confuses frontend MessageType with protocol MessageType. - // Placeholder is an example where it doesn't make sense. - const deletableMessageTypes = staticConfig?.deletableByDeleteHistory || Common.allMessageTypes - storeRegistry.getConvoState(conversationIDKey).dispatch.messagesWereDeleted({ - deletableMessageTypes, - upToMessageID: T.Chat.numberToMessageID(expunge.expunge.upto), - }) - break - } - case T.RPCChat.ChatActivityType.ephemeralPurge: { - const {ephemeralPurge} = activity - // Get actions to update messagemap / metamap when ephemeral messages expire - const conversationIDKey = T.Chat.conversationIDToKey(ephemeralPurge.convID) - const messageIDs = ephemeralPurge.msgs?.reduce>((arr, msg) => { - const msgID = Message.getMessageID(msg) - if (msgID) { - arr.push(msgID) - } - return arr - }, []) - - !!messageIDs && - storeRegistry.getConvoState(conversationIDKey).dispatch.messagesExploded(messageIDs) - break - } - case T.RPCChat.ChatActivityType.reactionUpdate: { - // Get actions to update the messagemap when reactions are updated - const {reactionUpdate} = activity - const conversationIDKey = T.Chat.conversationIDToKey(reactionUpdate.convID) - if (!reactionUpdate.reactionUpdates || reactionUpdate.reactionUpdates.length === 0) { - logger.warn(`Got ReactionUpdateNotif with no reactionUpdates for convID=${conversationIDKey}`) - break - } - const updates = reactionUpdate.reactionUpdates.map(ru => ({ - reactions: Message.reactionMapToReactions(ru.reactions), - targetMsgID: T.Chat.numberToMessageID(ru.targetMsgID), - })) - logger.info(`Got ${updates.length} reaction updates for convID=${conversationIDKey}`) - storeRegistry.getConvoState(conversationIDKey).dispatch.updateReactions(updates) - get().dispatch.updateUserReacjis(reactionUpdate.userReacjis) - break - } - case T.RPCChat.ChatActivityType.messagesUpdated: { - const {messagesUpdated} = activity - const conversationIDKey = T.Chat.conversationIDToKey(messagesUpdated.convID) - storeRegistry.getConvoState(conversationIDKey).dispatch.onMessagesUpdated(messagesUpdated) - break - } - default: - } - break - } - case 'chat.1.NotifyChat.ChatTypingUpdate': { - const {typingUpdates} = action.payload.params - typingUpdates?.forEach(u => { - storeRegistry - .getConvoState(T.Chat.conversationIDToKey(u.convID)) - .dispatch.setTyping(new Set(u.typers?.map(t => t.username))) - }) - break - } - case 'chat.1.NotifyChat.ChatSetConvRetention': { - const {conv, convID} = action.payload.params - if (!conv) { - logger.warn('onChatSetConvRetention: no conv given') - return - } - const meta = Meta.inboxUIItemToConversationMeta(conv) - if (!meta) { - logger.warn(`onChatSetConvRetention: no meta found for ${convID.toString()}`) - return - } - const cs = storeRegistry.getConvoState(meta.conversationIDKey) - // only insert if the convo is already in the inbox - if (cs.isMetaGood()) { - cs.dispatch.setMeta(meta) - } - break - } - case 'chat.1.NotifyChat.ChatSetTeamRetention': { - const {convs} = action.payload.params - const metas = (convs ?? []).reduce>((l, c) => { - const meta = Meta.inboxUIItemToConversationMeta(c) - if (meta) { - l.push(meta) - } - return l - }, []) - if (metas.length) { - metas.forEach(meta => { - const cs = storeRegistry.getConvoState(meta.conversationIDKey) - // only insert if the convo is already in the inbox - if (cs.isMetaGood()) { - cs.dispatch.setMeta(meta) - } - }) - } else { - logger.error( - 'got NotifyChat.ChatSetTeamRetention with no attached InboxUIItems. The local version may be out of date' - ) - } - break - } case 'keybase.1.NotifyBadges.badgeState': { const {badgeState} = action.payload.params get().dispatch.badgesUpdated(badgeState) @@ -736,7 +469,9 @@ export const useChatState = Z.createZustand('chat', (set, get) => { const items = state.items || [] const goodState = items.reduce>( (arr, {md, item}) => { - md && item && arr.push({item, md}) + if (md && item) { + arr.push({item, md}) + } return arr }, [] @@ -796,7 +531,9 @@ export const useChatState = Z.createZustand('chat', (set, get) => { // We get a subset of meta information from the cache even in the untrusted payload const metas = items.reduce>((arr, item) => { const m = Meta.unverifiedInboxUIItemToConversationMeta(item) - m && arr.push(m) + if (m) { + arr.push(m) + } return arr }, []) // Check if some of our existing stored metas might no longer be valid diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 50e2a854218e..786052d89def 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -499,7 +499,9 @@ export const onRouteChanged = (prev: T.Immutable, next: T.Immu const n = getVisibleScreen(next) const nParams = n?.params as undefined | {conversationIDKey?: T.Chat.ConversationIDKey} const isID = nParams?.conversationIDKey - isID && getConvoState(isID).dispatch.tabSelected() + if (isID) { + getConvoState(isID).dispatch.tabSelected() + } } } @@ -648,6 +650,291 @@ export const ensureWidgetMetas = ( unboxRows(missing, true) } +type ConvoEngineIncomingResult = { + handled: boolean + inboxUIItem?: T.RPCChat.InboxUIItem + userReacjis?: T.RPCGen.UserReacjis +} + +type NewChatActivity = EngineGen.EngineAction<'chat.1.NotifyChat.NewChatActivity'>['payload']['params']['activity'] +type ThreadStaleUpdates = + EngineGen.EngineAction<'chat.1.NotifyChat.ChatThreadsStale'>['payload']['params']['updates'] + +const handledConvoEngineIncoming = (result: Omit = {}) => ({ + ...result, + handled: true, +}) + +const onChatThreadsStale = (updates: ThreadStaleUpdates) => { + const keys = ['clear', 'newactivity'] as const + if (__DEV__) { + if (keys.length * 2 !== Object.keys(T.RPCChat.StaleUpdateType).length) { + throw new Error('onChatThreadsStale invalid enum') + } + } + const selectedConversation = Common.getSelectedConversation() + const shouldLoadMore = (updates ?? []).some(u => T.Chat.conversationIDToKey(u.convID) === selectedConversation) + keys.forEach(key => { + const conversationIDKeys = (updates ?? []).reduce>((arr, u) => { + const conversationIDKey = T.Chat.conversationIDToKey(u.convID) + if (u.updateType === T.RPCChat.StaleUpdateType[key]) { + arr.push(conversationIDKey) + } + return arr + }, []) + if (conversationIDKeys.length === 0) { + return + } + logger.info( + `onChatThreadsStale: dispatching thread reload actions for ${conversationIDKeys.length} convs of type ${key}` + ) + unboxRows(conversationIDKeys, true) + if (T.RPCChat.StaleUpdateType[key] === T.RPCChat.StaleUpdateType.clear) { + conversationIDKeys.forEach(conversationIDKey => { + // For the selected conversation, skip immediate clear. The deferred atomic + // clear+add in loadMoreMessages avoids a blank flash there. + if (conversationIDKey !== selectedConversation) { + getConvoState(conversationIDKey).dispatch.messagesClear() + } + }) + } + }) + if (shouldLoadMore) { + getConvoState(selectedConversation).dispatch.loadMoreMessages({reason: 'got stale'}) + } +} + +const onNewChatActivity = ( + activity: NewChatActivity, + staticConfig?: T.Chat.StaticConfig +): ConvoEngineIncomingResult => { + switch (activity.activityType) { + case T.RPCChat.ChatActivityType.incomingMessage: { + const {incomingMessage} = activity + const conversationIDKey = T.Chat.conversationIDToKey(incomingMessage.convID) + getConvoState(conversationIDKey).dispatch.onIncomingMessage(incomingMessage) + return handledConvoEngineIncoming({inboxUIItem: incomingMessage.conv ?? undefined}) + } + case T.RPCChat.ChatActivityType.setStatus: + return handledConvoEngineIncoming({inboxUIItem: activity.setStatus.conv ?? undefined}) + case T.RPCChat.ChatActivityType.readMessage: + return handledConvoEngineIncoming({inboxUIItem: activity.readMessage.conv ?? undefined}) + case T.RPCChat.ChatActivityType.newConversation: + return handledConvoEngineIncoming({inboxUIItem: activity.newConversation.conv ?? undefined}) + case T.RPCChat.ChatActivityType.failedMessage: { + const {failedMessage} = activity + const inboxUIItem = failedMessage.conv ?? undefined + const {outboxRecords} = failedMessage + if (!outboxRecords) { + return handledConvoEngineIncoming({inboxUIItem}) + } + for (const outboxRecord of outboxRecords) { + const s = outboxRecord.state + if (s.state !== T.RPCChat.OutboxStateType.error) { + return handledConvoEngineIncoming({inboxUIItem}) + } + const {error} = s + const conversationIDKey = T.Chat.conversationIDToKey(outboxRecord.convID) + const outboxID = T.Chat.rpcOutboxIDToOutboxID(outboxRecord.outboxID) + // This is temp until fixed by CORE-7112. We get this error but not the call + // to let us show the red banner. + const reason = Message.rpcErrorToString(error) + getConvoState(conversationIDKey).dispatch.onMessageErrored(outboxID, reason, error.typ) + + if (error.typ === T.RPCChat.OutboxErrorType.identify) { + const match = error.message.match(/"(.*)"/) + const tempForceRedBox = match?.[1] + if (tempForceRedBox) { + useUsersState.getState().dispatch.updates([{info: {broken: true}, name: tempForceRedBox}]) + } + } + } + return handledConvoEngineIncoming({inboxUIItem}) + } + case T.RPCChat.ChatActivityType.membersUpdate: + unboxRows([T.Chat.conversationIDToKey(activity.membersUpdate.convID)], true) + return handledConvoEngineIncoming() + case T.RPCChat.ChatActivityType.setAppNotificationSettings: { + const {setAppNotificationSettings} = activity + const conversationIDKey = T.Chat.conversationIDToKey(setAppNotificationSettings.convID) + const settings = setAppNotificationSettings.settings + const cs = getConvoState(conversationIDKey) + if (cs.isMetaGood()) { + cs.dispatch.updateMeta(Meta.parseNotificationSettings(settings)) + } + return handledConvoEngineIncoming() + } + case T.RPCChat.ChatActivityType.expunge: { + const {expunge} = activity + const conversationIDKey = T.Chat.conversationIDToKey(expunge.convID) + const deletableMessageTypes = staticConfig?.deletableByDeleteHistory || Common.allMessageTypes + getConvoState(conversationIDKey).dispatch.messagesWereDeleted({ + deletableMessageTypes, + upToMessageID: T.Chat.numberToMessageID(expunge.expunge.upto), + }) + return handledConvoEngineIncoming() + } + case T.RPCChat.ChatActivityType.ephemeralPurge: { + const {ephemeralPurge} = activity + const conversationIDKey = T.Chat.conversationIDToKey(ephemeralPurge.convID) + const messageIDs = ephemeralPurge.msgs?.reduce>((arr, msg) => { + const msgID = Message.getMessageID(msg) + if (msgID) { + arr.push(msgID) + } + return arr + }, []) + if (messageIDs) { + getConvoState(conversationIDKey).dispatch.messagesExploded(messageIDs) + } + return handledConvoEngineIncoming() + } + case T.RPCChat.ChatActivityType.reactionUpdate: { + const {reactionUpdate} = activity + const conversationIDKey = T.Chat.conversationIDToKey(reactionUpdate.convID) + if (!reactionUpdate.reactionUpdates || reactionUpdate.reactionUpdates.length === 0) { + logger.warn(`Got ReactionUpdateNotif with no reactionUpdates for convID=${conversationIDKey}`) + return handledConvoEngineIncoming() + } + const updates = reactionUpdate.reactionUpdates.map(ru => ({ + reactions: Message.reactionMapToReactions(ru.reactions), + targetMsgID: T.Chat.numberToMessageID(ru.targetMsgID), + })) + logger.info(`Got ${updates.length} reaction updates for convID=${conversationIDKey}`) + getConvoState(conversationIDKey).dispatch.updateReactions(updates) + return handledConvoEngineIncoming({userReacjis: reactionUpdate.userReacjis}) + } + case T.RPCChat.ChatActivityType.messagesUpdated: { + const {messagesUpdated} = activity + const conversationIDKey = T.Chat.conversationIDToKey(messagesUpdated.convID) + getConvoState(conversationIDKey).dispatch.onMessagesUpdated(messagesUpdated) + return handledConvoEngineIncoming() + } + default: + return {handled: false} + } +} + +export const handleConvoEngineIncoming = ( + action: EngineGen.Actions, + staticConfig?: T.Chat.StaticConfig +): ConvoEngineIncomingResult => { + switch (action.type) { + 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': { + const conversationIDKey = T.Chat.conversationIDToKey(action.payload.params.convID) + getConvoState(conversationIDKey).dispatch.onEngineIncoming(action) + return handledConvoEngineIncoming() + } + case 'chat.1.chatUi.chatCommandMarkdown': + case 'chat.1.chatUi.chatGiphyToggleResultWindow': + case 'chat.1.chatUi.chatCommandStatus': + case 'chat.1.chatUi.chatBotCommandsUpdateStatus': + case 'chat.1.chatUi.chatGiphySearchResults': { + const conversationIDKey = T.Chat.stringToConversationIDKey(action.payload.params.convID) + getConvoState(conversationIDKey).dispatch.onEngineIncoming(action) + return handledConvoEngineIncoming() + } + case 'chat.1.NotifyChat.ChatParticipantsInfo': { + const {participants: participantMap} = action.payload.params + Object.keys(participantMap ?? {}).forEach(convIDStr => { + const participants = participantMap?.[convIDStr] + if (participants) { + getConvoState(T.Chat.stringToConversationIDKey(convIDStr)).dispatch.setParticipants( + Common.uiParticipantsToParticipantInfo(participants) + ) + } + }) + return handledConvoEngineIncoming() + } + case 'chat.1.chatUi.chatCoinFlipStatus': { + const {statuses} = action.payload.params + const statusesByConvo = new Map>() + statuses?.forEach(status => { + const conversationIDKey = T.Chat.stringToConversationIDKey(status.convID) + const convoStatuses = statusesByConvo.get(conversationIDKey) + if (convoStatuses) { + convoStatuses.push(status) + } else { + statusesByConvo.set(conversationIDKey, [status]) + } + }) + statusesByConvo.forEach((convoStatuses, conversationIDKey) => { + getConvoState(conversationIDKey).dispatch.updateCoinFlipStatuses(convoStatuses) + }) + return handledConvoEngineIncoming() + } + case 'chat.1.NotifyChat.ChatThreadsStale': + onChatThreadsStale(action.payload.params.updates) + return handledConvoEngineIncoming() + case 'chat.1.NotifyChat.ChatSubteamRename': + unboxRows((action.payload.params.convs ?? []).map(c => T.Chat.stringToConversationIDKey(c.convID)), true) + return handledConvoEngineIncoming() + case 'chat.1.NotifyChat.ChatTLFFinalize': + unboxRows([T.Chat.conversationIDToKey(action.payload.params.convID)]) + return handledConvoEngineIncoming() + case 'chat.1.NotifyChat.NewChatActivity': + return onNewChatActivity(action.payload.params.activity, staticConfig) + case 'chat.1.NotifyChat.ChatTypingUpdate': { + const {typingUpdates} = action.payload.params + typingUpdates?.forEach(update => { + getConvoState(T.Chat.conversationIDToKey(update.convID)).dispatch.setTyping( + new Set(update.typers?.map(typer => typer.username)) + ) + }) + return handledConvoEngineIncoming() + } + case 'chat.1.NotifyChat.ChatSetConvRetention': { + const {conv, convID} = action.payload.params + if (!conv) { + logger.warn('onChatSetConvRetention: no conv given') + return handledConvoEngineIncoming() + } + const meta = Meta.inboxUIItemToConversationMeta(conv) + if (!meta) { + logger.warn(`onChatSetConvRetention: no meta found for ${convID.toString()}`) + return handledConvoEngineIncoming() + } + const cs = getConvoState(meta.conversationIDKey) + if (cs.isMetaGood()) { + cs.dispatch.setMeta(meta) + } + return handledConvoEngineIncoming() + } + case 'chat.1.NotifyChat.ChatSetTeamRetention': { + const metas = (action.payload.params.convs ?? []).reduce>((l, c) => { + const meta = Meta.inboxUIItemToConversationMeta(c) + if (meta) { + l.push(meta) + } + return l + }, []) + if (metas.length === 0) { + logger.error( + 'got NotifyChat.ChatSetTeamRetention with no attached InboxUIItems. The local version may be out of date' + ) + return handledConvoEngineIncoming() + } + metas.forEach(meta => { + const cs = getConvoState(meta.conversationIDKey) + if (cs.isMetaGood()) { + cs.dispatch.setMeta(meta) + } + }) + return handledConvoEngineIncoming() + } + default: + return {handled: false} + } +} + export const createConversation = ( participants: ReadonlyArray, highlightMessageID?: T.Chat.MessageID From cdc5f9e1c5342bab503c1c7cc1f1147c6f958003 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 16 Apr 2026 11:06:06 -0400 Subject: [PATCH 06/25] WIP --- plans/chat-split.md | 11 +--- shared/stores/chat.tsx | 104 +++-------------------------------- shared/stores/convostate.tsx | 101 +++++++++++++++++++++++++++++++++- 3 files changed, 111 insertions(+), 105 deletions(-) diff --git a/plans/chat-split.md b/plans/chat-split.md index a2e78bd2956c..aa2c0f8c4c5d 100644 --- a/plans/chat-split.md +++ b/plans/chat-split.md @@ -30,6 +30,7 @@ These cleanup steps are already done: - `ensureWidgetMetas`, meta queueing, and `unboxRows` now live in `convostate` - conversation creation and team-building handoff now live in `convostate` - convo-targeted engine notifications now route through `convostate.handleConvoEngineIncoming`; `chat.onEngineIncomingImpl` keeps only global branches +- service-driven convo reselect, stale selected-thread reload, inbox conversation hydration, and exploding-mode gregor sync now live in `convostate` This means the remaining work is about removing the actual `chat -> convo` logic, not import barrels. @@ -46,12 +47,7 @@ This means the remaining work is about removing the actual `chat -> convo` logic Status: - done for `onEngineIncomingImpl`; convo-targeted engine actions now dispatch from `convostate` - -Remaining direct `chat -> convo` calls outside that engine switch: - -- selected-convo navigation / reload helpers -- inbox conversation hydration that still sets convo participants from `chat` -- gregor exploding-mode fanout +- done for the remaining non-badge residual fanout; `chat.tsx` no longer directly drives convo navigation, hydration, stale reloads, or exploding-mode sync Most of `onEngineIncomingImpl` is a dispatcher into specific convo stores. @@ -99,8 +95,7 @@ Do not decide this early. Resolve simpler buckets first. ## Recommended Order -1. Residual non-engine `chat -> convo` fanout -2. Badge / unread ownership +1. Badge / unread ownership ## Acceptance Criteria diff --git a/shared/stores/chat.tsx b/shared/stores/chat.tsx index 11b93170a56c..0b3a132dda72 100644 --- a/shared/stores/chat.tsx +++ b/shared/stores/chat.tsx @@ -15,15 +15,17 @@ import {chatStores} from '@/stores/convo-registry' import { ensureWidgetMetas as ensureConvoWidgetMetas, handleConvoEngineIncoming, + hydrateInboxConversations, hydrateInboxLayout, + loadSelectedConversationIfStale, metasReceived as convoMetasReceived, + maybeChangeSelectedConversation, + syncGregorExplodingModes, unboxRows as convoUnboxRows, } from '@/stores/convostate' import {ignorePromise} from '@/constants/utils' import {isPhone} from '@/constants/platform' -import {navigateToInbox} from '@/constants/router' import {storeRegistry} from '@/stores/store-registry' -import {uint8ArrayToString} from '@/util/uint8array' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useDaemonState} from '@/stores/daemon' @@ -187,7 +189,6 @@ export type State = Store & { inboxRefresh: (reason: RefreshReason) => void setInboxRetriedOnCurrentEmpty: (retried: boolean) => void loadStaticConfig: () => void - maybeChangeSelectedConv: () => void onEngineIncomingImpl: (action: EngineGen.Actions) => void onChatInboxSynced: (action: EngineGen.EngineAction<'chat.1.NotifyChat.ChatInboxSynced'>) => void onGetInboxConvsUnboxed: (action: EngineGen.EngineAction<'chat.1.chatUi.chatInboxConversation'>) => void @@ -307,51 +308,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { } ignorePromise(f()) }, - maybeChangeSelectedConv: () => { - const {inboxLayout} = get() - const newConvID = inboxLayout?.reselectInfo?.newConvID - const oldConvID = inboxLayout?.reselectInfo?.oldConvID - - const selectedConversation = Common.getSelectedConversation() - - if (!newConvID && !oldConvID) { - return - } - - const existingValid = T.Chat.isValidConversationIDKey(selectedConversation) - // no new id, just take the opportunity to resolve - if (!newConvID) { - if (!existingValid && isPhone) { - logger.info(`maybeChangeSelectedConv: no new and no valid, so go to inbox`) - navigateToInbox(false) - } - return - } - // not matching? - if (selectedConversation !== oldConvID) { - if (!existingValid && isPhone) { - logger.info(`maybeChangeSelectedConv: no new and no valid, so go to inbox`) - navigateToInbox(false) - } - return - } - // matching - if (isPhone) { - // on mobile just head back to the inbox if we have something selected - if (T.Chat.isValidConversationIDKey(selectedConversation)) { - logger.info(`maybeChangeSelectedConv: mobile: navigating up on conv change`) - navigateToInbox(false) - return - } - logger.info(`maybeChangeSelectedConv: mobile: ignoring conv change, no conv selected`) - return - } else { - logger.info( - `maybeChangeSelectedConv: selecting new conv: new:${newConvID} old:${oldConvID} prevselected ${selectedConversation}` - ) - storeRegistry.getConvoState(newConvID).dispatch.navigateToThread('findNewestConversation') - } - }, onChatInboxSynced: action => { const {syncRes} = action.payload.params const {clear} = useWaitingState.getState().dispatch @@ -377,15 +333,12 @@ export const useChatState = Z.createZustand('chat', (set, get) => { // We got some new messages appended case T.RPCChat.SyncInboxResType.incremental: { const items = syncRes.incremental.items || [] - const selectedConversation = Common.getSelectedConversation() const metas = items.reduce>((arr, i) => { const meta = Meta.unverifiedInboxUIItemToConversationMeta(i.conv) if (meta) arr.push(meta) return arr }, []) - if (metas.some(m => m.conversationIDKey === selectedConversation)) { - storeRegistry.getConvoState(selectedConversation).dispatch.loadMoreMessages({reason: 'got stale'}) - } + loadSelectedConversationIfStale(metas) const removals = syncRes.incremental.removals?.map(T.Chat.stringToConversationIDKey) // Update new untrusted if (metas.length || removals?.length) { @@ -450,7 +403,7 @@ export const useChatState = Z.createZustand('chat', (set, get) => { break case 'chat.1.chatUi.chatInboxLayout': get().dispatch.updateInboxLayout(action.payload.params.layout) - get().dispatch.maybeChangeSelectedConv() + maybeChangeSelectedConversation(get().inboxLayout) ensureConvoWidgetMetas(get().inboxLayout?.widgetList) break case 'chat.1.NotifyChat.ChatInboxStale': @@ -490,21 +443,8 @@ export const useChatState = Z.createZustand('chat', (set, get) => { const {infoMap} = useUsersState.getState() const {convs} = action.payload.params const inboxUIItems = JSON.parse(convs) as Array - const metas: Array = [] const usernameToFullname: {[username: string]: string} = {} inboxUIItems.forEach(inboxUIItem => { - const meta = Meta.inboxUIItemToConversationMeta(inboxUIItem) - if (meta) { - metas.push(meta) - } - const participantInfo: T.Chat.ParticipantInfo = Common.uiParticipantsToParticipantInfo( - inboxUIItem.participants ?? [] - ) - if (participantInfo.all.length > 0) { - storeRegistry - .getConvoState(T.Chat.stringToConversationIDKey(inboxUIItem.convID)) - .dispatch.setParticipants(participantInfo) - } inboxUIItem.participants?.forEach((part: T.RPCChat.UIParticipant) => { const {assertion, fullName} = part if (!infoMap.get(assertion) && fullName) { @@ -520,9 +460,7 @@ export const useChatState = Z.createZustand('chat', (set, get) => { })) ) } - if (metas.length > 0) { - convoMetasReceived(metas) - } + hydrateInboxConversations(inboxUIItems) }, onGetInboxUnverifiedConvs: action => { const {inbox} = action.payload.params @@ -620,33 +558,7 @@ export const useChatState = Z.createZustand('chat', (set, get) => { }) }, updatedGregor: items => { - const explodingItems = items.filter(i => - i.item.category.startsWith(Common.explodingModeGregorKeyPrefix) - ) - if (!explodingItems.length) { - // No conversations have exploding modes, clear out what is set - for (const s of chatStores.values()) { - s.getState().dispatch.setExplodingMode(0, true) - } - } else { - // logger.info('Got push state with some exploding modes') - explodingItems.forEach(i => { - try { - const {category, body} = i.item - const secondsString = uint8ArrayToString(body) - const seconds = parseInt(secondsString, 10) - if (isNaN(seconds)) { - logger.warn(`Got dirty exploding mode ${secondsString} for category ${category}`) - return - } - const _conversationIDKey = category.substring(Common.explodingModeGregorKeyPrefix.length) - const conversationIDKey = T.Chat.stringToConversationIDKey(_conversationIDKey) - storeRegistry.getConvoState(conversationIDKey).dispatch.setExplodingMode(seconds, true) - } catch (e) { - logger.info('Error parsing exploding' + e) - } - }) - } + syncGregorExplodingModes(items) set(s => { const blockButtons = items.some(i => i.item.category.startsWith(blockButtonsGregorPrefix)) diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 786052d89def..2dd51d6857d6 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -42,7 +42,7 @@ import {type StoreApi, type UseBoundStore, useStore} from 'zustand' import * as Platform from '@/constants/platform' import KB2 from '@/util/electron' import {NotifyPopup} from '@/util/misc' -import {hexToUint8Array} from '@/util/uint8array' +import {hexToUint8Array, uint8ArrayToString} from '@/util/uint8array' import {clearChatTimeCache} from '@/util/timestamp' import {registerExternalResetter} from '@/util/zustand' import * as Config from '@/constants/config' @@ -522,6 +522,56 @@ export const metasReceived = ( }) } +export const maybeChangeSelectedConversation = (inboxLayout?: T.RPCChat.UIInboxLayout) => { + const newConvID = inboxLayout?.reselectInfo?.newConvID + const oldConvID = inboxLayout?.reselectInfo?.oldConvID + + const selectedConversation = Common.getSelectedConversation() + + if (!newConvID && !oldConvID) { + return + } + + const existingValid = T.Chat.isValidConversationIDKey(selectedConversation) + if (!newConvID) { + if (!existingValid && isMobile) { + logger.info(`maybeChangeSelectedConversation: no new and no valid, so go to inbox`) + navigateToInbox(false) + } + return + } + + if (selectedConversation !== oldConvID) { + if (!existingValid && isMobile) { + logger.info(`maybeChangeSelectedConversation: no new and no valid, so go to inbox`) + navigateToInbox(false) + } + return + } + + if (isMobile) { + if (T.Chat.isValidConversationIDKey(selectedConversation)) { + logger.info(`maybeChangeSelectedConversation: mobile: navigating up on conv change`) + navigateToInbox(false) + return + } + logger.info(`maybeChangeSelectedConversation: mobile: ignoring conv change, no conv selected`) + return + } + + logger.info( + `maybeChangeSelectedConversation: selecting new conv: new:${newConvID} old:${oldConvID} prevselected ${selectedConversation}` + ) + getConvoState(newConvID).dispatch.navigateToThread('findNewestConversation') +} + +export const loadSelectedConversationIfStale = (metas: ReadonlyArray) => { + const selectedConversation = Common.getSelectedConversation() + if (metas.some(meta => meta.conversationIDKey === selectedConversation)) { + getConvoState(selectedConversation).dispatch.loadMoreMessages({reason: 'got stale'}) + } +} + export const hydrateInboxLayout = (layout: T.RPCChat.UIInboxLayout) => { layout.smallTeams?.forEach(t => { const cs = getConvoState(t.convID) @@ -547,6 +597,25 @@ export const hydrateInboxLayout = (layout: T.RPCChat.UIInboxLayout) => { flushInboxRowUpdates() } +export const hydrateInboxConversations = (inboxUIItems: ReadonlyArray) => { + const metas: Array = [] + inboxUIItems.forEach(inboxUIItem => { + const meta = Meta.inboxUIItemToConversationMeta(inboxUIItem) + if (meta) { + metas.push(meta) + } + const participantInfo: T.Chat.ParticipantInfo = Common.uiParticipantsToParticipantInfo( + inboxUIItem.participants ?? [] + ) + if (participantInfo.all.length > 0) { + getConvoState(T.Chat.stringToConversationIDKey(inboxUIItem.convID)).dispatch.setParticipants(participantInfo) + } + }) + if (metas.length > 0) { + metasReceived(metas) + } +} + let metaQueue: Set = __DEV__ ? (((globalThis as {__hmr_convoMetaQueue?: Set}).__hmr_convoMetaQueue ??= new Set()) as Set) @@ -650,6 +719,36 @@ export const ensureWidgetMetas = ( unboxRows(missing, true) } +export const syncGregorExplodingModes = ( + items: ReadonlyArray<{md: T.RPCGen.Gregor1.Metadata; item: T.RPCGen.Gregor1.Item}> +) => { + const explodingItems = items.filter(i => i.item.category.startsWith(Common.explodingModeGregorKeyPrefix)) + if (!explodingItems.length) { + for (const store of chatStores.values()) { + store.getState().dispatch.setExplodingMode(0, true) + } + return + } + + explodingItems.forEach(i => { + try { + const {category, body} = i.item + const secondsString = uint8ArrayToString(body) + const seconds = parseInt(secondsString, 10) + if (isNaN(seconds)) { + logger.warn(`Got dirty exploding mode ${secondsString} for category ${category}`) + return + } + const conversationIDKey = T.Chat.stringToConversationIDKey( + category.substring(Common.explodingModeGregorKeyPrefix.length) + ) + getConvoState(conversationIDKey).dispatch.setExplodingMode(seconds, true) + } catch (error) { + logger.info('Error parsing exploding' + error) + } + }) +} + type ConvoEngineIncomingResult = { handled: boolean inboxUIItem?: T.RPCChat.InboxUIItem From 82202d5f931c7a49b6ae91c02f8fd19af651e2f8 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 16 Apr 2026 11:20:36 -0400 Subject: [PATCH 07/25] WIP --- plans/chat-split.md | 21 +++++---- .../messages/special-top-message.tsx | 6 +-- shared/chat/make-chat-screen.tsx | 18 ++++---- shared/constants/router.tsx | 5 ++- shared/stores/chat.tsx | 27 +++-------- shared/stores/convostate.tsx | 37 ++++++++++++--- shared/stores/tests/convostate.test.ts | 45 +++++++++++++++++++ 7 files changed, 111 insertions(+), 48 deletions(-) diff --git a/plans/chat-split.md b/plans/chat-split.md index aa2c0f8c4c5d..de8b658249de 100644 --- a/plans/chat-split.md +++ b/plans/chat-split.md @@ -31,6 +31,7 @@ These cleanup steps are already done: - conversation creation and team-building handoff now live in `convostate` - convo-targeted engine notifications now route through `convostate.handleConvoEngineIncoming`; `chat.onEngineIncomingImpl` keeps only global branches - service-driven convo reselect, stale selected-thread reload, inbox conversation hydration, and exploding-mode gregor sync now live in `convostate` +- badge / unread application and inbox-sync clear fanout now live in `convostate`; `chat.tsx` keeps only aggregate badge totals/versioning This means the remaining work is about removing the actual `chat -> convo` logic, not import barrels. @@ -79,23 +80,25 @@ Desired end state: ### 2. Badge / Unread Ownership -This is last because it is the riskiest ownership decision. +Status: -Current state: +- done; `convostate.syncBadgeState` now owns per-convo badge/unread application, while `chat.tsx` keeps only global badge counters -- global badge totals live in `chat` -- per-convo badge/unread also get updated from `chat` +This was last because it was the riskiest ownership decision. -Possible end states: +Resolved state: + +- global badge totals live in `chat` +- per-convo badge/unread get updated from `convostate` -- derive per-convo badge/unread from global inbox/badge data -- or move per-convo badge ownership fully to convo state +Chosen end state: -Do not decide this early. Resolve simpler buckets first. +- per-convo badge ownership remains in convo state +- badge-state payload fanout is convo-owned instead of chat-owned ## Recommended Order -1. Badge / unread ownership +1. Split complete ## Acceptance Criteria diff --git a/shared/chat/conversation/messages/special-top-message.tsx b/shared/chat/conversation/messages/special-top-message.tsx index e8bf10cb57ea..265edc9c34ff 100644 --- a/shared/chat/conversation/messages/special-top-message.tsx +++ b/shared/chat/conversation/messages/special-top-message.tsx @@ -118,9 +118,9 @@ function SpecialTopMessage() { const {teamType, supersedes, retentionPolicy, teamRetentionPolicy} = meta const loadMoreType = s.moreToLoadBack ? 'moreToLoad' : 'noMoreToLoad' const pendingState = - s.id === Chat.pendingWaitingConversationIDKey + s.id === T.Chat.pendingWaitingConversationIDKey ? 'waiting' - : s.id === Chat.pendingErrorConversationIDKey + : s.id === T.Chat.pendingErrorConversationIDKey ? 'error' : 'done' @@ -130,7 +130,7 @@ function SpecialTopMessage() { const isSelfConversation = teamType === 'adhoc' && partNum === 1 && partAll.includes(username) const showTeamOffer = hasLoadedEver && loadMoreType === 'noMoreToLoad' && teamType === 'adhoc' && partNum > 2 - const hasOlderResetConversation = supersedes !== Chat.noConversationIDKey + const hasOlderResetConversation = supersedes !== T.Chat.noConversationIDKey // don't show default header in the case of the retention notice being visible const showRetentionNotice = retentionPolicy.type !== 'retain' && diff --git a/shared/chat/make-chat-screen.tsx b/shared/chat/make-chat-screen.tsx index 593366ec4879..cbf303591ac8 100644 --- a/shared/chat/make-chat-screen.tsx +++ b/shared/chat/make-chat-screen.tsx @@ -2,7 +2,7 @@ import type {GetOptionsRet, RouteDef} from '@/constants/types/router' import type * as T from '@/constants/types' import {ProviderScreen} from '@/stores/convostate' import type {StaticScreenProps} from '@react-navigation/core' -import * as React from 'react' +import type {ComponentProps, LazyExoticComponent, ReactElement} from 'react' // See constants/router.tsx IsExactlyRecord for explanation type IsExactlyRecord = string extends keyof T ? true : false @@ -21,19 +21,19 @@ type AddConversationIDKey

= ? Omit & {conversationIDKey?: T.Chat.ConversationIDKey} : {conversationIDKey?: T.Chat.ConversationIDKey} -type LazyInnerComponent> = - COM extends React.LazyExoticComponent ? Inner : never +type LazyInnerComponent> = + COM extends LazyExoticComponent ? Inner : never -type ChatScreenParams> = NavigatorParamsFromProps< - AddConversationIDKey>> +type ChatScreenParams> = NavigatorParamsFromProps< + AddConversationIDKey>> > -type ChatScreenProps> = StaticScreenProps> -type ChatScreenComponent> = ( +type ChatScreenProps> = StaticScreenProps> +type ChatScreenComponent> = ( p: ChatScreenProps -) => React.ReactElement +) => ReactElement -export function makeChatScreen>( +export function makeChatScreen>( Component: COM, options?: { getOptions?: GetOptionsRet | ((props: ChatScreenProps) => GetOptionsRet) diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index fb84dbe9bdec..c223dcbebfc9 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -1,6 +1,7 @@ import type * as React from 'react' import * as T from './types' import type * as ConvoRegistryType from '@/stores/convo-registry' +import type * as ConvoStateType from '@/stores/convostate' import * as Tabs from './tabs' import { StackActions, @@ -370,7 +371,7 @@ export const previewConversation = (p: PreviewConversationParams) => { storeRegistry .getConvoState(T.Chat.pendingWaitingConversationIDKey) .dispatch.navigateToThread('justCreated') - const {createConversation} = require('@/stores/convostate') as typeof import('@/stores/convostate') + const {createConversation} = require('@/stores/convostate') as typeof ConvoStateType createConversation(participants, highlightMessageID) } @@ -432,7 +433,7 @@ export const previewConversation = (p: PreviewConversationParams) => { }) const meta = Meta.inboxUIItemToConversationMeta(results2.conv) if (meta) { - const {metasReceived} = require('@/stores/convostate') as typeof import('@/stores/convostate') + const {metasReceived} = require('@/stores/convostate') as typeof ConvoStateType metasReceived([meta]) } diff --git a/shared/stores/chat.tsx b/shared/stores/chat.tsx index 0b3a132dda72..442129ce9e0e 100644 --- a/shared/stores/chat.tsx +++ b/shared/stores/chat.tsx @@ -1,4 +1,3 @@ -import * as Common from '@/constants/chat/common' import type * as EngineGen from '@/constants/rpc' import * as Message from '@/constants/chat/message' import * as Meta from '@/constants/chat/meta' @@ -11,8 +10,8 @@ import logger from '@/logger' import type {RefreshReason} from '@/stores/chat-shared' import {RPCError} from '@/util/errors' import {bodyToJSON} from '@/constants/rpc-utils' -import {chatStores} from '@/stores/convo-registry' import { + clearConversationsForInboxSync, ensureWidgetMetas as ensureConvoWidgetMetas, handleConvoEngineIncoming, hydrateInboxConversations, @@ -20,12 +19,12 @@ import { loadSelectedConversationIfStale, metasReceived as convoMetasReceived, maybeChangeSelectedConversation, + syncBadgeState, syncGregorExplodingModes, unboxRows as convoUnboxRows, } from '@/stores/convostate' import {ignorePromise} from '@/constants/utils' import {isPhone} from '@/constants/platform' -import {storeRegistry} from '@/stores/store-registry' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useDaemonState} from '@/stores/daemon' @@ -223,18 +222,10 @@ export const useChatState = Z.createZustand('chat', (set, get) => { const dispatch: State['dispatch'] = { badgesUpdated: b => { - if (!b) return - const badgedConvIDs = new Set(b.conversations?.map(c => T.Chat.conversationIDToKey(c.convID)) ?? []) - for (const [id, cs] of chatStores) { - if (!badgedConvIDs.has(id) && cs.getState().badge > 0) { - cs.getState().dispatch.badgesUpdated(0) - } + syncBadgeState(b) + if (!b) { + return } - b.conversations?.forEach(c => { - const id = T.Chat.conversationIDToKey(c.convID) - storeRegistry.getConvoState(id).dispatch.badgesUpdated(c.badgeCount) - storeRegistry.getConvoState(id).dispatch.unreadUpdated(c.unreadMessages) - }) const {bigTeamBadgeCount, smallTeamBadgeCount} = b set(s => { s.badgeStateVersion += 1 @@ -318,11 +309,7 @@ export const useChatState = Z.createZustand('chat', (set, get) => { case T.RPCChat.SyncInboxResType.clear: { const f = async () => { await requestInboxLayout('inboxSyncedClear') - for (const [, cs] of chatStores) { - const {dispatch} = cs.getState() - dispatch.setMeta() - dispatch.messagesClear() - } + clearConversationsForInboxSync() } ignorePromise(f()) break @@ -352,7 +339,7 @@ export const useChatState = Z.createZustand('chat', (set, get) => { break } default: - inboxRefresh('inboxSyncedUnknown') + get().dispatch.inboxRefresh('inboxSyncedUnknown') } }, onEngineIncomingImpl: action => { diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 2dd51d6857d6..cc81bf8f1464 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -50,7 +50,7 @@ import {isMobile} from '@/constants/platform' import {enumKeys, ignorePromise, shallowEqual, timeoutPromise} from '@/constants/utils' import {flushInboxRowUpdates, queueInboxRowUpdate} from './inbox-rows' import * as Strings from '@/constants/strings' -import {chatStores, clearChatStores, convoUIStores} from './convo-registry' +import {chatStores, convoUIStores} from './convo-registry' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' @@ -616,6 +616,14 @@ export const hydrateInboxConversations = (inboxUIItems: ReadonlyArray { + for (const store of chatStores.values()) { + const {dispatch} = store.getState() + dispatch.setMeta() + dispatch.messagesClear() + } +} + let metaQueue: Set = __DEV__ ? (((globalThis as {__hmr_convoMetaQueue?: Set}).__hmr_convoMetaQueue ??= new Set()) as Set) @@ -702,7 +710,7 @@ export const queueMetaToRequest = (ids: ReadonlyArray) } export const ensureWidgetMetas = ( - widgetList: ReadonlyArray<{convID: T.Chat.ConversationIDKey}> | undefined + widgetList: ReadonlyArray<{convID: T.Chat.ConversationIDKey}> | null | undefined ) => { if (!widgetList) { return @@ -749,6 +757,26 @@ export const syncGregorExplodingModes = ( }) } +export const syncBadgeState = (badgeState?: T.RPCGen.BadgeState) => { + if (!badgeState) { + return + } + const badgedConvIDs = new Set( + badgeState.conversations?.map(conversation => T.Chat.conversationIDToKey(conversation.convID)) ?? [] + ) + for (const [conversationIDKey, store] of chatStores) { + if (!badgedConvIDs.has(conversationIDKey) && store.getState().badge > 0) { + store.getState().dispatch.badgesUpdated(0) + } + } + badgeState.conversations?.forEach(conversation => { + const conversationIDKey = T.Chat.conversationIDToKey(conversation.convID) + const {dispatch} = getConvoState(conversationIDKey) + dispatch.badgesUpdated(conversation.badgeCount) + dispatch.unreadUpdated(conversation.unreadMessages) + }) +} + type ConvoEngineIncomingResult = { handled: boolean inboxUIItem?: T.RPCChat.InboxUIItem @@ -805,7 +833,7 @@ const onChatThreadsStale = (updates: ThreadStaleUpdates) => { const onNewChatActivity = ( activity: NewChatActivity, - staticConfig?: T.Chat.StaticConfig + staticConfig?: T.Immutable ): ConvoEngineIncomingResult => { switch (activity.activityType) { case T.RPCChat.ChatActivityType.incomingMessage: { @@ -916,7 +944,7 @@ const onNewChatActivity = ( export const handleConvoEngineIncoming = ( action: EngineGen.Actions, - staticConfig?: T.Chat.StaticConfig + staticConfig?: T.Immutable ): ConvoEngineIncomingResult => { switch (action.type) { case 'chat.1.chatUi.chatInboxFailed': @@ -4076,7 +4104,6 @@ const createSlice = } type MadeStore = UseBoundStore> -type MadeUIStore = UseBoundStore> const createConvoUISlice = ( diff --git a/shared/stores/tests/convostate.test.ts b/shared/stores/tests/convostate.test.ts index a329c98fa372..ba1e55d46d84 100644 --- a/shared/stores/tests/convostate.test.ts +++ b/shared/stores/tests/convostate.test.ts @@ -10,6 +10,8 @@ import { createConvoStoresForTesting, type ConvoState, type ConvoUIState, + getConvoState, + syncBadgeState, } from '../convostate' jest.mock('../inbox-rows', () => ({ @@ -768,6 +770,49 @@ test('local setters update participants, reply target, and badge', () => { expect(store.getState().badge).toBe(3) }) +test('syncBadgeState updates listed conversations and clears missing badges', () => { + const firstConvID = T.Chat.conversationIDToKey(new Uint8Array([9, 8, 7, 6])) + const otherConvID = T.Chat.conversationIDToKey(new Uint8Array([6, 7, 8, 9])) + const store = getConvoState(firstConvID) + const otherStore = getConvoState(otherConvID) + + store.getState().dispatch.badgesUpdated(3) + store.getState().dispatch.unreadUpdated(4) + otherStore.getState().dispatch.badgesUpdated(2) + otherStore.getState().dispatch.unreadUpdated(5) + + syncBadgeState({ + bigTeamBadgeCount: 0, + conversations: [ + { + badgeCount: 1, + convID: T.Chat.keyToConversationID(otherConvID), + unreadMessages: 6, + }, + ], + homeTodoItems: 0, + inboxVers: 0, + newDevices: null, + newFollowers: 0, + newGitRepoGlobalUniqueIDs: [], + newTeamAccessRequestCount: 0, + newTeams: [], + newTlfs: 0, + rekeysNeeded: 0, + resetState: {active: false, endTime: 0}, + revokedDevices: null, + smallTeamBadgeCount: 1, + teamsWithResetUsers: null, + unverifiedEmails: 0, + unverifiedPhones: 0, + } as any) + + expect(store.getState().badge).toBe(0) + expect(store.getState().unread).toBe(4) + expect(otherStore.getState().badge).toBe(1) + expect(otherStore.getState().unread).toBe(6) +}) + test('toggleThreadSearch removes center highlight when opening search', () => { const store = createStore() applyState(store, { From ca354d1510cef3a4f3611a04c68a3af25a75e9bb Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 16 Apr 2026 11:22:43 -0400 Subject: [PATCH 08/25] WIP --- shared/chat/conversation/bot/install.tsx | 3 +-- shared/stores/tests/convostate.test.ts | 16 ++++++++-------- shared/teams/team/rows/index.tsx | 4 ++-- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/shared/chat/conversation/bot/install.tsx b/shared/chat/conversation/bot/install.tsx index fe9bb2d77c63..f3225eb037be 100644 --- a/shared/chat/conversation/bot/install.tsx +++ b/shared/chat/conversation/bot/install.tsx @@ -76,7 +76,6 @@ export const useBotConversationIDKey = (inConvIDKey?: T.Chat.ConversationIDKey, const cleanInConvIDKey = T.Chat.isValidConversationIDKey(inConvIDKey ?? '') ? inConvIDKey : undefined const [conversationIDKey, setConversationIDKey] = React.useState(cleanInConvIDKey) const findGeneralConvIDFromTeamID = C.useRPC(T.RPCChat.localFindGeneralConvFromTeamIDRpcPromise) - const metasReceived = Chat.useChatState(s => s.dispatch.metasReceived) const requestIDRef = React.useRef(0) React.useEffect(() => { @@ -99,7 +98,7 @@ export const useBotConversationIDKey = (inConvIDKey?: T.Chat.ConversationIDKey, if (!meta) { return } - metasReceived([meta]) + ConvoState.metasReceived([meta]) setConversationIDKey(meta.conversationIDKey) }, () => {} diff --git a/shared/stores/tests/convostate.test.ts b/shared/stores/tests/convostate.test.ts index ba1e55d46d84..fa86dd360357 100644 --- a/shared/stores/tests/convostate.test.ts +++ b/shared/stores/tests/convostate.test.ts @@ -776,10 +776,10 @@ test('syncBadgeState updates listed conversations and clears missing badges', () const store = getConvoState(firstConvID) const otherStore = getConvoState(otherConvID) - store.getState().dispatch.badgesUpdated(3) - store.getState().dispatch.unreadUpdated(4) - otherStore.getState().dispatch.badgesUpdated(2) - otherStore.getState().dispatch.unreadUpdated(5) + store.dispatch.badgesUpdated(3) + store.dispatch.unreadUpdated(4) + otherStore.dispatch.badgesUpdated(2) + otherStore.dispatch.unreadUpdated(5) syncBadgeState({ bigTeamBadgeCount: 0, @@ -807,10 +807,10 @@ test('syncBadgeState updates listed conversations and clears missing badges', () unverifiedPhones: 0, } as any) - expect(store.getState().badge).toBe(0) - expect(store.getState().unread).toBe(4) - expect(otherStore.getState().badge).toBe(1) - expect(otherStore.getState().unread).toBe(6) + expect(store.badge).toBe(0) + expect(store.unread).toBe(4) + expect(otherStore.badge).toBe(1) + expect(otherStore.unread).toBe(6) }) test('toggleThreadSearch removes center highlight when opening search', () => { diff --git a/shared/teams/team/rows/index.tsx b/shared/teams/team/rows/index.tsx index e467689e2eb4..a67a6779b466 100644 --- a/shared/teams/team/rows/index.tsx +++ b/shared/teams/team/rows/index.tsx @@ -1,6 +1,7 @@ import * as C from '@/constants' import * as Meta from '@/constants/chat/meta' import * as Chat from '@/stores/chat' +import * as ConvoState from '@/stores/convostate' import * as T from '@/constants/types' import * as Teams from '@/stores/teams' import * as Kb from '@/common-adapters' @@ -284,7 +285,6 @@ export const useSubteamsSections = ( const useGeneralConversationIDKey = (teamID?: T.Teams.TeamID) => { const [conversationIDKey, setConversationIDKey] = React.useState() const findGeneralConvIDFromTeamID = C.useRPC(T.RPCChat.localFindGeneralConvFromTeamIDRpcPromise) - const metasReceived = Chat.useChatState(s => s.dispatch.metasReceived) const requestIDRef = React.useRef(0) React.useEffect(() => { @@ -307,7 +307,7 @@ const useGeneralConversationIDKey = (teamID?: T.Teams.TeamID) => { if (!meta) { return } - metasReceived([meta]) + ConvoState.metasReceived([meta]) setConversationIDKey(meta.conversationIDKey) }, () => {} From 4f532113cf752e7515109c617a30da9b01a4230e Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 16 Apr 2026 11:24:48 -0400 Subject: [PATCH 09/25] WIP --- shared/chat/conversation/bot/install.tsx | 3 +-- shared/teams/team/rows/index.tsx | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/shared/chat/conversation/bot/install.tsx b/shared/chat/conversation/bot/install.tsx index f3225eb037be..2eebfe48e457 100644 --- a/shared/chat/conversation/bot/install.tsx +++ b/shared/chat/conversation/bot/install.tsx @@ -1,7 +1,6 @@ import * as C from '@/constants' import * as ChatCommon from '@/constants/chat/common' import * as Meta from '@/constants/chat/meta' -import * as Chat from '@/stores/chat' import * as ConvoState from '@/stores/convostate' import * as Kb from '@/common-adapters' import * as Teams from '@/stores/teams' @@ -108,7 +107,7 @@ export const useBotConversationIDKey = (inConvIDKey?: T.Chat.ConversationIDKey, requestIDRef.current += 1 } } - }, [cleanInConvIDKey, findGeneralConvIDFromTeamID, metasReceived, teamID]) + }, [cleanInConvIDKey, findGeneralConvIDFromTeamID, teamID]) return conversationIDKey } diff --git a/shared/teams/team/rows/index.tsx b/shared/teams/team/rows/index.tsx index a67a6779b466..10bf503244fe 100644 --- a/shared/teams/team/rows/index.tsx +++ b/shared/teams/team/rows/index.tsx @@ -317,7 +317,7 @@ const useGeneralConversationIDKey = (teamID?: T.Teams.TeamID) => { requestIDRef.current += 1 } } - }, [conversationIDKey, findGeneralConvIDFromTeamID, metasReceived, teamID]) + }, [conversationIDKey, findGeneralConvIDFromTeamID, teamID]) return conversationIDKey } From 8dd91297d3b2b0d94b2f5a799be7e6da413efc6d Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 16 Apr 2026 11:25:58 -0400 Subject: [PATCH 10/25] WIP --- shared/stores/tests/convostate.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/shared/stores/tests/convostate.test.ts b/shared/stores/tests/convostate.test.ts index fa86dd360357..a0d086f8a212 100644 --- a/shared/stores/tests/convostate.test.ts +++ b/shared/stores/tests/convostate.test.ts @@ -807,10 +807,10 @@ test('syncBadgeState updates listed conversations and clears missing badges', () unverifiedPhones: 0, } as any) - expect(store.badge).toBe(0) - expect(store.unread).toBe(4) - expect(otherStore.badge).toBe(1) - expect(otherStore.unread).toBe(6) + expect(getConvoState(firstConvID).badge).toBe(0) + expect(getConvoState(firstConvID).unread).toBe(4) + expect(getConvoState(otherConvID).badge).toBe(1) + expect(getConvoState(otherConvID).unread).toBe(6) }) test('toggleThreadSearch removes center highlight when opening search', () => { From 9c6d513f4270394f8d78f73c0e952927a0760762 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 16 Apr 2026 12:07:32 -0400 Subject: [PATCH 11/25] WIP --- shared/stores/convostate.tsx | 151 ++++++++++++++++++------- shared/stores/tests/convostate.test.ts | 18 +-- 2 files changed, 121 insertions(+), 48 deletions(-) diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index cc81bf8f1464..f4988dd986e2 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -44,7 +44,6 @@ import KB2 from '@/util/electron' import {NotifyPopup} from '@/util/misc' import {hexToUint8Array, uint8ArrayToString} from '@/util/uint8array' import {clearChatTimeCache} from '@/util/timestamp' -import {registerExternalResetter} from '@/util/zustand' import * as Config from '@/constants/config' import {isMobile} from '@/constants/platform' import {enumKeys, ignorePromise, shallowEqual, timeoutPromise} from '@/constants/utils' @@ -624,19 +623,117 @@ export const clearConversationsForInboxSync = () => { } } -let metaQueue: Set = __DEV__ - ? (((globalThis as {__hmr_convoMetaQueue?: Set}).__hmr_convoMetaQueue ??= - new Set()) as Set) - : new Set() +const untrustedConversationIDKeys = (ids: ReadonlyArray) => + ids.filter(id => getConvoState(id).meta.trustedState === 'untrusted') -const clearMetaQueue = () => { - metaQueue.clear() -} +type ConvoMetaQueueState = T.Immutable<{ + generation: number + inFlight: boolean + pending: ReadonlySet + dispatch: { + queueMetaHandle: () => void + queueMetaToRequest: (ids: ReadonlyArray) => void + resetState: () => void + } +}> -registerExternalResetter('convo-meta-queue', clearMetaQueue) +const useConvoMetaQueueState = Z.createZustand('convo-meta-queue', (set, get) => ({ + dispatch: { + queueMetaHandle: () => { + const {generation, inFlight, pending} = get() + if (inFlight || pending.size === 0) { + return + } + set(s => { + if (s.generation === generation && !s.inFlight && s.pending.size > 0) { + s.inFlight = true + } + }) + if (get().generation === generation && get().inFlight) { + ignorePromise(runMetaQueueWorker(generation)) + } + }, + queueMetaToRequest: (ids: ReadonlyArray) => { + const nextIDs = untrustedConversationIDKeys(ids) + let changed = false + set(s => { + const pending = new Set(s.pending) + nextIDs.forEach(k => pending.add(k)) + changed = pending.size > s.pending.size + if (changed) { + s.pending = pending + } + }) + if (changed) { + get().dispatch.queueMetaHandle() + } else { + logger.info('skipping meta queue run, queue unchanged') + } + }, + resetState: () => { + set(s => { + s.generation += 1 + s.inFlight = false + s.pending = new Set() + }) + }, + }, + generation: 0, + inFlight: false, + pending: new Set(), +})) + +async function runMetaQueueWorker(generation: number) { + try { + while (true) { + if (useConvoMetaQueueState.getState().generation !== generation) { + return + } -const untrustedConversationIDKeys = (ids: ReadonlyArray) => - ids.filter(id => getConvoState(id).meta.trustedState === 'untrusted') + const maxToUnboxAtATime = 10 + let maybeUnbox: Array = [] + useConvoMetaQueueState.setState(s => { + if (s.generation !== generation) { + return + } + const pending = [...s.pending] + maybeUnbox = pending.slice(0, maxToUnboxAtATime) + s.pending = new Set(pending.slice(maxToUnboxAtATime)) + }) + + if (!maybeUnbox.length) { + return + } + + const conversationIDKeys = untrustedConversationIDKeys(maybeUnbox) + if (conversationIDKeys.length) { + unboxRows(conversationIDKeys) + } + + const current = useConvoMetaQueueState.getState() + if (current.generation !== generation || current.pending.size === 0) { + return + } + if (conversationIDKeys.length) { + await timeoutPromise(100) + } + } + } finally { + const current = useConvoMetaQueueState.getState() + if (current.generation !== generation) { + return + } + useConvoMetaQueueState.setState(s => { + if (s.generation === generation) { + s.inFlight = false + } + }) + const next = useConvoMetaQueueState.getState() + if (next.generation === generation && next.pending.size > 0) { + next.dispatch.queueMetaHandle() + } + } +} export const unboxRows = (ids: ReadonlyArray, force?: boolean) => { const f = async () => { @@ -674,39 +771,11 @@ export const unboxRows = (ids: ReadonlyArray, force?: } export const queueMetaHandle = () => { - const f = async () => { - const maxToUnboxAtATime = 10 - const ar = [...metaQueue] - const maybeUnbox = ar.slice(0, maxToUnboxAtATime) - metaQueue = new Set(ar.slice(maxToUnboxAtATime)) - if (__DEV__) { - ;(globalThis as {__hmr_convoMetaQueue?: Set}).__hmr_convoMetaQueue = metaQueue - } - const conversationIDKeys = untrustedConversationIDKeys(maybeUnbox) - if (conversationIDKeys.length) { - unboxRows(conversationIDKeys) - } - if (metaQueue.size && conversationIDKeys.length) { - await timeoutPromise(100) - } - if (metaQueue.size) { - queueMetaHandle() - } - } - ignorePromise(f()) + useConvoMetaQueueState.getState().dispatch.queueMetaHandle() } export const queueMetaToRequest = (ids: ReadonlyArray) => { - const prevSize = metaQueue.size - untrustedConversationIDKeys(ids).forEach(k => metaQueue.add(k)) - if (__DEV__) { - ;(globalThis as {__hmr_convoMetaQueue?: Set}).__hmr_convoMetaQueue = metaQueue - } - if (metaQueue.size > prevSize) { - queueMetaHandle() - } else { - logger.info('skipping meta queue run, queue unchanged') - } + useConvoMetaQueueState.getState().dispatch.queueMetaToRequest(ids) } export const ensureWidgetMetas = ( diff --git a/shared/stores/tests/convostate.test.ts b/shared/stores/tests/convostate.test.ts index a0d086f8a212..60fe886d953a 100644 --- a/shared/stores/tests/convostate.test.ts +++ b/shared/stores/tests/convostate.test.ts @@ -4,6 +4,7 @@ import * as Meta from '../../constants/chat/meta' import * as Message from '../../constants/chat/message' import * as T from '../../constants/types' import HiddenString from '../../util/hidden-string' +import {resetAllStores} from '../../util/zustand' import {useCurrentUserState} from '../current-user' import { createConvoStoreForTesting, @@ -18,8 +19,18 @@ jest.mock('../inbox-rows', () => ({ queueInboxRowUpdate: jest.fn(), })) +beforeEach(() => { + useCurrentUserState.getState().dispatch.setBootstrap({ + deviceID: 'device-id', + deviceName: 'test-device', + uid: 'uid', + username: 'alice', + }) +}) + afterEach(() => { jest.restoreAllMocks() + resetAllStores() }) const convID = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) @@ -27,13 +38,6 @@ const ordinal = T.Chat.numberToOrdinal(10) const msgID = T.Chat.numberToMessageID(101) const outboxID = T.Chat.stringToOutboxID('outbox-1') -useCurrentUserState.getState().dispatch.setBootstrap({ - deviceID: 'device-id', - deviceName: 'test-device', - uid: 'uid', - username: 'alice', -}) - const makeReaction = (username: string, timestamp: number): T.Chat.ReactionDesc => ({ decorated: ':+1:', users: [{timestamp, username}], From 2641cece7e06838c29a69e6301b5a2d7aa9e9ad0 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 16 Apr 2026 12:17:22 -0400 Subject: [PATCH 12/25] WIP --- plans/chat-split.md | 58 +++++++++++++++++++++++++++++--- shared/constants/init/shared.tsx | 31 ++++++++++++++--- shared/stores/chat.tsx | 23 ------------- shared/stores/convostate.tsx | 10 ++++++ 4 files changed, 89 insertions(+), 33 deletions(-) diff --git a/plans/chat-split.md b/plans/chat-split.md index de8b658249de..87fad5faedf3 100644 --- a/plans/chat-split.md +++ b/plans/chat-split.md @@ -30,10 +30,18 @@ These cleanup steps are already done: - `ensureWidgetMetas`, meta queueing, and `unboxRows` now live in `convostate` - conversation creation and team-building handoff now live in `convostate` - convo-targeted engine notifications now route through `convostate.handleConvoEngineIncoming`; `chat.onEngineIncomingImpl` keeps only global branches +- init-time engine routing now sends convo-targeted notifications directly to `convostate`; `chat.tsx` is no longer the top-level engine entrypoint for convo-targeted branches - service-driven convo reselect, stale selected-thread reload, inbox conversation hydration, and exploding-mode gregor sync now live in `convostate` - badge / unread application and inbox-sync clear fanout now live in `convostate`; `chat.tsx` keeps only aggregate badge totals/versioning +- init-time badge routing now sends per-convo badge/unread fanout directly to `convostate`; `chat.dispatch.badgesUpdated` keeps only global badge counters -This means the remaining work is about removing the actual `chat -> convo` logic, not import barrels. +What is still true on this branch: + +- `chat.tsx` no longer reaches into convo stores directly with `getConvoState(...)` +- `chat.tsx` no longer iterates convo registries directly +- `chat.tsx` still imports and orchestrates several convo-owned entrypoints from `convostate` + +This means the remaining work is about removing the last `chat -> convostate` orchestration layer, not import barrels or direct registry access. ## Non-Goals @@ -47,8 +55,8 @@ This means the remaining work is about removing the actual `chat -> convo` logic Status: -- done for `onEngineIncomingImpl`; convo-targeted engine actions now dispatch from `convostate` -- done for the remaining non-badge residual fanout; `chat.tsx` no longer directly drives convo navigation, hydration, stale reloads, or exploding-mode sync +- done for the engine-entrypoint portion; convo-targeted branches now route from `shared/constants/init/shared.tsx` straight into `convostate.handleConvoEngineIncoming` +- `chat.onEngineIncomingImpl` now keeps only the remaining global / inbox-layout related chat branches Most of `onEngineIncomingImpl` is a dispatcher into specific convo stores. @@ -77,12 +85,14 @@ Desired end state: - convo-targeted notifications are handled by convo-owned entrypoints - `chat.tsx` only handles truly global notifications +- `chat.tsx` no longer calls `handleConvoEngineIncoming(...)` ### 2. Badge / Unread Ownership Status: -- done; `convostate.syncBadgeState` now owns per-convo badge/unread application, while `chat.tsx` keeps only global badge counters +- done for the ownership boundary; `convostate.syncBadgeState` owns per-convo badge/unread application, while init-time badge routing calls it directly +- `chat.dispatch.badgesUpdated` now keeps only the aggregate badge counters/versioning This was last because it was the riskiest ownership decision. @@ -96,9 +106,46 @@ Chosen end state: - per-convo badge ownership remains in convo state - badge-state payload fanout is convo-owned instead of chat-owned +### 3. Inbox Sync / Layout Orchestration + +Status: + +- partially done; most of the convo work has moved into `convostate` +- still not fully split because `chat.tsx` continues to orchestrate several convo-owned helpers during inbox sync and layout handling + +Residual `chat.tsx` call sites: + +- `clearConversationsForInboxSync` +- `loadSelectedConversationIfStale` +- `metasReceived` +- `unboxRows` +- `maybeChangeSelectedConversation` +- `ensureWidgetMetas` +- `hydrateInboxConversations` +- `hydrateInboxLayout` + +Desired end state: + +- inbox sync and layout events enter through a convo-owned entrypoint when they need convo work +- `chat.tsx` only keeps global inbox layout state and global refresh triggers + +### 4. Remaining Acceptance Gap + +Status: + +- the strict architectural goal is not met yet even though the direct-registry and direct-`getConvoState` cleanup is done + +Reason: + +- `chat.tsx` still imports a broad convo API surface and coordinates convo behavior indirectly +- the split is therefore functionally close, but not yet a true standalone global chat store + ## Recommended Order -1. Split complete +1. Done: move convo-targeted engine entrypoint ownership fully out of `chat.tsx` +2. Done: move badge-state fanout ownership fully out of `chat.tsx` +3. Move inbox sync/layout convo orchestration behind convo-owned entrypoints +4. Re-check whether `chat.tsx` still needs any import from `convostate` beyond narrow data types, and delete the remaining coupling ## Acceptance Criteria @@ -107,6 +154,7 @@ The split is done when: - `chat.tsx` contains only global chat state and global chat actions - `chat.tsx` does not iterate convo stores - `chat.tsx` does not call `getConvoState(...)` +- `chat.tsx` does not dispatch convo-owned work by calling `convostate` orchestration helpers - convo-targeted routing is owned outside `chat.tsx` - `chat.tsx` can be understood as a standalone global store file diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index deddcf2b971e..f266a910c2bc 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -50,10 +50,12 @@ import {useTeamsState} from '@/stores/teams' import {useRouterState} from '@/stores/router' import * as Util from '@/constants/router' import { + handleConvoEngineIncoming, metasReceived as convoMetasReceived, onRouteChanged as onConvoRouteChanged, onTeamBuildingFinished as onConvoTeamBuildingFinished, setConvoDefer, + syncBadgeState, } from '@/stores/convostate' import {clearSignupEmail} from '@/people/signup-email' import {clearSignupDeviceNameDraft} from '@/signup/device-name-draft' @@ -438,6 +440,16 @@ export const initSharedSubscriptions = () => { // 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, useChatState.getState().staticConfig) + if (result.inboxUIItem) { + useChatState.getState().dispatch.onIncomingInboxUIItem(result.inboxUIItem) + } + if (result.userReacjis) { + useChatState.getState().dispatch.updateUserReacjis(result.userReacjis) + } + } + switch (action.type) { case 'keybase.1.NotifySimpleFS.simpleFSArchiveStatusChanged': case 'chat.1.NotifyChat.ChatArchiveComplete': @@ -450,6 +462,7 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { case 'keybase.1.NotifyBadges.badgeState': { const {badgeState} = action.payload.params + syncBadgeState(badgeState) useModalHeaderState .getState() .dispatch.setDeviceBadges( @@ -472,7 +485,6 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { case 'chat.1.chatUi.chatShowManageChannels': case 'keybase.1.NotifyTeam.teamMetadataUpdate': case 'chat.1.NotifyChat.ChatWelcomeMessageLoaded': - case 'chat.1.NotifyChat.ChatSetTeamRetention': case 'keybase.1.NotifyTeam.teamTreeMembershipsPartial': case 'keybase.1.NotifyTeam.teamTreeMembershipsDone': case 'keybase.1.NotifyTeam.teamRoleMapChanged': @@ -487,6 +499,13 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { useChatState.getState().dispatch.onEngineIncomingImpl(action) } break + case 'chat.1.NotifyChat.ChatSetTeamRetention': + { + const {useTeamsState} = require('@/stores/teams') as typeof UseTeamsStateType + useTeamsState.getState().dispatch.onEngineIncomingImpl(action) + routeConvoEngineIncoming(action) + } + break case 'keybase.1.NotifyFS.FSOverallSyncStatusChanged': case 'keybase.1.NotifyFS.FSSubscriptionNotifyPath': case 'keybase.1.NotifyFS.FSSubscriptionNotify': @@ -550,12 +569,17 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { case 'chat.1.chatUi.chatBotCommandsUpdateStatus': case 'chat.1.chatUi.chatGiphySearchResults': case 'chat.1.NotifyChat.ChatParticipantsInfo': - case 'chat.1.chatUi.chatMaybeMentionUpdate': 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.chatUi.chatMaybeMentionUpdate': case 'chat.1.NotifyChat.ChatIdentifyUpdate': case 'chat.1.chatUi.chatInboxUnverified': case 'chat.1.NotifyChat.ChatInboxSyncStarted': @@ -563,9 +587,6 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { case 'chat.1.chatUi.chatInboxLayout': case 'chat.1.NotifyChat.ChatInboxStale': case 'chat.1.chatUi.chatInboxConversation': - case 'chat.1.NotifyChat.NewChatActivity': - case 'chat.1.NotifyChat.ChatTypingUpdate': - case 'chat.1.NotifyChat.ChatSetConvRetention': { const {useChatState} = require('@/stores/chat') as typeof UseChatStateType useChatState.getState().dispatch.onEngineIncomingImpl(action) diff --git a/shared/stores/chat.tsx b/shared/stores/chat.tsx index 442129ce9e0e..362657fc71c5 100644 --- a/shared/stores/chat.tsx +++ b/shared/stores/chat.tsx @@ -13,13 +13,11 @@ import {bodyToJSON} from '@/constants/rpc-utils' import { clearConversationsForInboxSync, ensureWidgetMetas as ensureConvoWidgetMetas, - handleConvoEngineIncoming, hydrateInboxConversations, hydrateInboxLayout, loadSelectedConversationIfStale, metasReceived as convoMetasReceived, maybeChangeSelectedConversation, - syncBadgeState, syncGregorExplodingModes, unboxRows as convoUnboxRows, } from '@/stores/convostate' @@ -222,7 +220,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { const dispatch: State['dispatch'] = { badgesUpdated: b => { - syncBadgeState(b) if (!b) { return } @@ -343,32 +340,12 @@ export const useChatState = Z.createZustand('chat', (set, get) => { } }, onEngineIncomingImpl: action => { - const convoResult = handleConvoEngineIncoming(action, get().staticConfig) - if (convoResult.handled) { - if (convoResult.inboxUIItem) { - get().dispatch.onIncomingInboxUIItem(convoResult.inboxUIItem) - } - if (convoResult.userReacjis) { - get().dispatch.updateUserReacjis(convoResult.userReacjis) - } - return - } switch (action.type) { case 'chat.1.chatUi.chatMaybeMentionUpdate': { const {teamName, channel, info} = action.payload.params get().dispatch.setMaybeMentionInfo(getTeamMentionName(teamName, channel), info) break } - case 'chat.1.NotifyChat.ChatConvUpdate': { - const {conv} = action.payload.params - if (conv) { - const meta = Meta.inboxUIItemToConversationMeta(conv) - if (meta) { - convoMetasReceived([meta]) - } - } - break - } case 'chat.1.NotifyChat.ChatIdentifyUpdate': { // Some participants are broken/fixed now const {update} = action.payload.params diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index f4988dd986e2..2a2814b422c2 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -1016,6 +1016,16 @@ export const handleConvoEngineIncoming = ( staticConfig?: T.Immutable ): ConvoEngineIncomingResult => { switch (action.type) { + case 'chat.1.NotifyChat.ChatConvUpdate': { + const {conv} = action.payload.params + if (conv) { + const meta = Meta.inboxUIItemToConversationMeta(conv) + if (meta) { + metasReceived([meta]) + } + } + return handledConvoEngineIncoming() + } case 'chat.1.chatUi.chatInboxFailed': case 'chat.1.NotifyChat.ChatSetConvSettings': case 'chat.1.NotifyChat.ChatAttachmentUploadStart': From da61de941b59c25e6875b9d4e8d66685c0da42dd Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 16 Apr 2026 12:27:04 -0400 Subject: [PATCH 13/25] WIP --- plans/chat-split.md | 38 ++++---- shared/constants/init/shared.tsx | 61 +++++++++++-- shared/stores/chat.tsx | 151 +------------------------------ shared/stores/convostate.tsx | 122 +++++++++++++++++++++++-- 4 files changed, 189 insertions(+), 183 deletions(-) diff --git a/plans/chat-split.md b/plans/chat-split.md index 87fad5faedf3..a3640e3f196e 100644 --- a/plans/chat-split.md +++ b/plans/chat-split.md @@ -34,14 +34,17 @@ These cleanup steps are already done: - service-driven convo reselect, stale selected-thread reload, inbox conversation hydration, and exploding-mode gregor sync now live in `convostate` - badge / unread application and inbox-sync clear fanout now live in `convostate`; `chat.tsx` keeps only aggregate badge totals/versioning - init-time badge routing now sends per-convo badge/unread fanout directly to `convostate`; `chat.dispatch.badgesUpdated` keeps only global badge counters +- inbox sync/layout routing now enters through init-time convo-owned handlers; `chat.tsx` keeps only global inbox layout state and refresh triggers +- exploding-mode gregor fanout now routes outside `chat.tsx`; `chat.tsx` no longer imports `convostate` -What is still true on this branch: +Current branch result: - `chat.tsx` no longer reaches into convo stores directly with `getConvoState(...)` - `chat.tsx` no longer iterates convo registries directly -- `chat.tsx` still imports and orchestrates several convo-owned entrypoints from `convostate` +- `chat.tsx` no longer imports `convostate` +- convo-targeted routing now lives outside `chat.tsx` -This means the remaining work is about removing the last `chat -> convostate` orchestration layer, not import barrels or direct registry access. +The split goal is now met on this branch. ## Non-Goals @@ -110,19 +113,8 @@ Chosen end state: Status: -- partially done; most of the convo work has moved into `convostate` -- still not fully split because `chat.tsx` continues to orchestrate several convo-owned helpers during inbox sync and layout handling - -Residual `chat.tsx` call sites: - -- `clearConversationsForInboxSync` -- `loadSelectedConversationIfStale` -- `metasReceived` -- `unboxRows` -- `maybeChangeSelectedConversation` -- `ensureWidgetMetas` -- `hydrateInboxConversations` -- `hydrateInboxLayout` +- done for the inbox/layout ownership boundary; init-time routing now sends inbox sync/layout convo work into `convostate` +- `chat.tsx` keeps only inbox layout state parsing/storage and global refresh triggers Desired end state: @@ -133,19 +125,23 @@ Desired end state: Status: -- the strict architectural goal is not met yet even though the direct-registry and direct-`getConvoState` cleanup is done +- none; the acceptance criteria below are now satisfied on this branch Reason: -- `chat.tsx` still imports a broad convo API surface and coordinates convo behavior indirectly -- the split is therefore functionally close, but not yet a true standalone global chat store +- `chat.tsx` no longer imports or dispatches convo-owned helpers +- remaining follow-up work, if any, is ordinary cleanup rather than split-boundary work ## Recommended Order 1. Done: move convo-targeted engine entrypoint ownership fully out of `chat.tsx` 2. Done: move badge-state fanout ownership fully out of `chat.tsx` -3. Move inbox sync/layout convo orchestration behind convo-owned entrypoints -4. Re-check whether `chat.tsx` still needs any import from `convostate` beyond narrow data types, and delete the remaining coupling +3. Done: move inbox sync/layout convo orchestration behind convo-owned entrypoints +4. Done: remove the last remaining `chat.tsx -> convostate` helper coupling + +Result: + +1. Split complete ## Acceptance Criteria diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index f266a910c2bc..5acfca850321 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -1,5 +1,6 @@ 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' @@ -47,15 +48,22 @@ import {useModalHeaderState} from '@/stores/modal-header' import {useProvisionState} from '@/stores/provision' import {useSettingsContactsState} from '@/stores/settings-contacts' import {useTeamsState} from '@/stores/teams' +import {useWaitingState} from '@/stores/waiting' import {useRouterState} from '@/stores/router' import * as Util from '@/constants/router' import { + onChatInboxSynced, + onGetInboxConvsUnboxed, + onGetInboxUnverifiedConvs, + onInboxLayoutChanged, + onIncomingInboxUIItem, handleConvoEngineIncoming, metasReceived as convoMetasReceived, onRouteChanged as onConvoRouteChanged, onTeamBuildingFinished as onConvoTeamBuildingFinished, setConvoDefer, syncBadgeState, + syncGregorExplodingModes, } from '@/stores/convostate' import {clearSignupEmail} from '@/people/signup-email' import {clearSignupDeviceNameDraft} from '@/signup/device-name-draft' @@ -443,7 +451,7 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { const routeConvoEngineIncoming = (engineAction: EngineGen.Actions) => { const result = handleConvoEngineIncoming(engineAction, useChatState.getState().staticConfig) if (result.inboxUIItem) { - useChatState.getState().dispatch.onIncomingInboxUIItem(result.inboxUIItem) + onIncomingInboxUIItem(result.inboxUIItem) } if (result.userReacjis) { useChatState.getState().dispatch.updateUserReacjis(result.userReacjis) @@ -491,7 +499,6 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { case 'keybase.1.NotifyTeam.teamChangedByID': case 'keybase.1.NotifyTeam.teamDeleted': case 'keybase.1.NotifyTeam.teamExit': - case 'keybase.1.gregorUI.pushState': { const {useTeamsState} = require('@/stores/teams') as typeof UseTeamsStateType useTeamsState.getState().dispatch.onEngineIncomingImpl(action) @@ -499,6 +506,29 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { useChatState.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') + } + syncGregorExplodingModes(goodState) + + const {useTeamsState} = require('@/stores/teams') as typeof UseTeamsStateType + useTeamsState.getState().dispatch.onEngineIncomingImpl(action) + const {useChatState} = require('@/stores/chat') as typeof UseChatStateType + useChatState.getState().dispatch.onEngineIncomingImpl(action) + break + } case 'chat.1.NotifyChat.ChatSetTeamRetention': { const {useTeamsState} = require('@/stores/teams') as typeof UseTeamsStateType @@ -581,17 +611,34 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { break case 'chat.1.chatUi.chatMaybeMentionUpdate': case 'chat.1.NotifyChat.ChatIdentifyUpdate': - case 'chat.1.chatUi.chatInboxUnverified': - case 'chat.1.NotifyChat.ChatInboxSyncStarted': - case 'chat.1.NotifyChat.ChatInboxSynced': - case 'chat.1.chatUi.chatInboxLayout': case 'chat.1.NotifyChat.ChatInboxStale': - case 'chat.1.chatUi.chatInboxConversation': { const {useChatState} = require('@/stores/chat') as typeof UseChatStateType useChatState.getState().dispatch.onEngineIncomingImpl(action) } 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, reason => useChatState.getState().dispatch.inboxRefresh(reason))) + break + case 'chat.1.chatUi.chatInboxLayout': { + const {inboxHasLoaded, dispatch} = useChatState.getState() + dispatch.updateInboxLayout(action.payload.params.layout) + const {inboxLayout} = useChatState.getState() + if (inboxLayout) { + onInboxLayoutChanged(inboxLayout, inboxHasLoaded) + } + break + } + case 'chat.1.chatUi.chatInboxConversation': + onGetInboxConvsUnboxed(action) + break case 'keybase.1.NotifyService.handleKeybaseLink': { const {link, deferred} = action.payload.params diff --git a/shared/stores/chat.tsx b/shared/stores/chat.tsx index 362657fc71c5..a8d490011882 100644 --- a/shared/stores/chat.tsx +++ b/shared/stores/chat.tsx @@ -1,7 +1,5 @@ import type * as EngineGen from '@/constants/rpc' import * as Message from '@/constants/chat/message' -import * as Meta from '@/constants/chat/meta' -import * as S from '@/constants/strings' import * as T from '@/constants/types' import * as TeamConstants from '@/constants/teams' import * as Z from '@/util/zustand' @@ -10,24 +8,12 @@ import logger from '@/logger' import type {RefreshReason} from '@/stores/chat-shared' import {RPCError} from '@/util/errors' import {bodyToJSON} from '@/constants/rpc-utils' -import { - clearConversationsForInboxSync, - ensureWidgetMetas as ensureConvoWidgetMetas, - hydrateInboxConversations, - hydrateInboxLayout, - loadSelectedConversationIfStale, - metasReceived as convoMetasReceived, - maybeChangeSelectedConversation, - syncGregorExplodingModes, - unboxRows as convoUnboxRows, -} from '@/stores/convostate' import {ignorePromise} from '@/constants/utils' import {isPhone} from '@/constants/platform' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useDaemonState} from '@/stores/daemon' import {useUsersState} from '@/stores/users' -import {useWaitingState} from '@/stores/waiting' const defaultTopReacjis = [ {name: ':+1:'}, @@ -183,14 +169,10 @@ export type State = Store & { badgesUpdated: (badgeState?: T.RPCGen.BadgeState) => void dismissBlockButtons: (teamID: T.RPCGen.TeamID) => void dismissBlockButtonsIfPresent: (teamID: T.RPCGen.TeamID) => void - inboxRefresh: (reason: RefreshReason) => void + inboxRefresh: (reason: RefreshReason) => Promise setInboxRetriedOnCurrentEmpty: (retried: boolean) => void loadStaticConfig: () => void onEngineIncomingImpl: (action: EngineGen.Actions) => void - onChatInboxSynced: (action: EngineGen.EngineAction<'chat.1.NotifyChat.ChatInboxSynced'>) => void - onGetInboxConvsUnboxed: (action: EngineGen.EngineAction<'chat.1.chatUi.chatInboxConversation'>) => void - onGetInboxUnverifiedConvs: (action: EngineGen.EngineAction<'chat.1.chatUi.chatInboxUnverified'>) => void - onIncomingInboxUIItem: (inboxUIItem?: T.RPCChat.InboxUIItem) => void resetState: () => void setMaybeMentionInfo: (name: string, info: T.RPCChat.UIMaybeMentionInfo) => void updateInboxLayout: (layout: string) => void @@ -247,9 +229,7 @@ export const useChatState = Z.createZustand('chat', (set, get) => { get().dispatch.dismissBlockButtons(teamID) } }, - inboxRefresh: reason => { - ignorePromise(requestInboxLayout(reason)) - }, + inboxRefresh: reason => requestInboxLayout(reason), loadStaticConfig: () => { if (get().staticConfig) { return @@ -296,49 +276,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { } ignorePromise(f()) }, - onChatInboxSynced: action => { - const {syncRes} = action.payload.params - const {clear} = useWaitingState.getState().dispatch - clear(S.waitingKeyChatInboxSyncStarted) - - switch (syncRes.syncType) { - // Just clear it all - case T.RPCChat.SyncInboxResType.clear: { - const f = async () => { - await requestInboxLayout('inboxSyncedClear') - clearConversationsForInboxSync() - } - ignorePromise(f()) - break - } - // We're up to date - case T.RPCChat.SyncInboxResType.current: - break - // We got some new messages appended - case T.RPCChat.SyncInboxResType.incremental: { - const items = syncRes.incremental.items || [] - const metas = items.reduce>((arr, i) => { - const meta = Meta.unverifiedInboxUIItemToConversationMeta(i.conv) - if (meta) arr.push(meta) - return arr - }, []) - loadSelectedConversationIfStale(metas) - const removals = syncRes.incremental.removals?.map(T.Chat.stringToConversationIDKey) - // Update new untrusted - if (metas.length || removals?.length) { - convoMetasReceived(metas, removals) - } - - convoUnboxRows( - items.filter(i => i.shouldUnbox).map(i => T.Chat.stringToConversationIDKey(i.conv.convID)), - true - ) - break - } - default: - get().dispatch.inboxRefresh('inboxSyncedUnknown') - } - }, onEngineIncomingImpl: action => { switch (action.type) { case 'chat.1.chatUi.chatMaybeMentionUpdate': { @@ -355,27 +292,9 @@ export const useChatState = Z.createZustand('chat', (set, get) => { useUsersState.getState().dispatch.updates(updates) break } - case 'chat.1.chatUi.chatInboxUnverified': - get().dispatch.onGetInboxUnverifiedConvs(action) - break - case 'chat.1.NotifyChat.ChatInboxSyncStarted': - useWaitingState.getState().dispatch.increment(S.waitingKeyChatInboxSyncStarted) - break - - case 'chat.1.NotifyChat.ChatInboxSynced': - get().dispatch.onChatInboxSynced(action) - break - case 'chat.1.chatUi.chatInboxLayout': - get().dispatch.updateInboxLayout(action.payload.params.layout) - maybeChangeSelectedConversation(get().inboxLayout) - ensureConvoWidgetMetas(get().inboxLayout?.widgetList) - break case 'chat.1.NotifyChat.ChatInboxStale': get().dispatch.inboxRefresh('inboxStale') break - case 'chat.1.chatUi.chatInboxConversation': - get().dispatch.onGetInboxConvsUnboxed(action) - break case 'keybase.1.NotifyBadges.badgeState': { const {badgeState} = action.payload.params get().dispatch.badgesUpdated(badgeState) @@ -402,66 +321,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { default: } }, - onGetInboxConvsUnboxed: action => { - // TODO not reactive - const {infoMap} = useUsersState.getState() - const {convs} = action.payload.params - const inboxUIItems = JSON.parse(convs) as Array - const usernameToFullname: {[username: string]: string} = {} - inboxUIItems.forEach(inboxUIItem => { - inboxUIItem.participants?.forEach((part: T.RPCChat.UIParticipant) => { - const {assertion, fullName} = part - if (!infoMap.get(assertion) && fullName) { - usernameToFullname[assertion] = fullName - } - }) - }) - if (Object.keys(usernameToFullname).length > 0) { - useUsersState.getState().dispatch.updates( - Object.keys(usernameToFullname).map(name => ({ - info: {fullname: usernameToFullname[name]}, - name, - })) - ) - } - hydrateInboxConversations(inboxUIItems) - }, - onGetInboxUnverifiedConvs: action => { - const {inbox} = action.payload.params - const result = JSON.parse(inbox) as T.RPCChat.UnverifiedInboxUIItems - const items: ReadonlyArray = result.items ?? [] - // We get a subset of meta information from the cache even in the untrusted payload - const metas = items.reduce>((arr, item) => { - const m = Meta.unverifiedInboxUIItemToConversationMeta(item) - if (m) { - arr.push(m) - } - return arr - }, []) - // Check if some of our existing stored metas might no longer be valid - convoMetasReceived(metas) - }, - onIncomingInboxUIItem: conv => { - if (!conv) return - const meta = Meta.inboxUIItemToConversationMeta(conv) - const usernameToFullname = (conv.participants ?? []).reduce<{[key: string]: string}>((map, part) => { - if (part.fullName) { - map[part.assertion] = part.fullName - } - return map - }, {}) - - useUsersState.getState().dispatch.updates( - Object.keys(usernameToFullname).map(name => ({ - info: {fullname: usernameToFullname[name]}, - name, - })) - ) - - if (meta) { - convoMetasReceived([meta]) - } - }, resetState: () => { set(s => ({ ...s, @@ -484,7 +343,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { updateInboxLayout: str => { set(s => { try { - const {inboxHasLoaded} = s const _layout = JSON.parse(str) as unknown if (!_layout || typeof _layout !== 'object') { console.log('Invalid layout?') @@ -504,9 +362,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { if (hasInboxRows) { s.inboxRetriedOnCurrentEmpty = false } - if (!inboxHasLoaded) { - hydrateInboxLayout(layout) - } } catch (e) { logger.info('failed to JSON parse inbox layout: ' + e) } @@ -522,8 +377,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { }) }, updatedGregor: items => { - syncGregorExplodingModes(items) - set(s => { const blockButtons = items.some(i => i.item.category.startsWith(blockButtonsGregorPrefix)) if (blockButtons || s.blockButtonsMap.size > 0) { diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 2a2814b422c2..ec569ec894a9 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -521,6 +521,38 @@ export const metasReceived = ( }) } +const updateInboxParticipants = (inboxUIItems: ReadonlyArray) => { + inboxUIItems.forEach(inboxUIItem => { + const participantInfo: T.Chat.ParticipantInfo = Common.uiParticipantsToParticipantInfo( + inboxUIItem.participants ?? [] + ) + if (participantInfo.all.length > 0) { + getConvoState(T.Chat.stringToConversationIDKey(inboxUIItem.convID)).dispatch.setParticipants(participantInfo) + } + }) +} + +const updateInboxUserInfo = (inboxUIItems: ReadonlyArray) => { + const usernameToFullname = inboxUIItems.reduce<{[username: string]: string}>((map, inboxUIItem) => { + inboxUIItem.participants?.forEach(part => { + if (part.fullName) { + map[part.assertion] = part.fullName + } + }) + return map + }, {}) + const usernames = Object.keys(usernameToFullname) + if (usernames.length === 0) { + return + } + useUsersState.getState().dispatch.updates( + usernames.map(name => ({ + info: {fullname: usernameToFullname[name]}, + name, + })) + ) +} + export const maybeChangeSelectedConversation = (inboxLayout?: T.RPCChat.UIInboxLayout) => { const newConvID = inboxLayout?.reselectInfo?.newConvID const oldConvID = inboxLayout?.reselectInfo?.oldConvID @@ -603,13 +635,8 @@ export const hydrateInboxConversations = (inboxUIItems: ReadonlyArray 0) { - getConvoState(T.Chat.stringToConversationIDKey(inboxUIItem.convID)).dispatch.setParticipants(participantInfo) - } }) + updateInboxParticipants(inboxUIItems) if (metas.length > 0) { metasReceived(metas) } @@ -796,6 +823,89 @@ export const ensureWidgetMetas = ( unboxRows(missing, true) } +export const onIncomingInboxUIItem = (inboxUIItem?: T.RPCChat.InboxUIItem) => { + if (!inboxUIItem) { + return + } + updateInboxUserInfo([inboxUIItem]) + hydrateInboxConversations([inboxUIItem]) +} + +export const onGetInboxConvsUnboxed = ( + action: EngineGen.EngineAction<'chat.1.chatUi.chatInboxConversation'> +) => { + const {convs} = action.payload.params + const inboxUIItems = JSON.parse(convs) as Array + updateInboxUserInfo(inboxUIItems) + hydrateInboxConversations(inboxUIItems) +} + +export const onGetInboxUnverifiedConvs = ( + action: EngineGen.EngineAction<'chat.1.chatUi.chatInboxUnverified'> +) => { + const {inbox} = action.payload.params + const result = JSON.parse(inbox) as T.RPCChat.UnverifiedInboxUIItems + const items: ReadonlyArray = result.items ?? [] + const metas = items.reduce>((arr, item) => { + const meta = Meta.unverifiedInboxUIItemToConversationMeta(item) + if (meta) { + arr.push(meta) + } + return arr + }, []) + metasReceived(metas) +} + +export const onInboxLayoutChanged = ( + inboxLayout: T.RPCChat.UIInboxLayout, + hadInboxLoaded: boolean +) => { + maybeChangeSelectedConversation(inboxLayout) + ensureWidgetMetas(inboxLayout.widgetList) + if (!hadInboxLoaded) { + hydrateInboxLayout(inboxLayout) + } +} + +export const onChatInboxSynced = async ( + action: EngineGen.EngineAction<'chat.1.NotifyChat.ChatInboxSynced'>, + refreshInbox: (reason: RefreshReason) => void | Promise +) => { + const {syncRes} = action.payload.params + + switch (syncRes.syncType) { + case T.RPCChat.SyncInboxResType.clear: + await refreshInbox('inboxSyncedClear') + clearConversationsForInboxSync() + return + case T.RPCChat.SyncInboxResType.current: + return + case T.RPCChat.SyncInboxResType.incremental: { + const items = syncRes.incremental.items || [] + const metas = items.reduce>((arr, item) => { + const meta = Meta.unverifiedInboxUIItemToConversationMeta(item.conv) + if (meta) { + arr.push(meta) + } + return arr + }, []) + loadSelectedConversationIfStale(metas) + const removals = syncRes.incremental.removals?.map(T.Chat.stringToConversationIDKey) + if (metas.length || removals?.length) { + metasReceived(metas, removals) + } + + unboxRows( + items.filter(item => item.shouldUnbox).map(item => T.Chat.stringToConversationIDKey(item.conv.convID)), + true + ) + return + } + default: + await refreshInbox('inboxSyncedUnknown') + } +} + export const syncGregorExplodingModes = ( items: ReadonlyArray<{md: T.RPCGen.Gregor1.Metadata; item: T.RPCGen.Gregor1.Item}> ) => { From 149716c05d216015f0d4fe55ccbee50ff3b2c350 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 16 Apr 2026 12:30:17 -0400 Subject: [PATCH 14/25] WIP --- plans/chat-split.md | 161 -------------------------------------------- 1 file changed, 161 deletions(-) delete mode 100644 plans/chat-split.md diff --git a/plans/chat-split.md b/plans/chat-split.md deleted file mode 100644 index a3640e3f196e..000000000000 --- a/plans/chat-split.md +++ /dev/null @@ -1,161 +0,0 @@ -# Chat / Convo Split Plan - -## Goal - -Make `shared/stores/chat.tsx` a true global chat store and remove its knowledge of per-conversation store internals. - -Target boundary: - -- `chat.tsx` owns global chat state only -- `convostate.tsx` owns per-conversation state only -- `chat.tsx` does not touch convo stores directly or indirectly -- no `convostate` re-export from `chat.tsx` -- route/provider helpers do not live in `chat.tsx` - -## Current State - -These cleanup steps are already done: - -- `blockButtons` dismissal moved to chat-global ownership -- `RefreshReason` moved to `shared/stores/chat-shared.tsx` -- `convostate` is no longer re-exported from `shared/stores/chat.tsx` -- convo hooks/helpers are imported from `@/stores/convostate` directly -- `makeChatScreen` moved out of `chat.tsx` into `shared/chat/make-chat-screen.tsx` -- aggregate reader helpers moved out of `chat.tsx`; callers now scan convo state directly -- `chat.resetState` now resets only chat-global state; convo registry teardown is composed by global store reset -- `inboxSyncedClear` convo clearing is no longer hidden inside `chat.inboxRefresh` -- route subscriptions now call convo-owned selection handling directly; `chat.tsx` no longer owns route selection -- `metasReceived` now applies convo meta updates from `convostate` -- first-layout inbox hydration moved out of `chat.updateInboxLayout` -- `ensureWidgetMetas`, meta queueing, and `unboxRows` now live in `convostate` -- conversation creation and team-building handoff now live in `convostate` -- convo-targeted engine notifications now route through `convostate.handleConvoEngineIncoming`; `chat.onEngineIncomingImpl` keeps only global branches -- init-time engine routing now sends convo-targeted notifications directly to `convostate`; `chat.tsx` is no longer the top-level engine entrypoint for convo-targeted branches -- service-driven convo reselect, stale selected-thread reload, inbox conversation hydration, and exploding-mode gregor sync now live in `convostate` -- badge / unread application and inbox-sync clear fanout now live in `convostate`; `chat.tsx` keeps only aggregate badge totals/versioning -- init-time badge routing now sends per-convo badge/unread fanout directly to `convostate`; `chat.dispatch.badgesUpdated` keeps only global badge counters -- inbox sync/layout routing now enters through init-time convo-owned handlers; `chat.tsx` keeps only global inbox layout state and refresh triggers -- exploding-mode gregor fanout now routes outside `chat.tsx`; `chat.tsx` no longer imports `convostate` - -Current branch result: - -- `chat.tsx` no longer reaches into convo stores directly with `getConvoState(...)` -- `chat.tsx` no longer iterates convo registries directly -- `chat.tsx` no longer imports `convostate` -- convo-targeted routing now lives outside `chat.tsx` - -The split goal is now met on this branch. - -## Non-Goals - -- Do not add a store coordinator module -- Do not keep a fake separation where `chat` still drives convo state through a registry/helper hop -- Do not silently change behavior during the split - -## Remaining `chat -> convo` Logic Buckets - -### 1. Engine Notification Fanout - -Status: - -- done for the engine-entrypoint portion; convo-targeted branches now route from `shared/constants/init/shared.tsx` straight into `convostate.handleConvoEngineIncoming` -- `chat.onEngineIncomingImpl` now keeps only the remaining global / inbox-layout related chat branches - -Most of `onEngineIncomingImpl` is a dispatcher into specific convo stores. - -Keep in `chat` only: - -- inbox layout -- global badge totals -- maybe mentions -- block buttons -- user reacjis -- global refresh triggers - -Move out of `chat`: - -- incoming messages -- typing -- reactions -- messages updated -- stale thread reload handling -- participant info -- coin flip status -- retention updates -- per-convo command/giphy UI notifications - -Desired end state: - -- convo-targeted notifications are handled by convo-owned entrypoints -- `chat.tsx` only handles truly global notifications -- `chat.tsx` no longer calls `handleConvoEngineIncoming(...)` - -### 2. Badge / Unread Ownership - -Status: - -- done for the ownership boundary; `convostate.syncBadgeState` owns per-convo badge/unread application, while init-time badge routing calls it directly -- `chat.dispatch.badgesUpdated` now keeps only the aggregate badge counters/versioning - -This was last because it was the riskiest ownership decision. - -Resolved state: - -- global badge totals live in `chat` -- per-convo badge/unread get updated from `convostate` - -Chosen end state: - -- per-convo badge ownership remains in convo state -- badge-state payload fanout is convo-owned instead of chat-owned - -### 3. Inbox Sync / Layout Orchestration - -Status: - -- done for the inbox/layout ownership boundary; init-time routing now sends inbox sync/layout convo work into `convostate` -- `chat.tsx` keeps only inbox layout state parsing/storage and global refresh triggers - -Desired end state: - -- inbox sync and layout events enter through a convo-owned entrypoint when they need convo work -- `chat.tsx` only keeps global inbox layout state and global refresh triggers - -### 4. Remaining Acceptance Gap - -Status: - -- none; the acceptance criteria below are now satisfied on this branch - -Reason: - -- `chat.tsx` no longer imports or dispatches convo-owned helpers -- remaining follow-up work, if any, is ordinary cleanup rather than split-boundary work - -## Recommended Order - -1. Done: move convo-targeted engine entrypoint ownership fully out of `chat.tsx` -2. Done: move badge-state fanout ownership fully out of `chat.tsx` -3. Done: move inbox sync/layout convo orchestration behind convo-owned entrypoints -4. Done: remove the last remaining `chat.tsx -> convostate` helper coupling - -Result: - -1. Split complete - -## Acceptance Criteria - -The split is done when: - -- `chat.tsx` contains only global chat state and global chat actions -- `chat.tsx` does not iterate convo stores -- `chat.tsx` does not call `getConvoState(...)` -- `chat.tsx` does not dispatch convo-owned work by calling `convostate` orchestration helpers -- convo-targeted routing is owned outside `chat.tsx` -- `chat.tsx` can be understood as a standalone global store file - -## Notes For Follow-Up Work - -- Prefer deleting logic over relocating it to another indirection layer -- For each bucket, identify call sites first, then decide the new owner -- Keep each bucket as a separate change so regressions are easier to review From a29b5d7796ace7cd59cc939f2793ded31e017cd5 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 16 Apr 2026 12:48:03 -0400 Subject: [PATCH 15/25] WIP --- shared/chat/blocking/invitation-to-block.tsx | 3 +- .../attachment-fullscreen/hooks.tsx | 3 +- .../conversation/info-panel/attachments.tsx | 4 +- .../chat/conversation/info-panel/members.tsx | 4 +- .../info-panel/settings/index.tsx | 3 +- .../messages/attachment/shared.tsx | 3 +- .../messages/cards/team-journey/container.tsx | 3 +- .../chat/conversation/messages/text/reply.tsx | 4 +- .../text/unfurl/unfurl-list/image/index.tsx | 4 +- .../conversation/messages/wrapper/wrapper.tsx | 3 +- shared/chat/conversation/pinned-message.tsx | 4 +- shared/chat/conversation/reply-preview.tsx | 4 +- .../markdown/maybe-mention/index.tsx | 3 +- .../markdown/maybe-mention/team.tsx | 3 +- shared/constants/chat/helpers.tsx | 105 +++++++++++++++++ shared/constants/chat/index.tsx | 1 + shared/stores/chat.tsx | 110 +----------------- shared/stores/tests/chat.test.ts | 12 +- shared/teams/add-members-wizard/confirm.tsx | 3 +- shared/teams/channel/index.tsx | 4 +- shared/teams/common/selection-popup.tsx | 3 +- shared/teams/team/member/index.new.tsx | 3 +- shared/teams/team/rows/index.tsx | 3 +- shared/teams/team/rows/invite-row/request.tsx | 3 +- shared/teams/team/settings-tab/index.tsx | 3 +- shared/teams/team/tabs.tsx | 3 +- 26 files changed, 152 insertions(+), 149 deletions(-) create mode 100644 shared/constants/chat/helpers.tsx diff --git a/shared/chat/blocking/invitation-to-block.tsx b/shared/chat/blocking/invitation-to-block.tsx index ac6cce6fe772..5d2dacaac26b 100644 --- a/shared/chat/blocking/invitation-to-block.tsx +++ b/shared/chat/blocking/invitation-to-block.tsx @@ -1,4 +1,5 @@ import * as C from '@/constants' +import {isAssertion} from '@/constants/chat/helpers' import * as Chat from '@/stores/chat' import * as ConvoState from '@/stores/convostate' import * as Kb from '@/common-adapters' @@ -23,7 +24,7 @@ const BlockButtons = () => { } const adder = blockButtonInfo.adder const others = (team ? participantInfo.all : participantInfo.name).filter( - person => person !== currentUser && person !== adder && !Chat.isAssertion(person) + person => person !== currentUser && person !== adder && !isAssertion(person) ) const onViewProfile = () => navToProfile(adder) diff --git a/shared/chat/conversation/attachment-fullscreen/hooks.tsx b/shared/chat/conversation/attachment-fullscreen/hooks.tsx index 486340c1cb10..d7790dd38f90 100644 --- a/shared/chat/conversation/attachment-fullscreen/hooks.tsx +++ b/shared/chat/conversation/attachment-fullscreen/hooks.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import * as C from '@/constants' +import {clampImageSize} from '@/constants/chat/helpers' import * as Chat from '@/stores/chat' import * as ConvoState from '@/stores/convostate' import type * as T from '@/constants/types' @@ -42,7 +43,7 @@ export const useData = (initialOrdinal: T.Chat.Ordinal) => { const attachmentDownload = ConvoState.useChatContext(s => s.dispatch.attachmentDownload) const {downloadPath, fileURL: path, fullHeight, fullWidth, fileType} = message const {previewHeight, previewURL: previewPath, previewWidth, title, transferProgress} = message - const {height: clampedHeight, width: clampedWidth} = Chat.clampImageSize( + const {height: clampedHeight, width: clampedWidth} = clampImageSize( previewWidth, previewHeight, maxWidth, diff --git a/shared/chat/conversation/info-panel/attachments.tsx b/shared/chat/conversation/info-panel/attachments.tsx index fcde94137999..3042e9f05a6a 100644 --- a/shared/chat/conversation/info-panel/attachments.tsx +++ b/shared/chat/conversation/info-panel/attachments.tsx @@ -1,5 +1,5 @@ import * as C from '@/constants' -import * as Chat from '@/stores/chat' +import {zoomImage} from '@/constants/chat/helpers' import * as ConvoState from '@/stores/convostate' import * as Kb from '@/common-adapters' import type {StylesTextCrossPlatform} from '@/common-adapters/text.shared' @@ -582,7 +582,7 @@ export const useAttachmentSections = ( maxMediaThumbSize, width: thumb.width, }, - sizing: Chat.zoomImage(thumb.width, thumb.height, maxMediaThumbSize), + sizing: zoomImage(thumb.width, thumb.height, maxMediaThumbSize), thumb, })) const dataChunked = useFlexWrap ? [dataUnchunked] : chunk(dataUnchunked, rowSize) diff --git a/shared/chat/conversation/info-panel/members.tsx b/shared/chat/conversation/info-panel/members.tsx index f1a8710baf77..5d24907d0014 100644 --- a/shared/chat/conversation/info-panel/members.tsx +++ b/shared/chat/conversation/info-panel/members.tsx @@ -1,5 +1,5 @@ import * as C from '@/constants' -import * as Chat from '@/stores/chat' +import {getBotsAndParticipants} from '@/constants/chat/helpers' import * as ConvoState from '@/stores/convostate' import * as Teams from '@/stores/teams' import * as React from 'react' @@ -47,7 +47,7 @@ const MembersTab = (props: Props) => { const refreshParticipants = C.useRPC(T.RPCChat.localRefreshParticipantsRpcPromise) const participantInfo = ConvoState.useChatContext(s => s.participants) const participants = ConvoState.useChatContext( - C.useShallow(s => Chat.getBotsAndParticipants(s.meta, s.participants, teamMembers).participants) + C.useShallow(s => getBotsAndParticipants(s.meta, s.participants, teamMembers).participants) ) const [lastTeamName, setLastTeamName] = React.useState('') React.useEffect(() => { diff --git a/shared/chat/conversation/info-panel/settings/index.tsx b/shared/chat/conversation/info-panel/settings/index.tsx index 6bbb7bfdb700..dddc99e33694 100644 --- a/shared/chat/conversation/info-panel/settings/index.tsx +++ b/shared/chat/conversation/info-panel/settings/index.tsx @@ -1,4 +1,5 @@ import * as C from '@/constants' +import {isAssertion} from '@/constants/chat/helpers' import * as Chat from '@/stores/chat' import * as ConvoState from '@/stores/convostate' import * as Kb from '@/common-adapters' @@ -36,7 +37,7 @@ const SettingsPanel = (props: SettingsPanelProps) => { const teamMembers = Teams.useTeamsState(s => s.teamIDToMembers.get(teamID)) const participantInfo = ConvoState.useChatContext(s => s.participants) const membersForBlock = (teamMembers?.size ? [...teamMembers.keys()] : participantInfo.name).filter( - u => u !== username && !Chat.isAssertion(u) + u => u !== username && !isAssertion(u) ) const navigateAppend = ConvoState.useChatNavigateAppend() diff --git a/shared/chat/conversation/messages/attachment/shared.tsx b/shared/chat/conversation/messages/attachment/shared.tsx index f5e63866e92c..88121131ccaa 100644 --- a/shared/chat/conversation/messages/attachment/shared.tsx +++ b/shared/chat/conversation/messages/attachment/shared.tsx @@ -1,4 +1,5 @@ import * as C from '@/constants' +import {clampImageSize} from '@/constants/chat/helpers' import * as Chat from '@/stores/chat' import * as ConvoState from '@/stores/convostate' import * as Kb from '@/common-adapters' @@ -174,7 +175,7 @@ export const getAttachmentPreviewSize = ( ) => { const {fileURL, previewHeight, previewWidth} = message let {previewURL} = message - let {height, width} = Chat.clampImageSize(previewWidth, previewHeight, maxWidth, maxHeight) + let {height, width} = clampImageSize(previewWidth, previewHeight, maxWidth, maxHeight) // This is mostly a sanity check and also allows us to handle HEIC even though the go side doesn't // understand. if (useSquareFallback && (height === 0 || width === 0)) { diff --git a/shared/chat/conversation/messages/cards/team-journey/container.tsx b/shared/chat/conversation/messages/cards/team-journey/container.tsx index ad8adcdd867a..2e5842867319 100644 --- a/shared/chat/conversation/messages/cards/team-journey/container.tsx +++ b/shared/chat/conversation/messages/cards/team-journey/container.tsx @@ -1,4 +1,5 @@ import * as C from '@/constants' +import {isBigTeam as getIsBigTeam} from '@/constants/chat/helpers' import * as Chat from '@/stores/chat' import * as ConvoState from '@/stores/convostate' import * as T from '@/constants/types' @@ -20,7 +21,7 @@ const TeamJourneyConnected = (ownProps: OwnProps) => { const {cannotWrite, channelname, teamname, teamID} = conv const welcomeMessage = {display: '', raw: '', set: false} const canShowcase = Teams.useTeamsState(s => Teams.canShowcase(s, teamID)) - const isBigTeam = Chat.useChatState(s => Chat.isBigTeam(s, teamID)) + const isBigTeam = Chat.useChatState(s => getIsBigTeam(s.inboxLayout, teamID)) const navigateAppend = C.Router2.navigateAppend const _onAuthorClick = (teamID: T.Teams.TeamID) => navigateAppend({name: 'team', params: {teamID}}) const dismissJourneycard = ConvoState.useChatContext(s => s.dispatch.dismissJourneycard) diff --git a/shared/chat/conversation/messages/text/reply.tsx b/shared/chat/conversation/messages/text/reply.tsx index eab74216309b..350a5f27b13d 100644 --- a/shared/chat/conversation/messages/text/reply.tsx +++ b/shared/chat/conversation/messages/text/reply.tsx @@ -1,6 +1,6 @@ import * as Kb from '@/common-adapters' import * as React from 'react' -import * as Chat from '@/stores/chat' +import {zoomImage} from '@/constants/chat/helpers' import {useIsHighlighted} from '../ids-context' import type * as T from '@/constants/types' @@ -38,7 +38,7 @@ const ReplyImage = () => { if (!imageURL) return null const imageHeight = replyTo.previewHeight const imageWidth = replyTo.previewWidth - const sizing = imageWidth && imageHeight ? Chat.zoomImage(imageWidth, imageHeight, 80) : undefined + const sizing = imageWidth && imageHeight ? zoomImage(imageWidth, imageHeight, 80) : undefined return ( diff --git a/shared/chat/conversation/messages/text/unfurl/unfurl-list/image/index.tsx b/shared/chat/conversation/messages/text/unfurl/unfurl-list/image/index.tsx index fff9f4e7ccae..89a5573a6b2d 100644 --- a/shared/chat/conversation/messages/text/unfurl/unfurl-list/image/index.tsx +++ b/shared/chat/conversation/messages/text/unfurl/unfurl-list/image/index.tsx @@ -1,6 +1,6 @@ import type * as React from 'react' import * as Kb from '@/common-adapters/index' -import * as Chat from '@/stores/chat' +import {clampImageSize} from '@/constants/chat/helpers' import {maxWidth} from '@/chat/conversation/messages/attachment/shared' import {Video} from './video' import {openURL} from '@/util/misc' @@ -24,7 +24,7 @@ const UnfurlImage = (p: Props) => { linkURL && openURL(linkURL) } const maxSize = Math.min(maxWidth, 320) - (widthPadding || 0) - const {height, width} = Chat.clampImageSize(p.width, p.height, maxSize, 320) + const {height, width} = clampImageSize(p.width, p.height, maxSize, 320) return isVideo ? (

=> { - const isBig = Chat.useChatState(s => Chat.isBigTeam(s, teamID)) + const isBig = Chat.useChatState(s => isBigTeam(s.inboxLayout, teamID)) const channels = Teams.useTeamsState(s => s.channelInfo.get(teamID)) const canCreate = Teams.useTeamsState(s => Teams.getCanPerformByID(s, teamID).createChannel) diff --git a/shared/teams/team/rows/invite-row/request.tsx b/shared/teams/team/rows/invite-row/request.tsx index 4bf43b5da3df..a4324a9c05a4 100644 --- a/shared/teams/team/rows/invite-row/request.tsx +++ b/shared/teams/team/rows/invite-row/request.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import * as C from '@/constants' +import {isBigTeam} from '@/constants/chat/helpers' import * as Chat from '@/stores/chat' import * as Teams from '@/stores/teams' import type * as T from '@/constants/types' @@ -213,7 +214,7 @@ const Container = (ownProps: OwnProps) => { const {teamID, username, reset, fullName} = ownProps const {teamname} = Teams.useTeamsState(s => Teams.getTeamMeta(s, teamID)) const _notifLabel = Chat.useChatState(s => - Chat.isBigTeam(s, teamID) ? `Announce them in #general` : `Announce them in team chat` + isBigTeam(s.inboxLayout, teamID) ? `Announce them in #general` : `Announce them in team chat` ) const disabledReasonsForRolePicker = Teams.useTeamsState(s => Teams.getDisabledReasonsForRolePicker(s, teamID, username) diff --git a/shared/teams/team/settings-tab/index.tsx b/shared/teams/team/settings-tab/index.tsx index d0513bf3d879..43a3f27952c9 100644 --- a/shared/teams/team/settings-tab/index.tsx +++ b/shared/teams/team/settings-tab/index.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import * as C from '@/constants' +import {isBigTeam as getIsBigTeam} from '@/constants/chat/helpers' import * as Chat from '@/stores/chat' import * as Teams from '@/stores/teams' import type * as T from '@/constants/types' @@ -354,7 +355,7 @@ const Container = (ownProps: OwnProps) => { const settings = teamDetails.settings const canShowcase = teamMeta.allowPromote || teamMeta.role === 'admin' || teamMeta.role === 'owner' const ignoreAccessRequests = teamDetails.settings.tarsDisabled - const isBigTeam = Chat.useChatState(s => Chat.isBigTeam(s, teamID)) + const isBigTeam = Chat.useChatState(s => getIsBigTeam(s.inboxLayout, teamID)) const openTeam = settings.open const openTeamRole = teamDetails.settings.openJoinAs const teamname = teamMeta.teamname diff --git a/shared/teams/team/tabs.tsx b/shared/teams/team/tabs.tsx index 7144dc1995dc..1e968bb108f0 100644 --- a/shared/teams/team/tabs.tsx +++ b/shared/teams/team/tabs.tsx @@ -1,6 +1,7 @@ import type * as T from '@/constants/types' import * as Kb from '@/common-adapters' import * as C from '@/constants' +import {isBigTeam} from '@/constants/chat/helpers' import * as Chat from '@/stores/chat' import * as Teams from '@/stores/teams' import type {Tab as TabType} from '@/common-adapters/tabs' @@ -111,7 +112,7 @@ const Container = (ownProps: OwnProps) => { const {teamMeta, yourOperations} = teamsState const admin = yourOperations.manageMembers - const isBig = Chat.useChatState(s => Chat.isBigTeam(s, teamID)) + const isBig = Chat.useChatState(s => isBigTeam(s.inboxLayout, teamID)) const loading = C.Waiting.useAnyWaiting([ C.waitingKeyTeamsTeam(teamID), C.waitingKeyTeamsTeamTars(teamMeta.teamname), From 6d8bd25823a641900729fbf10d3c8075603dbc0f Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 16 Apr 2026 12:50:44 -0400 Subject: [PATCH 16/25] WIP --- .../messages/attachment/shared.tsx | 1 - shared/constants/init/shared.tsx | 10 +++-- shared/stores/chat.tsx | 4 +- shared/stores/convostate.tsx | 40 +++++++++---------- 4 files changed, 28 insertions(+), 27 deletions(-) diff --git a/shared/chat/conversation/messages/attachment/shared.tsx b/shared/chat/conversation/messages/attachment/shared.tsx index 88121131ccaa..317c20cd468b 100644 --- a/shared/chat/conversation/messages/attachment/shared.tsx +++ b/shared/chat/conversation/messages/attachment/shared.tsx @@ -1,6 +1,5 @@ import * as C from '@/constants' import {clampImageSize} from '@/constants/chat/helpers' -import * as Chat from '@/stores/chat' import * as ConvoState from '@/stores/convostate' import * as Kb from '@/common-adapters' import * as React from 'react' diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 5acfca850321..02599c8284f5 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -189,7 +189,9 @@ export const initSharedSubscriptions = () => { setConvoDefer({ chatInboxLayoutSmallTeamsFirstConvID: () => storeRegistry.getState('chat').inboxLayout?.smallTeams?.[0]?.convID, - chatInboxRefresh: reason => storeRegistry.getState('chat').dispatch.inboxRefresh(reason), + chatInboxRefresh: reason => { + ignorePromise(storeRegistry.getState('chat').dispatch.inboxRefresh(reason)) + }, chatMetasReceived: metas => convoMetasReceived(metas), }) _sharedUnsubs.push( @@ -231,7 +233,7 @@ export const initSharedSubscriptions = () => { // mounts behind a pushed conversation do not pay inbox startup cost. if (!isPhone && useCurrentUserState.getState().username) { const {inboxRefresh} = useChatState.getState().dispatch - inboxRefresh('bootstrap') + ignorePromise(inboxRefresh('bootstrap')) } } @@ -625,7 +627,9 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { break case 'chat.1.NotifyChat.ChatInboxSynced': useWaitingState.getState().dispatch.clear(S.waitingKeyChatInboxSyncStarted) - ignorePromise(onChatInboxSynced(action, reason => useChatState.getState().dispatch.inboxRefresh(reason))) + ignorePromise( + onChatInboxSynced(action, async reason => useChatState.getState().dispatch.inboxRefresh(reason)) + ) break case 'chat.1.chatUi.chatInboxLayout': { const {inboxHasLoaded, dispatch} = useChatState.getState() diff --git a/shared/stores/chat.tsx b/shared/stores/chat.tsx index 6c264e63fb84..0d885de18052 100644 --- a/shared/stores/chat.tsx +++ b/shared/stores/chat.tsx @@ -121,7 +121,7 @@ export const useChatState = Z.createZustand('chat', (set, get) => { get().dispatch.dismissBlockButtons(teamID) } }, - inboxRefresh: reason => requestInboxLayout(reason), + inboxRefresh: async reason => requestInboxLayout(reason), loadStaticConfig: () => { if (get().staticConfig) { return @@ -185,7 +185,7 @@ export const useChatState = Z.createZustand('chat', (set, get) => { break } case 'chat.1.NotifyChat.ChatInboxStale': - get().dispatch.inboxRefresh('inboxStale') + ignorePromise(get().dispatch.inboxRefresh('inboxStale')) break case 'keybase.1.NotifyBadges.badgeState': { const {badgeState} = action.payload.params diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index ec569ec894a9..22097e347412 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -682,20 +682,17 @@ const useConvoMetaQueueState = Z.createZustand('convo-meta- }, queueMetaToRequest: (ids: ReadonlyArray) => { const nextIDs = untrustedConversationIDKeys(ids) - let changed = false + const changed = nextIDs.some(k => !get().pending.has(k)) + if (!changed) { + logger.info('skipping meta queue run, queue unchanged') + return + } set(s => { const pending = new Set(s.pending) nextIDs.forEach(k => pending.add(k)) - changed = pending.size > s.pending.size - if (changed) { - s.pending = pending - } + s.pending = pending }) - if (changed) { - get().dispatch.queueMetaHandle() - } else { - logger.info('skipping meta queue run, queue unchanged') - } + get().dispatch.queueMetaHandle() }, resetState: () => { set(s => { @@ -711,6 +708,7 @@ const useConvoMetaQueueState = Z.createZustand('convo-meta- })) async function runMetaQueueWorker(generation: number) { + let shouldQueueNextRun = false try { while (true) { if (useConvoMetaQueueState.getState().generation !== generation) { @@ -747,19 +745,19 @@ async function runMetaQueueWorker(generation: number) { } } finally { const current = useConvoMetaQueueState.getState() - if (current.generation !== generation) { - return - } - useConvoMetaQueueState.setState(s => { - if (s.generation === generation) { - s.inFlight = false - } - }) - const next = useConvoMetaQueueState.getState() - if (next.generation === generation && next.pending.size > 0) { - next.dispatch.queueMetaHandle() + if (current.generation === generation) { + useConvoMetaQueueState.setState(s => { + if (s.generation === generation) { + s.inFlight = false + } + }) + const next = useConvoMetaQueueState.getState() + shouldQueueNextRun = next.generation === generation && next.pending.size > 0 } } + if (shouldQueueNextRun) { + useConvoMetaQueueState.getState().dispatch.queueMetaHandle() + } } export const unboxRows = (ids: ReadonlyArray, force?: boolean) => { From ddc09069ef7bdad0c1cfe4b46908b359f22e4e2a Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 16 Apr 2026 12:53:42 -0400 Subject: [PATCH 17/25] WIP --- shared/stores/convostate.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 22097e347412..602ad2e5c3c0 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -712,7 +712,7 @@ async function runMetaQueueWorker(generation: number) { try { while (true) { if (useConvoMetaQueueState.getState().generation !== generation) { - return + break } const maxToUnboxAtATime = 10 @@ -727,7 +727,7 @@ async function runMetaQueueWorker(generation: number) { }) if (!maybeUnbox.length) { - return + break } const conversationIDKeys = untrustedConversationIDKeys(maybeUnbox) @@ -737,7 +737,7 @@ async function runMetaQueueWorker(generation: number) { const current = useConvoMetaQueueState.getState() if (current.generation !== generation || current.pending.size === 0) { - return + break } if (conversationIDKeys.length) { await timeoutPromise(100) From 721ee95cf4dcb14ab1bd89a876325874f42c8435 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 16 Apr 2026 12:55:10 -0400 Subject: [PATCH 18/25] WIP --- shared/chat/conversation/bot/install.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/chat/conversation/bot/install.tsx b/shared/chat/conversation/bot/install.tsx index 2eebfe48e457..aba316cac0d0 100644 --- a/shared/chat/conversation/bot/install.tsx +++ b/shared/chat/conversation/bot/install.tsx @@ -97,7 +97,7 @@ export const useBotConversationIDKey = (inConvIDKey?: T.Chat.ConversationIDKey, if (!meta) { return } - ConvoState.metasReceived([meta]) + ConvoState.metasReceived([meta]) setConversationIDKey(meta.conversationIDKey) }, () => {} From b9074a9e7d1f176e6ea698e2650dda7d7c50cc59 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 16 Apr 2026 12:59:34 -0400 Subject: [PATCH 19/25] WIP --- shared/constants/chat/index.tsx | 1 - 1 file changed, 1 deletion(-) delete mode 100644 shared/constants/chat/index.tsx diff --git a/shared/constants/chat/index.tsx b/shared/constants/chat/index.tsx deleted file mode 100644 index b2a4c7512849..000000000000 --- a/shared/constants/chat/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './helpers' From 41f4e30d939ebbf5877185c258e1a2d3d1b90801 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 16 Apr 2026 13:25:09 -0400 Subject: [PATCH 20/25] WIP --- shared/chat/conversation/list-area/hooks.tsx | 9 +++-- .../conversation/list-area/index.desktop.tsx | 34 +++++++++++++------ 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/shared/chat/conversation/list-area/hooks.tsx b/shared/chat/conversation/list-area/hooks.tsx index dc9be90b2997..7cf8aad90070 100644 --- a/shared/chat/conversation/list-area/hooks.tsx +++ b/shared/chat/conversation/list-area/hooks.tsx @@ -20,7 +20,11 @@ export const useActions = (p: {conversationIDKey: T.Chat.ConversationIDKey}) => return {markInitiallyLoadedThreadAsRead} } -export const useJumpToRecent = (scrollToBottom: () => void, numOrdinals: number) => { +export const useJumpToRecent = ( + scrollToBottom: () => void, + numOrdinals: number, + showOverride?: boolean +) => { const data = ConvoState.useChatContext( C.useShallow(s => { const {loaded, moreToLoadForward} = s @@ -36,5 +40,6 @@ export const useJumpToRecent = (scrollToBottom: () => void, numOrdinals: number) toggleThreadSearch(true) } - return loaded && moreToLoadForward && numOrdinals > 0 && + const shouldShow = showOverride ?? moreToLoadForward + return loaded && shouldShow && numOrdinals > 0 && } diff --git a/shared/chat/conversation/list-area/index.desktop.tsx b/shared/chat/conversation/list-area/index.desktop.tsx index 1a9ca47bfaf1..34fb8b45b618 100644 --- a/shared/chat/conversation/list-area/index.desktop.tsx +++ b/shared/chat/conversation/list-area/index.desktop.tsx @@ -58,6 +58,7 @@ const useScrolling = (p: { const isScrollingRef = React.useRef(false) const ignoreOnScrollRef = React.useRef(false) const lockedToBottomRef = React.useRef(true) + const [lockedToBottom, setLockedToBottom] = React.useState(true) // so we can turn pointer events on / off const pointerWrapperRef = React.useRef(null) const setPointerWrapperRef = (r: HTMLDivElement | null) => { @@ -71,6 +72,13 @@ const useScrolling = (p: { return lockedToBottomRef.current }) + const updateLockedToBottom = (next: boolean) => { + if (lockedToBottomRef.current !== next) { + lockedToBottomRef.current = next + setLockedToBottom(next) + } + } + const adjustScrollAndIgnoreOnScroll = (fn: () => void) => { ignoreOnScrollRef.current = true fn() @@ -92,7 +100,7 @@ const useScrolling = (p: { }) const [scrollToBottomSync] = React.useState(() => () => { - lockedToBottomRef.current = true + updateLockedToBottom(true) const list = listRef.current if (list) { adjustScrollAndIgnoreOnScroll(() => { @@ -140,7 +148,7 @@ const useScrolling = (p: { }) const [scrollUp] = React.useState(() => () => { - lockedToBottomRef.current = false + updateLockedToBottom(false) const list = listRef.current list && adjustScrollAndIgnoreOnScroll(() => { @@ -170,8 +178,7 @@ const useScrolling = (p: { const list = listRef.current // are we locked on the bottom? only lock if we have latest messages if (list && !centeredOrdinal && containsLatestMessageRef.current) { - lockedToBottomRef.current = - list.scrollHeight - list.clientHeight - list.scrollTop < listEdgeSlopBottom + updateLockedToBottom(list.scrollHeight - list.clientHeight - list.scrollTop < listEdgeSlopBottom) } } }, 200) @@ -214,7 +221,7 @@ const useScrolling = (p: { return } // quickly set to false to assume we're not locked. if we are the throttled one will set it to true - lockedToBottomRef.current = false + updateLockedToBottom(false) checkForLoadMoreThrottled() onScrollThrottledRef.current() }) @@ -277,7 +284,7 @@ const useScrolling = (p: { const list = listRef.current // no items? don't be locked if (!ordinalsLength) { - lockedToBottomRef.current = false + updateLockedToBottom(false) return } @@ -321,10 +328,10 @@ const useScrolling = (p: { if (!wasLoaded || !loaded || !changed) return if (centeredOrdinal) { - lockedToBottomRef.current = false + updateLockedToBottom(false) scrollToCentered() } else if (containsLatestMessage) { - lockedToBottomRef.current = true + updateLockedToBottom(true) scrollToBottom() } }, [centeredOrdinal, loaded, containsLatestMessage, scrollToCentered, scrollToBottom]) @@ -357,7 +364,7 @@ const useScrolling = (p: { } }, [editingOrdinal, messageOrdinals, listRef]) - return {didFirstLoad, isLockedToBottom, scrollToBottom, setListRef, setPointerWrapperRef} + return {didFirstLoad, isLockedToBottom, lockedToBottom, scrollToBottom, setListRef, setPointerWrapperRef} } const useItems = (p: { @@ -494,7 +501,8 @@ const ThreadWrapper = function ThreadWrapper() { const _setListRef = (r: HTMLDivElement | null) => { listRef.current = r } - const {isLockedToBottom, scrollToBottom, setListRef, didFirstLoad, setPointerWrapperRef} = useScrolling({ + const {isLockedToBottom, lockedToBottom, scrollToBottom, setListRef, didFirstLoad, setPointerWrapperRef} = + useScrolling({ centeredOrdinal, containsLatestMessage, listRef, @@ -503,7 +511,11 @@ const ThreadWrapper = function ThreadWrapper() { setListRef: _setListRef, }) - const jumpToRecent = Hooks.useJumpToRecent(scrollToBottom, messageOrdinals.length) + const jumpToRecent = Hooks.useJumpToRecent( + scrollToBottom, + messageOrdinals.length, + !lockedToBottom || !containsLatestMessage + ) const onCopyCapture = (e: React.BaseSyntheticEvent) => { // Copy text only, not HTML/styling. We use virtualText on texts to make uncopyable text e.preventDefault() From 0b6d4aef3d6b443970d0eba847be92072f435cec Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 16 Apr 2026 14:22:02 -0400 Subject: [PATCH 21/25] WIP --- shared/chat/inbox/row/teams-divider-container.tsx | 14 +++++--------- shared/chat/inbox/use-inbox-state.tsx | 8 ++++---- shared/stores/inbox-rows.tsx | 3 +++ 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/shared/chat/inbox/row/teams-divider-container.tsx b/shared/chat/inbox/row/teams-divider-container.tsx index a8d2067f9cbe..ae13558db001 100644 --- a/shared/chat/inbox/row/teams-divider-container.tsx +++ b/shared/chat/inbox/row/teams-divider-container.tsx @@ -1,8 +1,8 @@ -import * as C from '@/constants' import * as Chat from '@/stores/chat' import * as ConvoState from '@/stores/convostate' import * as React from 'react' import type {ChatInboxRowItem} from '../rowitem' +import {useInboxRowsState} from '@/stores/inbox-rows' import TeamsDivider from './teams-divider' type Props = Omit, 'badgeCount'> & { @@ -11,12 +11,8 @@ type Props = Omit, 'badgeCount'> & { const TeamsDividerContainer = React.memo(function TeamsDividerContainer(props: Props) { const {rows, ...rest} = props - const {badgeStateVersion, smallTeamBadgeCount} = Chat.useChatState( - C.useShallow(s => ({ - badgeStateVersion: s.badgeStateVersion, - smallTeamBadgeCount: s.smallTeamBadgeCount, - })) - ) + const smallTeamBadgeCount = Chat.useChatState(s => s.smallTeamBadgeCount) + const inboxRowsVersion = useInboxRowsState(s => s.version) const visibleSmallConvIDs = React.useMemo(() => { const ids: Array = [] @@ -29,13 +25,13 @@ const TeamsDividerContainer = React.memo(function TeamsDividerContainer(props: P }, [rows]) const visibleBadges = React.useMemo(() => { - void badgeStateVersion // we need to trigger on this also + void inboxRowsVersion let total = 0 for (const conversationIDKey of visibleSmallConvIDs) { total += ConvoState.getConvoState(conversationIDKey).badge } return total - }, [badgeStateVersion, visibleSmallConvIDs]) + }, [inboxRowsVersion, visibleSmallConvIDs]) const hiddenSmallBadgeCount = Math.max(0, smallTeamBadgeCount - visibleBadges) return diff --git a/shared/chat/inbox/use-inbox-state.tsx b/shared/chat/inbox/use-inbox-state.tsx index fc6f854158aa..fc85271b4cbf 100644 --- a/shared/chat/inbox/use-inbox-state.tsx +++ b/shared/chat/inbox/use-inbox-state.tsx @@ -5,6 +5,7 @@ import * as React from 'react' import * as T from '@/constants/types' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' +import {useInboxRowsState} from '@/stores/inbox-rows' import {useIsFocused} from '@react-navigation/core' import {buildInboxRows} from './rows' @@ -16,7 +17,6 @@ export function useInboxState(conversationIDKey?: string, isSearching = false) { const chatState = Chat.useChatState( C.useShallow(s => ({ - badgeStateVersion: s.badgeStateVersion, inboxHasLoaded: s.inboxHasLoaded, inboxLayout: s.inboxLayout, inboxRefresh: s.dispatch.inboxRefresh, @@ -25,13 +25,13 @@ export function useInboxState(conversationIDKey?: string, isSearching = false) { })) ) const { - badgeStateVersion, inboxHasLoaded, inboxLayout, inboxRefresh, inboxRetriedOnCurrentEmpty, setInboxRetriedOnCurrentEmpty, } = chatState + const inboxRowsVersion = useInboxRowsState(s => s.version) const [inboxNumSmallRows, setInboxNumSmallRowsState] = React.useState(5) const [smallTeamsExpanded, setSmallTeamsExpanded] = React.useState(false) const inboxNumSmallRowsLoadVersionRef = React.useRef(0) @@ -178,7 +178,7 @@ export function useInboxState(conversationIDKey?: string, isSearching = false) { }, [inboxRows]) const unreadIndices = React.useMemo(() => { - void badgeStateVersion + void inboxRowsVersion const next: Map = new Map() bigConvIds.forEach((conversationIDKey, idx) => { if (!conversationIDKey) { @@ -190,7 +190,7 @@ export function useInboxState(conversationIDKey?: string, isSearching = false) { } }) return next - }, [badgeStateVersion, bigConvIds]) + }, [bigConvIds, inboxRowsVersion]) let unreadTotal = 0 unreadIndices.forEach(count => { diff --git a/shared/stores/inbox-rows.tsx b/shared/stores/inbox-rows.tsx index 186501c75510..9bdd6da3121b 100644 --- a/shared/stores/inbox-rows.tsx +++ b/shared/stores/inbox-rows.tsx @@ -49,6 +49,7 @@ const defaultInboxRowSmall: InboxRowSmall = { type State = T.Immutable<{ rowsBig: Map rowsSmall: Map + version: number dispatch: { resetState: () => void } @@ -58,6 +59,7 @@ export const useInboxRowsState = Z.createZustand('inboxRows', () => ({ dispatch: {resetState: Z.defaultReset}, rowsBig: new Map(), rowsSmall: new Map(), + version: 0, })) // Batched update queue @@ -102,6 +104,7 @@ export const flushInboxRowUpdates = () => { const {getConvoState} = require('./convostate') as {getConvoState: typeof GetConvoStateT} useInboxRowsState.setState(s => { + s.version += 1 for (const id of ids) { const cs = getConvoState(id) const m = cs.meta From 2e41983b1285fe22833734d52b7bffee2d6ddf5e Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 16 Apr 2026 14:30:42 -0400 Subject: [PATCH 22/25] WIP --- .../inbox/row/teams-divider-container.tsx | 19 +++-- shared/chat/inbox/use-inbox-state.tsx | 80 ++++++++++--------- .../renderer/remote-event-handler.desktop.tsx | 2 +- shared/menubar/remote-proxy.desktop.tsx | 4 +- shared/stores/inbox-rows.tsx | 44 +++++++--- 5 files changed, 88 insertions(+), 61 deletions(-) diff --git a/shared/chat/inbox/row/teams-divider-container.tsx b/shared/chat/inbox/row/teams-divider-container.tsx index ae13558db001..6341c90b4ee4 100644 --- a/shared/chat/inbox/row/teams-divider-container.tsx +++ b/shared/chat/inbox/row/teams-divider-container.tsx @@ -1,5 +1,4 @@ import * as Chat from '@/stores/chat' -import * as ConvoState from '@/stores/convostate' import * as React from 'react' import type {ChatInboxRowItem} from '../rowitem' import {useInboxRowsState} from '@/stores/inbox-rows' @@ -12,7 +11,6 @@ type Props = Omit, 'badgeCount'> & { const TeamsDividerContainer = React.memo(function TeamsDividerContainer(props: Props) { const {rows, ...rest} = props const smallTeamBadgeCount = Chat.useChatState(s => s.smallTeamBadgeCount) - const inboxRowsVersion = useInboxRowsState(s => s.version) const visibleSmallConvIDs = React.useMemo(() => { const ids: Array = [] @@ -24,14 +22,15 @@ const TeamsDividerContainer = React.memo(function TeamsDividerContainer(props: P return ids }, [rows]) - const visibleBadges = React.useMemo(() => { - void inboxRowsVersion - let total = 0 - for (const conversationIDKey of visibleSmallConvIDs) { - total += ConvoState.getConvoState(conversationIDKey).badge - } - return total - }, [inboxRowsVersion, visibleSmallConvIDs]) + const visibleBadges = useInboxRowsState( + React.useCallback(s => { + let total = 0 + for (const conversationIDKey of visibleSmallConvIDs) { + total += s.rowsSmall.get(conversationIDKey)?.badgeCount ?? 0 + } + return total + }, [visibleSmallConvIDs]) + ) const hiddenSmallBadgeCount = Math.max(0, smallTeamBadgeCount - visibleBadges) return diff --git a/shared/chat/inbox/use-inbox-state.tsx b/shared/chat/inbox/use-inbox-state.tsx index fc85271b4cbf..9a2443bcb1bc 100644 --- a/shared/chat/inbox/use-inbox-state.tsx +++ b/shared/chat/inbox/use-inbox-state.tsx @@ -31,7 +31,6 @@ export function useInboxState(conversationIDKey?: string, isSearching = false) { inboxRetriedOnCurrentEmpty, setInboxRetriedOnCurrentEmpty, } = chatState - const inboxRowsVersion = useInboxRowsState(s => s.version) const [inboxNumSmallRows, setInboxNumSmallRowsState] = React.useState(5) const [smallTeamsExpanded, setSmallTeamsExpanded] = React.useState(false) const inboxNumSmallRowsLoadVersionRef = React.useRef(0) @@ -96,13 +95,13 @@ export function useInboxState(conversationIDKey?: string, isSearching = false) { ConvoState.getConvoState(Chat.getSelectedConversation()).dispatch.tabSelected() } if (!C.isPhone && !inboxHasLoaded) { - inboxRefresh('componentNeverLoaded') + C.ignorePromise(inboxRefresh('componentNeverLoaded')) } }) C.Router2.useSafeFocusEffect(() => { if (!inboxHasLoaded) { - inboxRefresh('componentNeverLoaded') + C.ignorePromise(inboxRefresh('componentNeverLoaded')) } }) @@ -110,7 +109,7 @@ export function useInboxState(conversationIDKey?: string, isSearching = false) { const ready = loggedIn && !!username const shouldRetry = !inboxHasLoaded && ready && (!C.isMobile || isFocused) if (shouldRetry) { - inboxRefresh('componentNeverLoaded') + C.ignorePromise(inboxRefresh('componentNeverLoaded')) } }, [inboxHasLoaded, inboxRefresh, isFocused, loggedIn, username]) @@ -124,27 +123,29 @@ export function useInboxState(conversationIDKey?: string, isSearching = false) { } const loadVersion = inboxNumSmallRowsLoadVersionRef.current + 1 inboxNumSmallRowsLoadVersionRef.current = loadVersion - loadInboxNumSmallRows( - [{path: 'ui.inboxSmallRows'}], - rows => { - if ( - inboxNumSmallRowsLoadVersionRef.current !== loadVersion || - inboxNumSmallRowsUserChangedRef.current - ) { - return - } - inboxNumSmallRowsLoadedRef.current = true - const count = rows.i ?? -1 - if (count > 0) { - setInboxNumSmallRowsState(count) - } - }, - () => { - if (inboxNumSmallRowsLoadVersionRef.current !== loadVersion) { - return + C.ignorePromise( + loadInboxNumSmallRows( + [{path: 'ui.inboxSmallRows'}], + rows => { + if ( + inboxNumSmallRowsLoadVersionRef.current !== loadVersion || + inboxNumSmallRowsUserChangedRef.current + ) { + return + } + inboxNumSmallRowsLoadedRef.current = true + const count = rows.i ?? -1 + if (count > 0) { + setInboxNumSmallRowsState(count) + } + }, + () => { + if (inboxNumSmallRowsLoadVersionRef.current !== loadVersion) { + return + } + inboxNumSmallRowsLoadedRef.current = true } - inboxNumSmallRowsLoadedRef.current = true - } + ) ) return () => { if (inboxNumSmallRowsLoadVersionRef.current === loadVersion) { @@ -159,7 +160,7 @@ export function useInboxState(conversationIDKey?: string, isSearching = false) { return } setInboxRetriedOnCurrentEmpty(true) - inboxRefresh('inboxSyncedCurrentButEmpty') + C.ignorePromise(inboxRefresh('inboxSyncedCurrentButEmpty')) }, [ inboxHasLoaded, inboxRefresh, @@ -177,20 +178,21 @@ export function useInboxState(conversationIDKey?: string, isSearching = false) { return inboxRows.map(r => (r.type === 'big' ? r.conversationIDKey : '')) }, [inboxRows]) - const unreadIndices = React.useMemo(() => { - void inboxRowsVersion - const next: Map = new Map() - bigConvIds.forEach((conversationIDKey, idx) => { - if (!conversationIDKey) { - return - } - const badge = ConvoState.getConvoState(conversationIDKey).badge - if (badge > 0) { - next.set(idx, badge) - } - }) - return next - }, [bigConvIds, inboxRowsVersion]) + const unreadIndices = useInboxRowsState( + React.useCallback(s => { + const next: Map = new Map() + bigConvIds.forEach((conversationIDKey, idx) => { + if (!conversationIDKey) { + return + } + const badge = s.rowsBig.get(conversationIDKey)?.badgeCount ?? 0 + if (badge > 0) { + next.set(idx, badge) + } + }) + return next + }, [bigConvIds]) + ) let unreadTotal = 0 unreadIndices.forEach(count => { diff --git a/shared/desktop/renderer/remote-event-handler.desktop.tsx b/shared/desktop/renderer/remote-event-handler.desktop.tsx index 7e745d515b2a..f0ee8825c1e2 100644 --- a/shared/desktop/renderer/remote-event-handler.desktop.tsx +++ b/shared/desktop/renderer/remote-event-handler.desktop.tsx @@ -73,7 +73,7 @@ export const eventFromRemoteWindows = (action: RemoteGen.Actions) => { break } case RemoteGen.inboxRefresh: { - storeRegistry.getState('chat').dispatch.inboxRefresh('widgetRefresh') + ignorePromise(storeRegistry.getState('chat').dispatch.inboxRefresh('widgetRefresh')) break } case RemoteGen.engineConnection: { diff --git a/shared/menubar/remote-proxy.desktop.tsx b/shared/menubar/remote-proxy.desktop.tsx index b44e2e4668f3..4f78f8bcf75c 100644 --- a/shared/menubar/remote-proxy.desktop.tsx +++ b/shared/menubar/remote-proxy.desktop.tsx @@ -181,11 +181,11 @@ function useEnsureWidgetData( loggedIn: boolean, inboxHasLoaded: boolean, widgetList: ReadonlyArray<{convID: T.Chat.ConversationIDKey}> | undefined, - inboxRefresh: (reason: Chat.RefreshReason) => void + inboxRefresh: (reason: Chat.RefreshReason) => Promise ) { React.useEffect(() => { if (loggedIn && inboxHasLoaded && !widgetList) { - inboxRefresh('widgetRefresh') + C.ignorePromise(inboxRefresh('widgetRefresh')) } }, [loggedIn, inboxHasLoaded, widgetList, inboxRefresh]) diff --git a/shared/stores/inbox-rows.tsx b/shared/stores/inbox-rows.tsx index 9bdd6da3121b..dc5e508735c7 100644 --- a/shared/stores/inbox-rows.tsx +++ b/shared/stores/inbox-rows.tsx @@ -6,6 +6,7 @@ import {useCurrentUserState} from './current-user' import {shallowEqual} from '@/constants/utils' export type InboxRowBig = { + badgeCount: number channelname: string hasBadge: boolean hasDraft: boolean @@ -13,9 +14,11 @@ export type InboxRowBig = { isError: boolean isMuted: boolean snippetDecoration: number + unreadCount: number } export type InboxRowSmall = { + badgeCount: number draft: string hasBadge: boolean hasResetUsers: boolean @@ -30,26 +33,47 @@ export type InboxRowSmall = { teamDisplayName: string timestamp: number typingSnippet: string + unreadCount: number youAreReset: boolean youNeedToRekey: boolean } const defaultInboxRowBig = { - channelname: '', hasBadge: false, hasDraft: false, hasUnread: false, - isError: false, isMuted: false, snippetDecoration: 0, + badgeCount: 0, + channelname: '', + hasBadge: false, + hasDraft: false, + hasUnread: false, + isError: false, + isMuted: false, + snippetDecoration: 0, + unreadCount: 0, } satisfies InboxRowBig const defaultInboxRowSmall: InboxRowSmall = { - draft: '', hasBadge: false, hasResetUsers: false, hasUnread: false, - isDecryptingSnippet: true, isLocked: false, isMuted: false, participantNeedToRekey: false, - participants: [], snippet: '', snippetDecoration: T.RPCChat.SnippetDecoration.none, - teamDisplayName: '', timestamp: 0, typingSnippet: '', youAreReset: false, youNeedToRekey: false, + badgeCount: 0, + draft: '', + hasBadge: false, + hasResetUsers: false, + hasUnread: false, + isDecryptingSnippet: true, + isLocked: false, + isMuted: false, + participantNeedToRekey: false, + participants: [], + snippet: '', + snippetDecoration: T.RPCChat.SnippetDecoration.none, + teamDisplayName: '', + timestamp: 0, + typingSnippet: '', + unreadCount: 0, + youAreReset: false, + youNeedToRekey: false, } type State = T.Immutable<{ rowsBig: Map rowsSmall: Map - version: number dispatch: { resetState: () => void } @@ -59,7 +83,6 @@ export const useInboxRowsState = Z.createZustand('inboxRows', () => ({ dispatch: {resetState: Z.defaultReset}, rowsBig: new Map(), rowsSmall: new Map(), - version: 0, })) // Batched update queue @@ -104,7 +127,6 @@ export const flushInboxRowUpdates = () => { const {getConvoState} = require('./convostate') as {getConvoState: typeof GetConvoStateT} useInboxRowsState.setState(s => { - s.version += 1 for (const id of ids) { const cs = getConvoState(id) const m = cs.meta @@ -114,6 +136,7 @@ export const flushInboxRowUpdates = () => { s.rowsBig.set(id, {...defaultInboxRowBig}) } const big = s.rowsBig.get(id)! + big.badgeCount = cs.badge big.channelname = m.channelname big.hasBadge = cs.badge > 0 big.hasDraft = !!m.draft @@ -121,12 +144,14 @@ export const flushInboxRowUpdates = () => { big.isError = m.trustedState === 'error' big.isMuted = m.isMuted big.snippetDecoration = bigSnippetDecoration(m.snippetDecoration) + big.unreadCount = cs.unread // Small — ensure entry exists if (!s.rowsSmall.has(id)) { s.rowsSmall.set(id, {...defaultInboxRowSmall, participants: [] as string[]}) } const small = s.rowsSmall.get(id)! + small.badgeCount = cs.badge const snippet = m.snippetDecorated ?? '' small.draft = m.draft || '' small.hasBadge = cs.badge > 0 @@ -147,6 +172,7 @@ export const flushInboxRowUpdates = () => { small.teamDisplayName = m.teamname ? m.teamname.split('#')[0] ?? '' : '' small.timestamp = m.timestamp || 0 small.typingSnippet = buildTypingSnippet(cs.typing) + small.unreadCount = cs.unread small.youAreReset = m.membershipType === 'youAreReset' small.youNeedToRekey = m.rekeyers.has(you) } From d955f0316fa6672a999fbe5d091c0507edf38a83 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 16 Apr 2026 14:33:12 -0400 Subject: [PATCH 23/25] WIP --- shared/chat/inbox/use-inbox-state.tsx | 42 +++++++++++++-------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/shared/chat/inbox/use-inbox-state.tsx b/shared/chat/inbox/use-inbox-state.tsx index 9a2443bcb1bc..08c426a56eea 100644 --- a/shared/chat/inbox/use-inbox-state.tsx +++ b/shared/chat/inbox/use-inbox-state.tsx @@ -123,29 +123,27 @@ export function useInboxState(conversationIDKey?: string, isSearching = false) { } const loadVersion = inboxNumSmallRowsLoadVersionRef.current + 1 inboxNumSmallRowsLoadVersionRef.current = loadVersion - C.ignorePromise( - loadInboxNumSmallRows( - [{path: 'ui.inboxSmallRows'}], - rows => { - if ( - inboxNumSmallRowsLoadVersionRef.current !== loadVersion || - inboxNumSmallRowsUserChangedRef.current - ) { - return - } - inboxNumSmallRowsLoadedRef.current = true - const count = rows.i ?? -1 - if (count > 0) { - setInboxNumSmallRowsState(count) - } - }, - () => { - if (inboxNumSmallRowsLoadVersionRef.current !== loadVersion) { - return - } - inboxNumSmallRowsLoadedRef.current = true + loadInboxNumSmallRows( + [{path: 'ui.inboxSmallRows'}], + rows => { + if ( + inboxNumSmallRowsLoadVersionRef.current !== loadVersion || + inboxNumSmallRowsUserChangedRef.current + ) { + return + } + inboxNumSmallRowsLoadedRef.current = true + const count = rows.i ?? -1 + if (count > 0) { + setInboxNumSmallRowsState(count) } - ) + }, + () => { + if (inboxNumSmallRowsLoadVersionRef.current !== loadVersion) { + return + } + inboxNumSmallRowsLoadedRef.current = true + } ) return () => { if (inboxNumSmallRowsLoadVersionRef.current === loadVersion) { From 6d29d7026c66c7a48cd81a7cb25d462ba57e581e Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 16 Apr 2026 14:35:19 -0400 Subject: [PATCH 24/25] WIP --- shared/chat/inbox/use-inbox-state.tsx | 30 ++++++++++++++------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/shared/chat/inbox/use-inbox-state.tsx b/shared/chat/inbox/use-inbox-state.tsx index 08c426a56eea..7012594bd3ee 100644 --- a/shared/chat/inbox/use-inbox-state.tsx +++ b/shared/chat/inbox/use-inbox-state.tsx @@ -176,22 +176,24 @@ export function useInboxState(conversationIDKey?: string, isSearching = false) { return inboxRows.map(r => (r.type === 'big' ? r.conversationIDKey : '')) }, [inboxRows]) - const unreadIndices = useInboxRowsState( - React.useCallback(s => { - const next: Map = new Map() - bigConvIds.forEach((conversationIDKey, idx) => { - if (!conversationIDKey) { - return - } - const badge = s.rowsBig.get(conversationIDKey)?.badgeCount ?? 0 - if (badge > 0) { - next.set(idx, badge) - } - }) - return next - }, [bigConvIds]) + const unreadBadges = useInboxRowsState( + C.useShallow(s => + bigConvIds.map(conversationIDKey => + conversationIDKey ? (s.rowsBig.get(conversationIDKey)?.badgeCount ?? 0) : 0 + ) + ) ) + const unreadIndices = React.useMemo(() => { + const next: Map = new Map() + unreadBadges.forEach((badge, idx) => { + if (badge > 0) { + next.set(idx, badge) + } + }) + return next + }, [unreadBadges]) + let unreadTotal = 0 unreadIndices.forEach(count => { unreadTotal += count From b838589e1a91f16fcbb54614e95c21e9e81d9214 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 16 Apr 2026 15:38:39 -0400 Subject: [PATCH 25/25] WIP --- shared/chat/conversation/list-area/hooks.tsx | 9 ++--- .../conversation/list-area/index.desktop.tsx | 34 ++++++------------- 2 files changed, 13 insertions(+), 30 deletions(-) diff --git a/shared/chat/conversation/list-area/hooks.tsx b/shared/chat/conversation/list-area/hooks.tsx index 7cf8aad90070..dc9be90b2997 100644 --- a/shared/chat/conversation/list-area/hooks.tsx +++ b/shared/chat/conversation/list-area/hooks.tsx @@ -20,11 +20,7 @@ export const useActions = (p: {conversationIDKey: T.Chat.ConversationIDKey}) => return {markInitiallyLoadedThreadAsRead} } -export const useJumpToRecent = ( - scrollToBottom: () => void, - numOrdinals: number, - showOverride?: boolean -) => { +export const useJumpToRecent = (scrollToBottom: () => void, numOrdinals: number) => { const data = ConvoState.useChatContext( C.useShallow(s => { const {loaded, moreToLoadForward} = s @@ -40,6 +36,5 @@ export const useJumpToRecent = ( toggleThreadSearch(true) } - const shouldShow = showOverride ?? moreToLoadForward - return loaded && shouldShow && numOrdinals > 0 && + return loaded && moreToLoadForward && numOrdinals > 0 && } diff --git a/shared/chat/conversation/list-area/index.desktop.tsx b/shared/chat/conversation/list-area/index.desktop.tsx index 34fb8b45b618..1a9ca47bfaf1 100644 --- a/shared/chat/conversation/list-area/index.desktop.tsx +++ b/shared/chat/conversation/list-area/index.desktop.tsx @@ -58,7 +58,6 @@ const useScrolling = (p: { const isScrollingRef = React.useRef(false) const ignoreOnScrollRef = React.useRef(false) const lockedToBottomRef = React.useRef(true) - const [lockedToBottom, setLockedToBottom] = React.useState(true) // so we can turn pointer events on / off const pointerWrapperRef = React.useRef(null) const setPointerWrapperRef = (r: HTMLDivElement | null) => { @@ -72,13 +71,6 @@ const useScrolling = (p: { return lockedToBottomRef.current }) - const updateLockedToBottom = (next: boolean) => { - if (lockedToBottomRef.current !== next) { - lockedToBottomRef.current = next - setLockedToBottom(next) - } - } - const adjustScrollAndIgnoreOnScroll = (fn: () => void) => { ignoreOnScrollRef.current = true fn() @@ -100,7 +92,7 @@ const useScrolling = (p: { }) const [scrollToBottomSync] = React.useState(() => () => { - updateLockedToBottom(true) + lockedToBottomRef.current = true const list = listRef.current if (list) { adjustScrollAndIgnoreOnScroll(() => { @@ -148,7 +140,7 @@ const useScrolling = (p: { }) const [scrollUp] = React.useState(() => () => { - updateLockedToBottom(false) + lockedToBottomRef.current = false const list = listRef.current list && adjustScrollAndIgnoreOnScroll(() => { @@ -178,7 +170,8 @@ const useScrolling = (p: { const list = listRef.current // are we locked on the bottom? only lock if we have latest messages if (list && !centeredOrdinal && containsLatestMessageRef.current) { - updateLockedToBottom(list.scrollHeight - list.clientHeight - list.scrollTop < listEdgeSlopBottom) + lockedToBottomRef.current = + list.scrollHeight - list.clientHeight - list.scrollTop < listEdgeSlopBottom } } }, 200) @@ -221,7 +214,7 @@ const useScrolling = (p: { return } // quickly set to false to assume we're not locked. if we are the throttled one will set it to true - updateLockedToBottom(false) + lockedToBottomRef.current = false checkForLoadMoreThrottled() onScrollThrottledRef.current() }) @@ -284,7 +277,7 @@ const useScrolling = (p: { const list = listRef.current // no items? don't be locked if (!ordinalsLength) { - updateLockedToBottom(false) + lockedToBottomRef.current = false return } @@ -328,10 +321,10 @@ const useScrolling = (p: { if (!wasLoaded || !loaded || !changed) return if (centeredOrdinal) { - updateLockedToBottom(false) + lockedToBottomRef.current = false scrollToCentered() } else if (containsLatestMessage) { - updateLockedToBottom(true) + lockedToBottomRef.current = true scrollToBottom() } }, [centeredOrdinal, loaded, containsLatestMessage, scrollToCentered, scrollToBottom]) @@ -364,7 +357,7 @@ const useScrolling = (p: { } }, [editingOrdinal, messageOrdinals, listRef]) - return {didFirstLoad, isLockedToBottom, lockedToBottom, scrollToBottom, setListRef, setPointerWrapperRef} + return {didFirstLoad, isLockedToBottom, scrollToBottom, setListRef, setPointerWrapperRef} } const useItems = (p: { @@ -501,8 +494,7 @@ const ThreadWrapper = function ThreadWrapper() { const _setListRef = (r: HTMLDivElement | null) => { listRef.current = r } - const {isLockedToBottom, lockedToBottom, scrollToBottom, setListRef, didFirstLoad, setPointerWrapperRef} = - useScrolling({ + const {isLockedToBottom, scrollToBottom, setListRef, didFirstLoad, setPointerWrapperRef} = useScrolling({ centeredOrdinal, containsLatestMessage, listRef, @@ -511,11 +503,7 @@ const ThreadWrapper = function ThreadWrapper() { setListRef: _setListRef, }) - const jumpToRecent = Hooks.useJumpToRecent( - scrollToBottom, - messageOrdinals.length, - !lockedToBottom || !containsLatestMessage - ) + const jumpToRecent = Hooks.useJumpToRecent(scrollToBottom, messageOrdinals.length) const onCopyCapture = (e: React.BaseSyntheticEvent) => { // Copy text only, not HTML/styling. We use virtualText on texts to make uncopyable text e.preventDefault()