From fc88a7a97ab2dcd09cb4290b6d08ce8009d724f4 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 3 Jul 2026 10:39:39 -0400 Subject: [PATCH 01/18] refactor(chat): dedupe InboxUIItem meta converters via shared base helper --- shared/constants/chat/meta.test.tsx | 208 ++++++++++++++++++++++++++++ shared/constants/chat/meta.tsx | 123 +++++++--------- 2 files changed, 257 insertions(+), 74 deletions(-) create mode 100644 shared/constants/chat/meta.test.tsx diff --git a/shared/constants/chat/meta.test.tsx b/shared/constants/chat/meta.test.tsx new file mode 100644 index 000000000000..2fc37a009402 --- /dev/null +++ b/shared/constants/chat/meta.test.tsx @@ -0,0 +1,208 @@ +/// +import * as T from '@/constants/types' +import { + getEffectiveRetentionPolicy, + inboxUIItemToConversationMeta, + unverifiedInboxUIItemToConversationMeta, +} from './meta' + +const commands = {typ: T.RPCChat.ConversationCommandGroupsTyp.none} as T.RPCChat.ConversationCommandGroups + +const makeTrustedFixture = ( + overrides: Partial = {} +): T.RPCChat.InboxUIItem => ({ + botAliases: {}, + botCommands: commands, + channel: '', + commands, + convID: 'convIDTeam' as T.RPCChat.ConvIDStr, + convRetention: undefined, + draft: undefined, + finalizeInfo: undefined, + headline: 'the headline', + headlineDecorated: 'the headline decorated', + isDefaultConv: false, + isEmpty: false, + isPublic: false, + maxMsgID: 5 as T.RPCChat.MessageID, + maxVisibleMsgID: 5 as T.RPCChat.MessageID, + memberStatus: T.RPCChat.ConversationMemberStatus.active, + membersType: T.RPCChat.ConversationMembersType.team, + name: 'acme', + notifications: undefined, + participants: undefined, + pinnedMsg: undefined, + readMsgID: 5 as T.RPCChat.MessageID, + resetParticipants: undefined, + snippet: 'the snippet', + snippetDecorated: 'the snippet decorated', + snippetDecoration: T.RPCChat.SnippetDecoration.none, + status: T.RPCChat.ConversationStatus.unfiled, + supersededBy: undefined, + supersedes: undefined, + teamRetention: undefined, + teamType: T.RPCChat.TeamType.simple, + time: 12345, + tlfID: 'tlfIDTeam' as T.RPCChat.TLFIDStr, + topicType: T.RPCChat.TopicType.chat, + version: 1 as T.RPCChat.ConversationVers, + localVersion: 1 as T.RPCChat.LocalConversationVers, + visibility: T.RPCGen.TLFVisibility.private, + ...overrides, +}) + +const makeUnverifiedFixture = ( + overrides: Partial = {} +): T.RPCChat.UnverifiedInboxUIItem => ({ + commands, + convID: 'convIDAdhoc' as T.RPCChat.ConvIDStr, + convRetention: undefined, + draft: undefined, + finalizeInfo: undefined, + isDefaultConv: false, + isPublic: false, + localMetadata: { + channelName: '', + headline: '', + headlineDecorated: '', + resetParticipants: undefined, + snippet: 'unverified snippet', + snippetDecoration: T.RPCChat.SnippetDecoration.none, + writerNames: undefined, + }, + maxMsgID: 3 as T.RPCChat.MessageID, + maxVisibleMsgID: 3 as T.RPCChat.MessageID, + memberStatus: T.RPCChat.ConversationMemberStatus.active, + membersType: T.RPCChat.ConversationMembersType.impteamnative, + name: 'testuser,testuser-mac', + notifications: undefined, + readMsgID: 3 as T.RPCChat.MessageID, + status: T.RPCChat.ConversationStatus.unfiled, + supersededBy: undefined, + supersedes: undefined, + teamRetention: undefined, + teamType: T.RPCChat.TeamType.none, + time: 6789, + tlfID: 'tlfIDAdhoc' as T.RPCChat.TLFIDStr, + topicType: T.RPCChat.TopicType.chat, + version: 2 as T.RPCChat.ConversationVers, + localVersion: 2 as T.RPCChat.LocalConversationVers, + visibility: T.RPCGen.TLFVisibility.private, + ...overrides, +}) + +describe('meta converters', () => { + it('trusted team item maps fields', () => { + const meta = inboxUIItemToConversationMeta(makeTrustedFixture()) + expect(meta?.trustedState).toBe('trusted') + expect(meta?.snippet).toBe('the snippet') + expect(meta?.channelname).toBe('') + expect(meta?.teamname).toBe('acme') + expect(meta?.teamType).toBe('small') + expect(meta?.resetParticipants).toEqual(new Set()) + expect(meta?.isMuted).toBe(false) + expect(meta?.notificationsDesktop).toBe('never') + }) + + it('trusted adhoc item with reset participants maps fields', () => { + const meta = inboxUIItemToConversationMeta( + makeTrustedFixture({ + channel: 'general', + membersType: T.RPCChat.ConversationMembersType.impteamnative, + name: 'testuser,testuser-mac', + resetParticipants: ['testuser-mac'], + teamType: T.RPCChat.TeamType.none, + }) + ) + expect(meta?.trustedState).toBe('trusted') + expect(meta?.teamType).toBe('adhoc') + expect(meta?.teamname).toBe('') + expect(meta?.channelname).toBe('') + expect(meta?.resetParticipants).toEqual(new Set(['testuser-mac'])) + }) + + it('trusted muted item with retention set maps fields', () => { + const meta = inboxUIItemToConversationMeta( + makeTrustedFixture({ + convRetention: {retain: {}, typ: T.RPCChat.RetentionPolicyType.retain}, + status: T.RPCChat.ConversationStatus.muted, + }) + ) + expect(meta?.isMuted).toBe(true) + expect(meta?.retentionPolicy.type).toBe('retain') + expect(getEffectiveRetentionPolicy(meta!).type).toBe('retain') + }) + + it('returns undefined for non-private trusted items', () => { + const meta = inboxUIItemToConversationMeta( + makeTrustedFixture({visibility: T.RPCGen.TLFVisibility.public}) + ) + expect(meta).toBeUndefined() + }) + + it('unverified item maps fields', () => { + const meta = unverifiedInboxUIItemToConversationMeta(makeUnverifiedFixture()) + expect(meta?.trustedState).toBe('untrusted') + expect(meta?.snippet).toBe('unverified snippet') + expect(meta?.channelname).toBe('') + expect(meta?.teamname).toBe('') + expect(meta?.teamType).toBe('adhoc') + expect(meta?.resetParticipants).toEqual(new Set()) + // fields the unverified path must NOT set (trusted-only fields stay defaults) + expect(meta?.botAliases).toEqual({}) + expect(meta?.isEmpty).toBe(false) + expect(meta?.pinnedMsg).toBeUndefined() + expect(meta?.minWriterRole).toBe('reader') + }) + + it('unverified team item with reset participants and muted status maps fields', () => { + const meta = unverifiedInboxUIItemToConversationMeta( + makeUnverifiedFixture({ + localMetadata: { + channelName: 'general', + headline: 'headline', + headlineDecorated: 'headline decorated', + resetParticipants: ['testuser-mac'], + snippet: 'team snippet', + snippetDecoration: T.RPCChat.SnippetDecoration.none, + writerNames: undefined, + }, + membersType: T.RPCChat.ConversationMembersType.team, + name: 'acme', + status: T.RPCChat.ConversationStatus.muted, + teamType: T.RPCChat.TeamType.simple, + }) + ) + expect(meta?.trustedState).toBe('untrusted') + expect(meta?.teamname).toBe('acme') + expect(meta?.channelname).toBe('general') + expect(meta?.teamType).toBe('small') + expect(meta?.isMuted).toBe(true) + // team (not impteam) members type never populates resetParticipants + expect(meta?.resetParticipants).toEqual(new Set()) + }) + + it('unverified adhoc item with reset participants maps fields', () => { + const meta = unverifiedInboxUIItemToConversationMeta( + makeUnverifiedFixture({ + localMetadata: { + channelName: '', + headline: '', + headlineDecorated: '', + resetParticipants: ['testuser-mac'], + snippet: 'adhoc snippet', + snippetDecoration: T.RPCChat.SnippetDecoration.none, + writerNames: undefined, + }, + }) + ) + expect(meta?.resetParticipants).toEqual(new Set(['testuser-mac'])) + }) + + it('returns undefined for non-private unverified items', () => { + const meta = unverifiedInboxUIItemToConversationMeta( + makeUnverifiedFixture({visibility: T.RPCGen.TLFVisibility.public}) + ) + expect(meta).toBeUndefined() + }) +}) diff --git a/shared/constants/chat/meta.tsx b/shared/constants/chat/meta.tsx index bb4032301d50..2cce8261e78a 100644 --- a/shared/constants/chat/meta.tsx +++ b/shared/constants/chat/meta.tsx @@ -6,7 +6,9 @@ import * as Message from './message' import {base64ToUint8Array, uint8ArrayToHex} from '@/util/uint8array' import {useCurrentUserState} from '@/stores/current-user' -const conversationMemberStatusToMembershipType = (m: T.RPCChat.ConversationMemberStatus) => { +const conversationMemberStatusToMembershipType = ( + m: T.RPCChat.ConversationMemberStatus +): T.Chat.MembershipType => { switch (m) { case T.RPCChat.ConversationMemberStatus.active: return 'active' @@ -24,51 +26,36 @@ const supersededConversationIDToKey = (id: string | Uint8Array): string => { return typeof id === 'string' ? uint8ArrayToHex(base64ToUint8Array(id)) : uint8ArrayToHex(id) } -export const unverifiedInboxUIItemToConversationMeta = ( - i: T.RPCChat.UnverifiedInboxUIItem -): T.Chat.ConversationMeta | undefined => { - // Private chats only - if (i.visibility !== T.RPCGen.TLFVisibility.private) { - return undefined - } - - // Should be impossible - if (!i.convID) { - return undefined - } +// We only treat implicit adhoc teams as having resetParticipants +const isImpteamMembersType = (membersType: T.RPCChat.ConversationMembersType) => + membersType === T.RPCChat.ConversationMembersType.impteamnative || + membersType === T.RPCChat.ConversationMembersType.impteamupgrade - // We only treat implicit adhoc teams as having resetParticipants +// Shared field mappings between InboxUIItem (trusted) and UnverifiedInboxUIItem. +// `resetParticipants` is passed in explicitly since its source field differs +// per type (`i.resetParticipants` vs `i.localMetadata?.resetParticipants`). +const baseMetaFromUIItem = ( + i: T.RPCChat.InboxUIItem | T.RPCChat.UnverifiedInboxUIItem, + isTeam: boolean, + resetParticipantsSource: ReadonlyArray | null | undefined +) => { const resetParticipants: Set = new Set( - i.localMetadata && - (i.membersType === T.RPCChat.ConversationMembersType.impteamnative || - i.membersType === T.RPCChat.ConversationMembersType.impteamupgrade) && - i.localMetadata.resetParticipants - ? i.localMetadata.resetParticipants - : [] + isImpteamMembersType(i.membersType) && resetParticipantsSource ? resetParticipantsSource : [] ) - const isTeam = i.membersType === T.RPCChat.ConversationMembersType.team - const channelname = isTeam && i.localMetadata ? i.localMetadata.channelName : '' - const supersededBy = conversationMetadataToMetaSupersedeInfo(i.supersededBy ?? undefined) const supersedes = conversationMetadataToMetaSupersedeInfo(i.supersedes ?? undefined) - const teamname = isTeam ? i.name : '' const {retentionPolicy, teamRetentionPolicy} = UIItemToRetentionPolicies(i, isTeam) const {notificationsDesktop, notificationsGlobalIgnoreMentions, notificationsMobile} = parseNotificationSettings(i.notifications ?? undefined) return { - ...makeConversationMeta(), - channelname, commands: i.commands, conversationIDKey: T.Chat.stringToConversationIDKey(i.convID), - description: i.localMetadata?.headline || '', - descriptionDecorated: i.localMetadata?.headlineDecorated || '', draft: i.draft || '', inboxLocalVersion: i.localVersion, inboxVersion: i.version, - isEmpty: false, isMuted: i.status === T.RPCChat.ConversationStatus.muted, maxMsgID: T.Chat.numberToMessageID(i.maxMsgID), maxVisibleMsgID: T.Chat.numberToMessageID(i.maxVisibleMsgID), @@ -79,23 +66,50 @@ export const unverifiedInboxUIItemToConversationMeta = ( readMsgID: T.Chat.numberToMessageID(i.readMsgID), resetParticipants, retentionPolicy, - snippet: i.localMetadata ? i.localMetadata.snippet : undefined, - snippetDecorated: undefined, - snippetDecoration: i.localMetadata ? i.localMetadata.snippetDecoration : T.RPCChat.SnippetDecoration.none, status: i.status, supersededBy: supersededBy ? T.Chat.stringToConversationIDKey(supersededBy) : T.Chat.noConversationIDKey, supersedes: supersedes ? T.Chat.stringToConversationIDKey(supersedes) : T.Chat.noConversationIDKey, teamID: i.tlfID, teamRetentionPolicy, teamType: getTeamType(i), - teamname, timestamp: i.time, tlfname: i.name, - trustedState: 'untrusted', wasFinalizedBy: i.finalizeInfo ? i.finalizeInfo.resetUser : '', } } +export const unverifiedInboxUIItemToConversationMeta = ( + i: T.RPCChat.UnverifiedInboxUIItem +): T.Chat.ConversationMeta | undefined => { + // Private chats only + if (i.visibility !== T.RPCGen.TLFVisibility.private) { + return undefined + } + + // Should be impossible + if (!i.convID) { + return undefined + } + + const isTeam = i.membersType === T.RPCChat.ConversationMembersType.team + const channelname = isTeam && i.localMetadata ? i.localMetadata.channelName : '' + const teamname = isTeam ? i.name : '' + + return { + ...makeConversationMeta(), + ...baseMetaFromUIItem(i, isTeam, i.localMetadata?.resetParticipants), + channelname, + description: i.localMetadata?.headline || '', + descriptionDecorated: i.localMetadata?.headlineDecorated || '', + isEmpty: false, + snippet: i.localMetadata ? i.localMetadata.snippet : undefined, + snippetDecorated: undefined, + snippetDecoration: i.localMetadata ? i.localMetadata.snippetDecoration : T.RPCChat.SnippetDecoration.none, + teamname, + trustedState: 'untrusted', + } +} + export const inboxUIItemErrorToConversationMetaAndParticipants = ( error: T.RPCChat.InboxUIItemError, username: string, @@ -285,23 +299,7 @@ export const inboxUIItemToConversationMeta = ( return } - // We only treat implied adhoc teams as having resetParticipants - const resetParticipants = new Set( - (i.membersType === T.RPCChat.ConversationMembersType.impteamnative || - i.membersType === T.RPCChat.ConversationMembersType.impteamupgrade) && - i.resetParticipants - ? i.resetParticipants - : [] - ) - - const supersededBy = conversationMetadataToMetaSupersedeInfo(i.supersededBy ?? undefined) - const supersedes = conversationMetadataToMetaSupersedeInfo(i.supersedes ?? undefined) - const isTeam = i.membersType === T.RPCChat.ConversationMembersType.team - const {notificationsDesktop, notificationsGlobalIgnoreMentions, notificationsMobile} = - parseNotificationSettings(i.notifications ?? undefined) - - const {retentionPolicy, teamRetentionPolicy} = UIItemToRetentionPolicies(i, isTeam) const minWriterRoleEnum = i.convSettings?.minWriterRoleInfo ? i.convSettings.minWriterRoleInfo.role @@ -336,44 +334,21 @@ export const inboxUIItemToConversationMeta = ( return { ...makeConversationMeta(), + ...baseMetaFromUIItem(i, isTeam, i.resetParticipants), botAliases: i.botAliases ?? {}, botCommands: i.botCommands, cannotWrite, channelname: (isTeam && i.channel) || '', - commands: i.commands, - conversationIDKey, description: i.headline, descriptionDecorated: i.headlineDecorated, - draft: i.draft || '', - inboxLocalVersion: i.localVersion, - inboxVersion: i.version, isEmpty: i.isEmpty, - isMuted: i.status === T.RPCChat.ConversationStatus.muted, - maxMsgID: T.Chat.numberToMessageID(i.maxMsgID), - maxVisibleMsgID: T.Chat.numberToMessageID(i.maxVisibleMsgID), - membershipType: conversationMemberStatusToMembershipType(i.memberStatus), minWriterRole, - notificationsDesktop, - notificationsGlobalIgnoreMentions, - notificationsMobile, pinnedMsg, - readMsgID: T.Chat.numberToMessageID(i.readMsgID), - resetParticipants, - retentionPolicy, snippet: i.snippet, snippetDecorated: i.snippetDecorated, snippetDecoration: i.snippetDecoration, - status: i.status, - supersededBy: supersededBy ? T.Chat.stringToConversationIDKey(supersededBy) : T.Chat.noConversationIDKey, - supersedes: supersedes ? T.Chat.stringToConversationIDKey(supersedes) : T.Chat.noConversationIDKey, - teamID: i.tlfID, - teamRetentionPolicy, - teamType: getTeamType(i), teamname: (isTeam && i.name) || '', - timestamp: i.time, - tlfname: i.name, trustedState: 'trusted', - wasFinalizedBy: i.finalizeInfo ? i.finalizeInfo.resetUser : '', } } From ca44ce6bd04723a935baf6d4afded014e334c93f Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 3 Jul 2026 10:46:50 -0400 Subject: [PATCH 02/18] refactor(chat): shared UIMessages JSON parse helper --- shared/chat/conversation/data-hooks.tsx | 39 ++++++++------------- shared/chat/conversation/thread-context.tsx | 25 +++++-------- shared/constants/chat/message.tsx | 27 ++++++++++++++ 3 files changed, 50 insertions(+), 41 deletions(-) diff --git a/shared/chat/conversation/data-hooks.tsx b/shared/chat/conversation/data-hooks.tsx index 1de090cc4848..3126aead5495 100644 --- a/shared/chat/conversation/data-hooks.tsx +++ b/shared/chat/conversation/data-hooks.tsx @@ -161,31 +161,22 @@ const parseThreadMessages = (conversationIDKey: T.Chat.ConversationIDKey, thread if (!thread) { return emptyMessages } - try { - const {username, deviceName} = useCurrentUserState.getState() - let lastOrdinal = T.Chat.numberToOrdinal(0) - const getLastOrdinal = () => lastOrdinal - const uiMessages = JSON.parse(thread) as T.RPCChat.UIMessages - return (uiMessages.messages ?? []).reduce>((arr, uiMessage) => { - const message = Message.uiMessageToMessage( - conversationIDKey, - uiMessage, - username, - getLastOrdinal, - deviceName - ) - if (message) { - arr.push(message) - if (T.Chat.ordinalToNumber(message.ordinal) > T.Chat.ordinalToNumber(lastOrdinal)) { - lastOrdinal = message.ordinal - } + const {username, deviceName} = useCurrentUserState.getState() + let lastOrdinal = T.Chat.numberToOrdinal(0) + const getLastOrdinal = () => lastOrdinal + const {messages} = Message.parseUIMessagesJSON( + conversationIDKey, + thread, + username, + deviceName, + getLastOrdinal, + message => { + if (T.Chat.ordinalToNumber(message.ordinal) > T.Chat.ordinalToNumber(lastOrdinal)) { + lastOrdinal = message.ordinal } - return arr - }, []) - } catch (error) { - logger.warn(`parseThreadMessages: failed for ${conversationIDKey}: ${String(error)}`) - return emptyMessages - } + } + ) + return messages } const loadConversationMessagesAroundMessageID = async ( diff --git a/shared/chat/conversation/thread-context.tsx b/shared/chat/conversation/thread-context.tsx index 631e3f95bdd2..8cc8955cb785 100644 --- a/shared/chat/conversation/thread-context.tsx +++ b/shared/chat/conversation/thread-context.tsx @@ -779,23 +779,14 @@ const loadConversationThreadMessages = ( } const {username, devicename} = getCurrentUser() - const uiMessages = JSON.parse(thread) as T.RPCChat.UIMessages - - const messages = (uiMessages.messages ?? []).reduce>((arr, m) => { - const message = Message.uiMessageToMessage( - conversationIDKey, - m, - username, - () => getLastOrdinalFromSnapshot(actions.getSnapshot()), - devicename - ) - if (message) { - arr.push(message) - } - return arr - }, []) - - const moreToLoad = uiMessages.pagination ? !uiMessages.pagination.last : true + const {messages, pagination} = Message.parseUIMessagesJSON( + conversationIDKey, + thread, + username, + devicename, + () => getLastOrdinalFromSnapshot(actions.getSnapshot()) + ) + const moreToLoad = pagination ? !pagination.last : true const canMarkReadForThreadWindow = allowMarkAsRead && !centeredMessageID && diff --git a/shared/constants/chat/message.tsx b/shared/constants/chat/message.tsx index 62519a22807d..110f5ee6e86a 100644 --- a/shared/constants/chat/message.tsx +++ b/shared/constants/chat/message.tsx @@ -1213,6 +1213,33 @@ export const uiMessageToMessage = ( } } +export const parseUIMessagesJSON = ( + conversationIDKey: T.Chat.ConversationIDKey, + threadJSON: string, + username: string, + devicename: string, + getLastOrdinal: () => T.Chat.Ordinal, + // called as each message is converted, before the next conversion; callers can use it to keep a + // running max feeding getLastOrdinal + onMessage?: (m: T.Chat.Message) => void +): {messages: Array; pagination?: T.RPCChat.UIPagination} => { + try { + const uiMessages = JSON.parse(threadJSON) as T.RPCChat.UIMessages + const messages = (uiMessages.messages ?? []).reduce>((arr, uiMessage) => { + const message = uiMessageToMessage(conversationIDKey, uiMessage, username, getLastOrdinal, devicename) + if (message) { + arr.push(message) + onMessage?.(message) + } + return arr + }, []) + return {messages, pagination: uiMessages.pagination ?? undefined} + } catch (error) { + logger.warn(`parseUIMessagesJSON: failed for ${conversationIDKey}: ${String(error)}`) + return {messages: []} + } +} + const assertNever = (_: never) => undefined function nextFractionalOrdinal(ord: T.Chat.Ordinal) { From 76a463a034320dd3ce3e4b0ef8ef9fa4c0381add Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 3 Jul 2026 11:08:52 -0400 Subject: [PATCH 03/18] refactor(chat): thread store reads participants from inbox metadata store Makes the inbox metadata store (useInboxMetadataState) the single owner of per-conversation participant info. The per-conversation thread store (ConversationThreadState) drops its participants field, setParticipants action, seeded-at-mount snapshot, mirror effect, and ChatParticipantsInfo listener. Readers switch to useConversationParticipants(conversationIDKey) (data-hooks.tsx) or, for the one non-hook call site, a direct getInboxConversationParticipants(id) read. The chatInboxFailed listener now writes straight to the inbox store via participantInfoReceived instead of round-tripping through the thread store. Verified no global-coverage regression: chat/inbox/engine.tsx's handleConvoEngineIncoming handles both ChatParticipantsInfo and chatInboxFailed unconditionally (no per-conversation gating), writing into useInboxMetadataState for every conversationIDKey in the RPC payload. constants/init/shared.tsx's _onEngineIncoming routes both notification types through this handler unconditionally, and it's wired once at the engine level (engine/index-impl.tsx), not per mounted provider - so the deleted per-conversation listener was fully redundant. yarn lint, yarn tsc, and yarn jest stores chat all pass (45 suites, 188 tests). --- shared/chat/blocking/invitation-to-block.tsx | 22 +++++------ shared/chat/conversation/bottom-banner.tsx | 9 +++-- .../conversation/input-area/normal/index.tsx | 6 ++- .../messages/message-popup/attachment.tsx | 9 +++-- .../messages/message-popup/hooks.tsx | 7 ++-- .../messages/message-popup/text.tsx | 10 ++--- .../chat/conversation/messages/reset-user.tsx | 6 +-- .../messages/special-top-message.tsx | 5 ++- .../container.tsx | 13 +++---- .../conversation/messages/wrapper/wrapper.tsx | 6 ++- .../chat/conversation/thread-context.test.tsx | 5 ++- shared/chat/conversation/thread-context.tsx | 38 ++----------------- 12 files changed, 56 insertions(+), 80 deletions(-) diff --git a/shared/chat/blocking/invitation-to-block.tsx b/shared/chat/blocking/invitation-to-block.tsx index 92ba6df5a805..a6e992649e2d 100644 --- a/shared/chat/blocking/invitation-to-block.tsx +++ b/shared/chat/blocking/invitation-to-block.tsx @@ -12,6 +12,7 @@ import { useConversationThreadID, useConversationThreadSelector, } from '../conversation/thread-context' +import {useConversationParticipants} from '../conversation/data-hooks' const dismissBlockButtons = (teamID: T.RPCGen.TeamID) => { const f = async () => { @@ -29,17 +30,16 @@ const dismissBlockButtons = (teamID: T.RPCGen.TeamID) => { const BlockButtons = () => { const navigateAppend = C.Router2.navigateAppend const conversationIDKey = useConversationThreadID() - const {messageMap, messageOrdinals, participantInfo, team, teamID, tlfname} = - useConversationThreadSelector( - C.useShallow(s => ({ - messageMap: s.messageMap, - messageOrdinals: s.messageOrdinals, - participantInfo: s.participants, - team: s.meta.teamname, - teamID: s.meta.teamID, - tlfname: s.meta.tlfname, - })) - ) + const {messageMap, messageOrdinals, team, teamID, tlfname} = useConversationThreadSelector( + C.useShallow(s => ({ + messageMap: s.messageMap, + messageOrdinals: s.messageOrdinals, + team: s.meta.teamname, + teamID: s.meta.teamID, + tlfname: s.meta.tlfname, + })) + ) + const participantInfo = useConversationParticipants(conversationIDKey) const blockButtonInfo = useBlockButtonsInfo(teamID) const currentUser = useCurrentUserState(s => s.username) const hasOwnMessage = diff --git a/shared/chat/conversation/bottom-banner.tsx b/shared/chat/conversation/bottom-banner.tsx index 09685440215d..8e3ac4de932e 100644 --- a/shared/chat/conversation/bottom-banner.tsx +++ b/shared/chat/conversation/bottom-banner.tsx @@ -12,6 +12,7 @@ import { useConversationThreadID, useConversationThreadSelector, } from './thread-context' +import {useConversationParticipants} from './data-hooks' type Store = T.Immutable<{ inviteBannerDismissed: Set @@ -48,7 +49,8 @@ const installMessage = `I sent you encrypted messages on Keybase. You can instal const Invite = (props: {onDismiss: () => void}) => { const linkUrlProps = Kb.useClickURL('https://keybase.io/app') - const participantInfo = useConversationThreadSelector(s => s.participants) + const conversationIDKey = useConversationThreadID() + const participantInfo = useConversationParticipants(conversationIDKey) const participantInfoAll = participantInfo.all const users = participantInfoAll.filter(p => p.includes('@')) @@ -145,9 +147,8 @@ const BannerContainerInner = function BannerContainerInner(props: { dismissed: s.inviteBannerDismissed.has(conversationIDKey), })) ) - const {meta, participantInfo} = useConversationThreadSelector( - C.useShallow(s => ({meta: s.meta, participantInfo: s.participants})) - ) + const meta = useConversationThreadSelector(s => s.meta) + const participantInfo = useConversationParticipants(conversationIDKey) if (meta.teamType !== 'adhoc') { return null } diff --git a/shared/chat/conversation/input-area/normal/index.tsx b/shared/chat/conversation/input-area/normal/index.tsx index 3f4a6ee80e10..6eace485a107 100644 --- a/shared/chat/conversation/input-area/normal/index.tsx +++ b/shared/chat/conversation/input-area/normal/index.tsx @@ -22,6 +22,7 @@ import { useConversationThreadSetExplodingMode, useConversationThreadToggleSearch, } from '../../thread-context' +import {useConversationParticipants} from '../../data-hooks' import {useCurrentUserState} from '@/stores/current-user' import {useRoute} from '@react-navigation/native' import {metasReceived, unboxRows} from '@/chat/inbox/metadata' @@ -34,14 +35,15 @@ const useHintText = (p: { }) => { const {minWriterRole, isExploding, isEditing, cannotWrite} = p const username = useCurrentUserState(s => s.username) - const {channelname, participantInfoName, teamType, teamname} = useConversationThreadSelector( + const conversationIDKey = useConversationThreadID() + const {channelname, teamType, teamname} = useConversationThreadSelector( C.useShallow(s => ({ channelname: s.meta.channelname, - participantInfoName: s.participants.name, teamType: s.meta.teamType, teamname: s.meta.teamname, })) ) + const participantInfoName = useConversationParticipants(conversationIDKey).name if (isMobile && isExploding) { return C.isLargeScreen ? `Write an exploding message` : 'Exploding message' } diff --git a/shared/chat/conversation/messages/message-popup/attachment.tsx b/shared/chat/conversation/messages/message-popup/attachment.tsx index 8291181c3f0d..61bbdc3baf05 100644 --- a/shared/chat/conversation/messages/message-popup/attachment.tsx +++ b/shared/chat/conversation/messages/message-popup/attachment.tsx @@ -12,10 +12,11 @@ import { import {openLocalPathInSystemFileManagerDesktop} from '@/util/fs-storeless-actions' import { showConversationInfoPanel, + useConversationThreadID, useConversationThreadMessage, useConversationThreadSelector, } from '../../thread-context' -import {useConversationMetadata} from '../../data-hooks' +import {useConversationMetadata, useConversationParticipants} from '../../data-hooks' import {useRoute} from '@react-navigation/native' import type {MessagePopupItems} from './hooks' import {useHeader, useHeaderForMessage, useItems, useModeration, useStorelessItems} from './hooks' @@ -157,6 +158,7 @@ const PopAttachLoaded = (ownProps: OwnProps & { const PopAttachThread = (ownProps: OwnProps) => { const {ordinal, onHidden} = ownProps + const conversationIDKey = useConversationThreadID() const loadedMessage = useConversationThreadMessage(ordinal) const message = loadedMessage?.type === 'attachment' ? loadedMessage : emptyMessage const {attachmentDownload, messageAttachmentNativeSave, messageAttachmentNativeShare} = @@ -167,9 +169,8 @@ const PopAttachThread = (ownProps: OwnProps) => { // infoPanel only exists on the desktop/tablet split-view chatRoot route const infoPanelShowing = route.name === 'chatRoot' && 'infoPanel' in route.params && !!route.params.infoPanel - const {meta, participantInfo} = useConversationThreadSelector( - C.useShallow(s => ({meta: s.meta, participantInfo: s.participants})) - ) + const meta = useConversationThreadSelector(s => s.meta) + const participantInfo = useConversationParticipants(conversationIDKey) return ( void) => { const conversationIDKey = useConversationThreadID() const message = useConversationThreadMessage(ordinal) ?? emptyText - const {meta, participantInfo} = useConversationThreadSelector( - C.useShallow(s => ({meta: s.meta, participantInfo: s.participants})) - ) + const meta = useConversationThreadSelector(s => s.meta) + const participantInfo = useConversationParticipants(conversationIDKey) const {messageDelete, toggleMessageReaction} = useConversationThreadMessageActions() const setMarkAsUnread = useConversationThreadSetMarkAsUnread() return useItemsForMessage({ diff --git a/shared/chat/conversation/messages/message-popup/text.tsx b/shared/chat/conversation/messages/message-popup/text.tsx index 7cc6264f532b..2ae5b68ad2d4 100644 --- a/shared/chat/conversation/messages/message-popup/text.tsx +++ b/shared/chat/conversation/messages/message-popup/text.tsx @@ -1,4 +1,3 @@ -import * as C from '@/constants' import * as Chat from '@/constants/chat' import * as Kb from '@/common-adapters' import type * as React from 'react' @@ -6,9 +5,10 @@ import * as T from '@/constants/types' import {copyToClipboard} from '@/util/storeless-actions' import {openURL} from '@/util/misc' import {replyPrivatelyToConversationMessage} from '../../message-actions' -import {useConversationMetadata} from '../../data-hooks' +import {useConversationMetadata, useConversationParticipants} from '../../data-hooks' import {useCurrentUserState} from '@/stores/current-user' import { + useConversationThreadID, useConversationThreadMessage, useConversationThreadMessageActions, useConversationThreadSelector, @@ -153,10 +153,10 @@ const PopTextLoaded = (ownProps: OwnProps & { const PopTextThread = (ownProps: OwnProps) => { const {ordinal, onHidden} = ownProps + const conversationIDKey = useConversationThreadID() const message = useConversationThreadMessage(ordinal) ?? emptyMessage - const {meta, participantInfo} = useConversationThreadSelector( - C.useShallow(s => ({meta: s.meta, participantInfo: s.participants})) - ) + const meta = useConversationThreadSelector(s => s.meta) + const participantInfo = useConversationParticipants(conversationIDKey) const itemsData = useItems(ordinal, onHidden) const header = useHeader(ordinal, onHidden) const {messageReplyPrivately} = useConversationThreadMessageActions() diff --git a/shared/chat/conversation/messages/reset-user.tsx b/shared/chat/conversation/messages/reset-user.tsx index 82913bdcb00b..2079594cbbda 100644 --- a/shared/chat/conversation/messages/reset-user.tsx +++ b/shared/chat/conversation/messages/reset-user.tsx @@ -3,12 +3,12 @@ import * as Kb from '@/common-adapters' import * as T from '@/constants/types' import {navToProfile} from '@/constants/router' import {useConversationThreadID, useConversationThreadSelector} from '../thread-context' +import {useConversationParticipants} from '../data-hooks' const ResetUser = () => { - const {meta, participantInfo} = useConversationThreadSelector( - C.useShallow(s => ({meta: s.meta, participantInfo: s.participants})) - ) const conversationIDKey = useConversationThreadID() + const meta = useConversationThreadSelector(s => s.meta) + const participantInfo = useConversationParticipants(conversationIDKey) const _participants = participantInfo.all const _resetParticipants = meta.resetParticipants const _viewProfile = navToProfile diff --git a/shared/chat/conversation/messages/special-top-message.tsx b/shared/chat/conversation/messages/special-top-message.tsx index 378b8c29c5f2..e97a33491aa5 100644 --- a/shared/chat/conversation/messages/special-top-message.tsx +++ b/shared/chat/conversation/messages/special-top-message.tsx @@ -11,6 +11,7 @@ import { useConversationThreadID, useConversationThreadSelector, } from '../thread-context' +import {useConversationParticipants} from '../data-hooks' import * as FS from '@/constants/fs' import {useCurrentUserState} from '@/stores/current-user' @@ -110,14 +111,14 @@ const ErrorMessage = () => { function SpecialTopMessage() { const username = useCurrentUserState(s => s.username) const conversationIDKey = useConversationThreadID() - const {hasLoadedEver, meta, moreToLoadBack, participants} = useConversationThreadSelector( + const {hasLoadedEver, meta, moreToLoadBack} = useConversationThreadSelector( C.useShallow(s => ({ hasLoadedEver: s.messageOrdinals !== undefined, meta: s.meta, moreToLoadBack: s.moreToLoadBack, - participants: s.participants, })) ) + const participants = useConversationParticipants(conversationIDKey) const {teamType, supersedes, retentionPolicy, teamRetentionPolicy} = meta const loadMoreType = moreToLoadBack ? 'moreToLoad' : 'noMoreToLoad' const pendingState = diff --git a/shared/chat/conversation/messages/system-old-profile-reset-notice/container.tsx b/shared/chat/conversation/messages/system-old-profile-reset-notice/container.tsx index 379700323871..4787e687d460 100644 --- a/shared/chat/conversation/messages/system-old-profile-reset-notice/container.tsx +++ b/shared/chat/conversation/messages/system-old-profile-reset-notice/container.tsx @@ -1,17 +1,14 @@ -import * as C from '@/constants' import type * as T from '@/constants/types' import {navigateToThread, previewConversation} from '@/constants/router' import {Text} from '@/common-adapters' import UserNotice from '../user-notice' -import {useConversationThreadSelector} from '../../thread-context' +import {useConversationThreadID, useConversationThreadSelector} from '../../thread-context' +import {useConversationParticipants} from '../../data-hooks' const SystemOldProfileResetNotice = () => { - const {meta, participantInfo} = useConversationThreadSelector( - C.useShallow(s => ({ - meta: s.meta, - participantInfo: s.participants, - })) - ) + const conversationIDKey = useConversationThreadID() + const meta = useConversationThreadSelector(s => s.meta) + const participantInfo = useConversationParticipants(conversationIDKey) const _participants = participantInfo.all const nextConversationIDKey = meta.supersededBy const username = meta.wasFinalizedBy || '' diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index c865a1d893ba..5a98868181a9 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -24,9 +24,11 @@ import { getConversationThreadDisplayMessage, ShownUsernameCacheContext, useConversationThreadActions, + useConversationThreadID, useConversationThreadMessageActions, useConversationThreadSelector, } from '../../thread-context' +import {useConversationParticipants} from '../../data-hooks' import type {ConversationInputState} from '../../input-area/input-state' import {useChatTeamMemberRole} from '../../team-hooks' @@ -372,6 +374,8 @@ export const useMessageData = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: bo const {retryMessage} = useConversationThreadActions() const messageActions = useConversationThreadMessageActions() const shownCache = React.useContext(ShownUsernameCacheContext) + const conversationIDKey = useConversationThreadID() + const participantInfo = useConversationParticipants(conversationIDKey) return useConversationThreadSelector( C.useShallow(s => { @@ -397,7 +401,7 @@ export const useMessageData = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: bo ...commonData, ...getEditCancelRetryData(commonData.ecrType, message), ...getRowActions(messageActions, uiDispatch, retryMessage), - ...getAuthorData(message, s.meta, s.participants, showUsername), + ...getAuthorData(message, s.meta, participantInfo, showUsername), message, } }) diff --git a/shared/chat/conversation/thread-context.test.tsx b/shared/chat/conversation/thread-context.test.tsx index 0d495c677121..b60a38891d83 100644 --- a/shared/chat/conversation/thread-context.test.tsx +++ b/shared/chat/conversation/thread-context.test.tsx @@ -27,6 +27,7 @@ import { useConversationThreadSelector, useConversationThreadStore, } from './thread-context' +import {useConversationParticipants} from './data-hooks' jest.mock('@/chat/inbox/rows-state', () => ({ flushInboxRowUpdates: jest.fn(), @@ -331,7 +332,7 @@ test('separate providers do not share thread state', () => { }) test('mounted thread syncs participant updates received outside its provider', () => { - const {result} = renderHook(() => useConversationThreadSelector(s => s.participants), {wrapper}) + const {result} = renderHook(() => useConversationParticipants(convID), {wrapper}) const participantInfo = { all: ['alice', 'helperbot'], contactName: new Map(), @@ -1140,7 +1141,7 @@ test('mounted thread listener applies inbox failure metadata for the active conv const {result} = renderHook( () => ({ meta: useConversationThreadSelector(s => s.meta), - participants: useConversationThreadSelector(s => s.participants), + participants: useConversationParticipants(convID), }), {wrapper} ) diff --git a/shared/chat/conversation/thread-context.tsx b/shared/chat/conversation/thread-context.tsx index 8cc8955cb785..feec0df161fa 100644 --- a/shared/chat/conversation/thread-context.tsx +++ b/shared/chat/conversation/thread-context.tsx @@ -58,7 +58,6 @@ import { metasReceived, participantInfoReceived, unboxRows, - useInboxMetadataState, } from '@/chat/inbox/metadata' import { loadThreadMessageIDAtIndex, @@ -96,11 +95,11 @@ const ignoreErrors = [ T.RPCGen.StatusCode.sctimeout, ] -const makeEmptyParticipantInfo = (): T.Chat.ParticipantInfo => ({ +const emptyParticipantInfo: T.Chat.ParticipantInfo = { all: [], contactName: new Map(), name: [], -}) +} const getExplodingModeFromGregorItems = ( conversationIDKey: T.Chat.ConversationIDKey, @@ -203,7 +202,6 @@ export type ConversationThreadState = { moreToLoadForward: boolean optimisticReactionMap: Map paymentStatusMap: Map - participants: T.Chat.ParticipantInfo pendingOutboxToOrdinal: Map typing: Set unfurlPrompt: Map> @@ -238,7 +236,6 @@ const makeEmptyThreadState = (): ConversationThreadState => moreToLoadBack: false, moreToLoadForward: false, optimisticReactionMap: new Map(), - participants: makeEmptyParticipantInfo(), paymentStatusMap: new Map(), pendingOutboxToOrdinal: new Map(), typing: new Set(), @@ -250,14 +247,10 @@ const makeEmptyThreadState = (): ConversationThreadState => const makeInitialThreadState = (id: T.Chat.ConversationIDKey) => { const meta = getInboxConversationMeta(id) - const participants = getInboxConversationParticipants(id) return produce(makeEmptyThreadState(), s => { if (meta) { s.meta = T.castDraft(meta) } - if (participants) { - s.participants = T.castDraft(participants) - } s.explodingMode = getExplodingModeFromConfig(id) }) } @@ -355,7 +348,6 @@ type ConversationThreadActions = { setMessageErrored: (outboxID: T.Chat.OutboxID, reason: string, errorTyp?: number) => void setMessageSubmitState: (ordinal: T.Chat.Ordinal, submitState: T.Chat.Message['submitState']) => void setMarkAsUnread: (readMsgID?: T.Chat.MessageID | false) => void - setParticipants: (participants: T.Chat.ParticipantInfo) => void setTyping: (typing: ReadonlySet) => void showUnfurlPrompt: (messageID: T.Chat.MessageID, domain: string) => void addOptimisticReaction: (outboxID: T.Chat.OutboxID, reaction: OptimisticReaction) => void @@ -1152,12 +1144,6 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => metasReceived([nextMeta]) } }) - const setParticipants = React.useEffectEvent((participants: T.Chat.ParticipantInfo) => { - updateThreadState(s => { - s.participants = T.castDraft(participants) - }) - participantInfoReceived(id, participants, getSnapshot().meta) - }) const setMarkAsUnread = React.useEffectEvent((readMsgID?: T.Chat.MessageID | false) => { if (readMsgID === false) { return @@ -1595,7 +1581,6 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => setMessageErrored, setMessageSubmitState, setMeta, - setParticipants, setTyping, showUnfurlPrompt, startAttachmentDownload, @@ -1617,15 +1602,6 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => threadActions.loadMoreMessages.cancel() } }, [threadActions]) - const inboxParticipants = useInboxMetadataState(s => s.participants.get(id)) - React.useEffect(() => { - if (!inboxParticipants) { - return - } - updateThreadState(s => { - s.participants = T.castDraft(inboxParticipants) - }) - }, [inboxParticipants]) useEngineActionListener('chat.1.NotifyChat.NewChatActivity', action => { const {activity} = action.payload.params switch (activity.activityType) { @@ -1751,7 +1727,7 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => threadActions.setMeta(meta) } if (participants) { - threadActions.setParticipants(participants) + participantInfoReceived(id, participants, meta) } }) useEngineActionListener('chat.1.NotifyChat.ChatSetConvSettings', action => { @@ -1801,12 +1777,6 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => applyConversationMetaToThread(meta, threadActions) } }) - useEngineActionListener('chat.1.NotifyChat.ChatParticipantsInfo', action => { - const participants = action.payload.params.participants?.[id] - if (participants) { - threadActions.setParticipants(Common.uiParticipantsToParticipantInfo(participants)) - } - }) useEngineActionListener('chat.1.NotifyChat.ChatRequestInfo', action => { const {convID, info, msgID} = action.payload.params if (T.Chat.conversationIDToKey(convID) !== id) { @@ -2048,7 +2018,6 @@ export const useConversationThreadMessageActions = () => { export const useConversationThreadSelectedConversation = () => { const conversationIDKey = useConversationThreadID() const loadMoreMessages = useConversationThreadLoadMoreMessages() - const participantInfo = useConversationThreadSelector(s => s.participants) const selectedConversation: SelectedConversation = (options?: SelectedConversationOptions) => { const {skipThreadLoad, ...loadStatusOptions} = options ?? {} @@ -2057,6 +2026,7 @@ export const useConversationThreadSelectedConversation = () => { unboxRows([conversationIDKey]) const username = useCurrentUserState.getState().username + const participantInfo = getInboxConversationParticipants(conversationIDKey) ?? emptyParticipantInfo const otherParticipants = Meta.getRowParticipants(participantInfo, username || '') if (otherParticipants.length === 1) { const otherUsername = otherParticipants[0] || '' From b5a84e9a5722a03339f6cbcb1e6c7cafe1f01c37 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 3 Jul 2026 11:19:15 -0400 Subject: [PATCH 04/18] fix(chat): version-gate metasReceived like thread-store path did metasReceived overwrote the inbox store unconditionally; the thread store's meta path version-gated via updateMeta. Fold that gating into metasReceived so a stale-version update can't clobber newer data. Add a {force} option for callers that must bypass gating: - metaReceivedError (error metas reuse the prior inbox version but flip trustedState to 'error', which gating would swallow) - onChatInboxSynced incremental (authoritative unverified sync) - updateInboxConversationMeta and the input-area draft write (both merge from the current meta at the same version) Gating is computed against getState() rather than the immer draft so updateMeta never sees a proxy. --- .../conversation/input-area/normal/index.tsx | 3 +- shared/chat/inbox/metadata.test.tsx | 29 ++++++++++- shared/chat/inbox/metadata.tsx | 50 +++++++++++++------ 3 files changed, 64 insertions(+), 18 deletions(-) diff --git a/shared/chat/conversation/input-area/normal/index.tsx b/shared/chat/conversation/input-area/normal/index.tsx index 6eace485a107..2be38eaa0e05 100644 --- a/shared/chat/conversation/input-area/normal/index.tsx +++ b/shared/chat/conversation/input-area/normal/index.tsx @@ -207,7 +207,8 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() { const updateDraftRaw = (text: string) => { // Immediately update local meta.draft so switching back to this thread // before the async unbox completes won't re-inject the old stale draft. - metasReceived([{...meta, draft: text}]) + // Merges from the current meta (same inbox version), so force past gating. + metasReceived([{...meta, draft: text}], undefined, {force: true}) const f = async () => { await T.RPCChat.localUpdateUnsentTextRpcPromise({ conversationID: convoID, diff --git a/shared/chat/inbox/metadata.test.tsx b/shared/chat/inbox/metadata.test.tsx index cbde2fbfde72..5d3b8203f826 100644 --- a/shared/chat/inbox/metadata.test.tsx +++ b/shared/chat/inbox/metadata.test.tsx @@ -1,8 +1,9 @@ /// +import * as Meta from '@/constants/chat/meta' import * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' import {useConfigState} from '@/stores/config' -import {forceUnboxRowsForService} from './metadata' +import {forceUnboxRowsForService, getInboxConversationMeta, metasReceived} from './metadata' const convID = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) @@ -46,3 +47,29 @@ test('forceUnboxRowsForService reruns once for requests made while an unbox is i resolvers[1]?.() await flushPromises() }) + +const makeMeta = (over: Partial): T.Chat.ConversationMeta => ({ + ...Meta.makeConversationMeta(), + conversationIDKey: convID, + ...over, +}) + +test('metasReceived version-gates: newer inbox version wins, older is ignored', () => { + metasReceived([makeMeta({inboxVersion: 2, snippet: 'v2', trustedState: 'trusted'})]) + metasReceived([makeMeta({inboxVersion: 1, snippet: 'v1', trustedState: 'trusted'})]) + expect(getInboxConversationMeta(convID)?.snippet).toBe('v2') + expect(getInboxConversationMeta(convID)?.inboxVersion).toBe(2) +}) + +test('metasReceived gates same-version updates (change swallowed without force)', () => { + metasReceived([makeMeta({inboxVersion: 2, snippet: 'orig', trustedState: 'trusted'})]) + metasReceived([makeMeta({inboxVersion: 2, snippet: 'changed', trustedState: 'trusted'})]) + expect(getInboxConversationMeta(convID)?.snippet).toBe('orig') +}) + +test('metasReceived force overwrites regardless of version', () => { + metasReceived([makeMeta({inboxVersion: 2, snippet: 'orig', trustedState: 'trusted'})]) + metasReceived([makeMeta({inboxVersion: 1, snippet: 'forced'})], undefined, {force: true}) + expect(getInboxConversationMeta(convID)?.snippet).toBe('forced') + expect(getInboxConversationMeta(convID)?.inboxVersion).toBe(1) +}) diff --git a/shared/chat/inbox/metadata.tsx b/shared/chat/inbox/metadata.tsx index 4a1f57ba7b49..867ba8cbfa4c 100644 --- a/shared/chat/inbox/metadata.tsx +++ b/shared/chat/inbox/metadata.tsx @@ -55,16 +55,21 @@ export const updateInboxConversationMeta = ( if (!oldMeta) { return } - metasReceived([ - { - ...oldMeta, - ...partial, - rekeyers: partial.rekeyers ? new Set(partial.rekeyers) : oldMeta.rekeyers, - resetParticipants: partial.resetParticipants - ? new Set(partial.resetParticipants) - : oldMeta.resetParticipants, - }, - ]) + // Already merged from the current meta, so bypass version gating. + metasReceived( + [ + { + ...oldMeta, + ...partial, + rekeyers: partial.rekeyers ? new Set(partial.rekeyers) : oldMeta.rekeyers, + resetParticipants: partial.resetParticipants + ? new Set(partial.resetParticipants) + : oldMeta.resetParticipants, + }, + ], + undefined, + {force: true} + ) } export const metaReceivedError = ( @@ -90,7 +95,9 @@ export const metaReceivedError = ( if (!meta) { return } - metasReceived([meta]) + // Error metas share the prior inbox version but flip trustedState to 'error'; + // gating would swallow that, so force the overwrite. + metasReceived([meta], undefined, {force: true}) if (participants) { participantInfoReceived(conversationIDKey, participants, meta) } @@ -111,18 +118,28 @@ export const participantInfoReceived = ( export const metasReceived = ( metas: ReadonlyArray, - removals?: ReadonlyArray + removals?: ReadonlyArray, + options?: {force?: boolean} ) => { + const force = options?.force ?? false + // Version-gate against the currently stored meta so a stale-version update + // can't clobber newer data (previously done by the thread store). Compute + // against getState() (not the immer draft) so updateMeta never sees a proxy. + const current = useInboxMetadataState.getState().metas + const nextMetas = metas.map(m => { + const old = force ? undefined : current.get(m.conversationIDKey) + return old ? Meta.updateMeta(old, m) : m + }) useInboxMetadataState.setState(s => { removals?.forEach(r => { s.metas.delete(r) s.participants.delete(r) }) - metas.forEach(m => { - s.metas.set(m.conversationIDKey, T.castDraft(m)) + nextMetas.forEach(next => { + s.metas.set(next.conversationIDKey, T.castDraft(next)) }) }) - syncInboxRowsFromMetas(metas, removals) + syncInboxRowsFromMetas(nextMetas, removals) } const updateInboxParticipants = (inboxUIItems: ReadonlyArray) => { @@ -591,7 +608,8 @@ export const onChatInboxSynced = async ( }, []) const removals = syncRes.incremental.removals?.map(T.Chat.stringToConversationIDKey) if (metas.length || removals?.length) { - metasReceived(metas, removals) + // Incremental unverified sync is authoritative for these convs; force past gating. + metasReceived(metas, removals, {force: true}) } forceUnboxRowsForService( From 101644454c85a07e79a58af4dfac4451a19323ce Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 3 Jul 2026 11:38:46 -0400 Subject: [PATCH 05/18] refactor(chat): single-owner conversation meta in inbox metadata store The per-conversation thread store kept its own copy of ConversationMeta, bi-directionally synced with the inbox metadata store. Remove that copy so the inbox store is the single owner: reads come from it, writes flow one way (RPC/engine -> metasReceived -> inbox store -> subscribers). Thread-context changes: - Drop `meta` from ConversationThreadState, its seeding, and the setMeta/updateMeta actions plus the applyConversationMetaToThread / applyInboxUIItemToThread helpers. - Internal reads now go through a module-local getMeta(id) (or getInboxConversationMeta for presence checks); the offline write uses updateInboxConversationMeta directly. - Add useThreadMeta(selector), a narrow inbox-store read keyed by the current thread id, and repoint every component/hook that read the thread-store meta (render-hot message rows use narrow field selectors; open-on-demand popups read the whole meta). Deleted thread-context listeners (each only refreshed the local meta copy; verified the global engine path in chat/inbox/engine.tsx already writes the inbox store, wired via constants/init/shared.tsx): - NewChatActivity setStatus/readMessage/newConversation branches and the applyInboxUIItemToThread calls in incomingMessage/failedMessage -> onNewChatActivity returns the inboxUIItem, pushed through onIncomingInboxUIItem -> hydrateInboxConversations -> metasReceived (readMessage with no conv -> forceUnboxRowsForService). - setAppNotificationSettings branch -> engine onNewChatActivity calls updateInboxConversationMeta(parseNotificationSettings). - ChatConvUpdate -> engine metasReceived. - chatInboxFailed -> engine metaReceivedError (error meta + rekey participants via participantInfoReceived; covered by engine.test.tsx 'global inbox failure routing stores error metadata and rekey participants'). - ChatSetConvSettings -> engine updateInboxConversationMeta(minWriterRole). - ChatSetConvRetention / ChatSetTeamRetention -> engine metasReceived. Tests: delete the thread-context test that asserted the removed mirroring listener (its behavior is covered by engine.test.tsx against the inbox store); teach normal/container.test.tsx to mock useThreadMeta. --- shared/chat/blocking/invitation-to-block.tsx | 9 +- shared/chat/conversation/bottom-banner.tsx | 7 +- shared/chat/conversation/container.tsx | 10 +- shared/chat/conversation/error.tsx | 4 +- .../conversation/input-area/container.tsx | 12 +- .../conversation/input-area/normal/index.tsx | 16 +- .../normal/set-explode-popup/hooks.tsx | 4 +- .../chat/conversation/input-area/preview.tsx | 4 +- .../messages/cards/team-journey/container.tsx | 12 +- .../messages/message-popup/attachment.tsx | 4 +- .../messages/message-popup/hooks.tsx | 4 +- .../messages/message-popup/text.tsx | 4 +- .../chat/conversation/messages/reset-user.tsx | 5 +- .../messages/retention-notice.tsx | 13 +- .../messages/special-bottom-message.tsx | 11 +- .../messages/special-top-message.tsx | 13 +- .../messages/system-added-to-team/wrapper.tsx | 12 +- .../system-change-retention/wrapper.tsx | 12 +- .../messages/system-create-team/wrapper.tsx | 6 +- .../system-invite-accepted/wrapper.tsx | 4 +- .../messages/system-joined/container.tsx | 8 +- .../messages/system-left/wrapper.tsx | 8 +- .../messages/system-new-channel/wrapper.tsx | 4 +- .../container.tsx | 7 +- .../messages/system-profile-reset-notice.tsx | 6 +- .../system-simple-to-complex/wrapper.tsx | 4 +- .../system-users-added-to-conv/container.tsx | 4 +- .../conversation/messages/wrapper/wrapper.tsx | 13 +- .../conversation/normal/container.test.tsx | 1 + shared/chat/conversation/normal/container.tsx | 19 +- shared/chat/conversation/normal/index.tsx | 10 +- shared/chat/conversation/pinned-message.tsx | 12 +- shared/chat/conversation/rekey/container.tsx | 4 +- shared/chat/conversation/send-actions.tsx | 5 +- shared/chat/conversation/team-hooks.tsx | 12 +- .../chat/conversation/thread-context.test.tsx | 76 ------- shared/chat/conversation/thread-context.tsx | 206 +++--------------- .../team/settings-tab/retention/index.tsx | 4 +- 38 files changed, 200 insertions(+), 369 deletions(-) diff --git a/shared/chat/blocking/invitation-to-block.tsx b/shared/chat/blocking/invitation-to-block.tsx index a6e992649e2d..d7c228238557 100644 --- a/shared/chat/blocking/invitation-to-block.tsx +++ b/shared/chat/blocking/invitation-to-block.tsx @@ -11,6 +11,7 @@ import {useBlockButtonsInfo} from './block-buttons-state' import { useConversationThreadID, useConversationThreadSelector, + useThreadMeta, } from '../conversation/thread-context' import {useConversationParticipants} from '../conversation/data-hooks' @@ -30,15 +31,15 @@ const dismissBlockButtons = (teamID: T.RPCGen.TeamID) => { const BlockButtons = () => { const navigateAppend = C.Router2.navigateAppend const conversationIDKey = useConversationThreadID() - const {messageMap, messageOrdinals, team, teamID, tlfname} = useConversationThreadSelector( + const {messageMap, messageOrdinals} = useConversationThreadSelector( C.useShallow(s => ({ messageMap: s.messageMap, messageOrdinals: s.messageOrdinals, - team: s.meta.teamname, - teamID: s.meta.teamID, - tlfname: s.meta.tlfname, })) ) + const {team, teamID, tlfname} = useThreadMeta( + C.useShallow(m => ({team: m.teamname, teamID: m.teamID, tlfname: m.tlfname})) + ) const participantInfo = useConversationParticipants(conversationIDKey) const blockButtonInfo = useBlockButtonsInfo(teamID) const currentUser = useCurrentUserState(s => s.username) diff --git a/shared/chat/conversation/bottom-banner.tsx b/shared/chat/conversation/bottom-banner.tsx index 8e3ac4de932e..1d8e16b66786 100644 --- a/shared/chat/conversation/bottom-banner.tsx +++ b/shared/chat/conversation/bottom-banner.tsx @@ -8,10 +8,7 @@ import {assertionToDisplay} from '@/common-adapters/usernames' import {useUsersState} from '@/stores/users' import {useFollowerState} from '@/stores/followers' import {showShareActionSheet} from '@/util/platform-specific' -import { - useConversationThreadID, - useConversationThreadSelector, -} from './thread-context' +import {useConversationThreadID, useThreadMeta} from './thread-context' import {useConversationParticipants} from './data-hooks' type Store = T.Immutable<{ @@ -147,7 +144,7 @@ const BannerContainerInner = function BannerContainerInner(props: { dismissed: s.inviteBannerDismissed.has(conversationIDKey), })) ) - const meta = useConversationThreadSelector(s => s.meta) + const meta = useThreadMeta(C.useShallow(m => ({isEmpty: m.isEmpty, teamType: m.teamType}))) const participantInfo = useConversationParticipants(conversationIDKey) if (meta.teamType !== 'adhoc') { return null diff --git a/shared/chat/conversation/container.tsx b/shared/chat/conversation/container.tsx index 6fa4e3d949ce..72525b379156 100644 --- a/shared/chat/conversation/container.tsx +++ b/shared/chat/conversation/container.tsx @@ -8,7 +8,7 @@ import Rekey from './rekey/container' import type {ThreadSearchRouteProps} from './thread-search-route' import type * as T from '@/constants/types' import {BadgeHeaderUpdater} from './header-area' -import {LiveConversationThreadProvider, useConversationThreadID, useConversationThreadSelector} from './thread-context' +import {LiveConversationThreadProvider, useConversationThreadID, useThreadMeta} from './thread-context' type Props = ThreadSearchRouteProps & { conversationIDKey?: T.Chat.ConversationIDKey @@ -26,7 +26,13 @@ const Conversation = function Conversation(props: Props) { const ConversationInner = function ConversationInner() { const conversationIDKey = useConversationThreadID() - const meta = useConversationThreadSelector(s => s.meta) + const meta = useThreadMeta( + C.useShallow(m => ({ + membershipType: m.membershipType, + rekeyers: m.rekeyers, + trustedState: m.trustedState, + })) + ) const type = (() => { switch (conversationIDKey) { case Chat.noConversationIDKey: diff --git a/shared/chat/conversation/error.tsx b/shared/chat/conversation/error.tsx index 867be46841ff..d6e66d409e55 100644 --- a/shared/chat/conversation/error.tsx +++ b/shared/chat/conversation/error.tsx @@ -1,8 +1,8 @@ import * as Kb from '@/common-adapters' -import {useConversationThreadSelector} from './thread-context' +import {useThreadMeta} from './thread-context' const ConversationError = () => { - const text = useConversationThreadSelector(s => s.meta.snippet) ?? '' + const text = useThreadMeta(m => m.snippet) ?? '' return ( There was an error loading this conversation. diff --git a/shared/chat/conversation/input-area/container.tsx b/shared/chat/conversation/input-area/container.tsx index d6323fc991e4..5770f0af733f 100644 --- a/shared/chat/conversation/input-area/container.tsx +++ b/shared/chat/conversation/input-area/container.tsx @@ -5,16 +5,16 @@ import Normal from './normal' import Preview from './preview' import ThreadSearch from '../search' import {useThreadSearchRoute} from '../thread-search-route' -import {useConversationThreadID, useConversationThreadSelector} from '../thread-context' +import {useConversationThreadID, useThreadMeta} from '../thread-context' const InputAreaContainer = () => { const conversationIDKey = useConversationThreadID() const showThreadSearch = !!useThreadSearchRoute() - const {membershipType, resetParticipants, wasFinalizedBy} = useConversationThreadSelector( - C.useShallow(s => ({ - membershipType: s.meta.membershipType, - resetParticipants: s.meta.resetParticipants, - wasFinalizedBy: s.meta.wasFinalizedBy, + const {membershipType, resetParticipants, wasFinalizedBy} = useThreadMeta( + C.useShallow(m => ({ + membershipType: m.membershipType, + resetParticipants: m.resetParticipants, + wasFinalizedBy: m.wasFinalizedBy, })) ) diff --git a/shared/chat/conversation/input-area/normal/index.tsx b/shared/chat/conversation/input-area/normal/index.tsx index 2be38eaa0e05..d6092f97e532 100644 --- a/shared/chat/conversation/input-area/normal/index.tsx +++ b/shared/chat/conversation/input-area/normal/index.tsx @@ -21,6 +21,7 @@ import { useConversationThreadSelector, useConversationThreadSetExplodingMode, useConversationThreadToggleSearch, + useThreadMeta, } from '../../thread-context' import {useConversationParticipants} from '../../data-hooks' import {useCurrentUserState} from '@/stores/current-user' @@ -36,11 +37,11 @@ const useHintText = (p: { const {minWriterRole, isExploding, isEditing, cannotWrite} = p const username = useCurrentUserState(s => s.username) const conversationIDKey = useConversationThreadID() - const {channelname, teamType, teamname} = useConversationThreadSelector( - C.useShallow(s => ({ - channelname: s.meta.channelname, - teamType: s.meta.teamType, - teamname: s.meta.teamname, + const {channelname, teamType, teamname} = useThreadMeta( + C.useShallow(m => ({ + channelname: m.channelname, + teamType: m.teamType, + teamname: m.teamname, })) ) const participantInfoName = useConversationParticipants(conversationIDKey).name @@ -141,9 +142,8 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() { ) const replyToMessage = useConversationThreadMessage(uiData.replyTo) const conversationIDKey = useConversationThreadID() - const {explodingMode, meta} = useConversationThreadSelector( - C.useShallow(s => ({explodingMode: s.explodingMode, meta: s.meta})) - ) + const explodingMode = useConversationThreadSelector(s => s.explodingMode) + const meta = useThreadMeta(m => m) const setExplodingModeRaw = useConversationThreadSetExplodingMode() const {cannotWrite, minWriterRole, tlfname} = meta const convoID = T.Chat.isValidConversationIDKey(conversationIDKey) diff --git a/shared/chat/conversation/input-area/normal/set-explode-popup/hooks.tsx b/shared/chat/conversation/input-area/normal/set-explode-popup/hooks.tsx index c8ea1b1e788a..745b82c93035 100644 --- a/shared/chat/conversation/input-area/normal/set-explode-popup/hooks.tsx +++ b/shared/chat/conversation/input-area/normal/set-explode-popup/hooks.tsx @@ -1,7 +1,7 @@ import * as Chat from '@/constants/chat' import type * as T from '@/constants/types' import type {Props} from './index.shared' -import {useConversationThreadSelector} from '../../../thread-context' +import {useConversationThreadSelector, useThreadMeta} from '../../../thread-context' export type MessageExplodeDescription = { text: string @@ -32,7 +32,7 @@ const makeItems = (meta: T.Chat.ConversationMeta) => { export default (p: Props) => { const {setExplodingMode, onHidden, visible, attachTo, onAfterSelect} = p - const _meta = useConversationThreadSelector(s => s.meta) + const _meta = useThreadMeta(m => m) const selected = useConversationThreadSelector(s => s.explodingMode) const onSelect = (seconds: number) => { setTimeout(() => { diff --git a/shared/chat/conversation/input-area/preview.tsx b/shared/chat/conversation/input-area/preview.tsx index 1a98428c0341..95839f8fb7ee 100644 --- a/shared/chat/conversation/input-area/preview.tsx +++ b/shared/chat/conversation/input-area/preview.tsx @@ -2,11 +2,11 @@ import * as C from '@/constants' import * as React from 'react' import * as Kb from '@/common-adapters' import {joinConversation} from '../status-actions' -import {useConversationThreadID, useConversationThreadSelector} from '../thread-context' +import {useConversationThreadID, useThreadMeta} from '../thread-context' const Preview = () => { const conversationIDKey = useConversationThreadID() - const channelname = useConversationThreadSelector(s => s.meta.channelname) + const channelname = useThreadMeta(m => m.channelname) const [clicked, setClicked] = React.useState(undefined) const _onClick = (join: boolean) => { diff --git a/shared/chat/conversation/messages/cards/team-journey/container.tsx b/shared/chat/conversation/messages/cards/team-journey/container.tsx index 6ecc363b58a9..61ba822efc81 100644 --- a/shared/chat/conversation/messages/cards/team-journey/container.tsx +++ b/shared/chat/conversation/messages/cards/team-journey/container.tsx @@ -13,7 +13,7 @@ import { useConversationThreadDismissJourneycard, useConversationThreadID, useConversationThreadMessage, - useConversationThreadSelector, + useThreadMeta, } from '../../../thread-context' type Action = {label: string; onClick: () => void} | 'wave' @@ -25,7 +25,15 @@ const TeamJourneyConnected = (ownProps: OwnProps) => { const {ordinal} = ownProps const m = useConversationThreadMessage(ordinal) const message = m?.type === 'journeycard' ? m : emptyJourney - const conv = useConversationThreadSelector(s => s.meta) + const conv = useThreadMeta( + C.useShallow(m => ({ + cannotWrite: m.cannotWrite, + channelname: m.channelname, + teamID: m.teamID, + teamname: m.teamname, + tlfname: m.tlfname, + })) + ) const {cannotWrite, channelname, teamname, teamID} = conv const welcomeMessage = {display: '', raw: '', set: false} const teamMetaByID = useTeamsListMap() diff --git a/shared/chat/conversation/messages/message-popup/attachment.tsx b/shared/chat/conversation/messages/message-popup/attachment.tsx index 61bbdc3baf05..696f48ea0a94 100644 --- a/shared/chat/conversation/messages/message-popup/attachment.tsx +++ b/shared/chat/conversation/messages/message-popup/attachment.tsx @@ -14,7 +14,7 @@ import { showConversationInfoPanel, useConversationThreadID, useConversationThreadMessage, - useConversationThreadSelector, + useThreadMeta, } from '../../thread-context' import {useConversationMetadata, useConversationParticipants} from '../../data-hooks' import {useRoute} from '@react-navigation/native' @@ -169,7 +169,7 @@ const PopAttachThread = (ownProps: OwnProps) => { // infoPanel only exists on the desktop/tablet split-view chatRoot route const infoPanelShowing = route.name === 'chatRoot' && 'infoPanel' in route.params && !!route.params.infoPanel - const meta = useConversationThreadSelector(s => s.meta) + const meta = useThreadMeta(m => m) const participantInfo = useConversationParticipants(conversationIDKey) return ( void) => { const conversationIDKey = useConversationThreadID() const message = useConversationThreadMessage(ordinal) ?? emptyText - const meta = useConversationThreadSelector(s => s.meta) + const meta = useThreadMeta(m => m) const participantInfo = useConversationParticipants(conversationIDKey) const {messageDelete, toggleMessageReaction} = useConversationThreadMessageActions() const setMarkAsUnread = useConversationThreadSetMarkAsUnread() diff --git a/shared/chat/conversation/messages/message-popup/text.tsx b/shared/chat/conversation/messages/message-popup/text.tsx index 2ae5b68ad2d4..bb5daf9ebb8b 100644 --- a/shared/chat/conversation/messages/message-popup/text.tsx +++ b/shared/chat/conversation/messages/message-popup/text.tsx @@ -11,7 +11,7 @@ import { useConversationThreadID, useConversationThreadMessage, useConversationThreadMessageActions, - useConversationThreadSelector, + useThreadMeta, } from '../../thread-context' import type {MessagePopupItems} from './hooks' import {useHeader, useHeaderForMessage, useItems, useModeration, useStorelessItems} from './hooks' @@ -155,7 +155,7 @@ const PopTextThread = (ownProps: OwnProps) => { const {ordinal, onHidden} = ownProps const conversationIDKey = useConversationThreadID() const message = useConversationThreadMessage(ordinal) ?? emptyMessage - const meta = useConversationThreadSelector(s => s.meta) + const meta = useThreadMeta(m => m) const participantInfo = useConversationParticipants(conversationIDKey) const itemsData = useItems(ordinal, onHidden) const header = useHeader(ordinal, onHidden) diff --git a/shared/chat/conversation/messages/reset-user.tsx b/shared/chat/conversation/messages/reset-user.tsx index 2079594cbbda..aa6c7201c220 100644 --- a/shared/chat/conversation/messages/reset-user.tsx +++ b/shared/chat/conversation/messages/reset-user.tsx @@ -2,15 +2,14 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import * as T from '@/constants/types' import {navToProfile} from '@/constants/router' -import {useConversationThreadID, useConversationThreadSelector} from '../thread-context' +import {useConversationThreadID, useThreadMeta} from '../thread-context' import {useConversationParticipants} from '../data-hooks' const ResetUser = () => { const conversationIDKey = useConversationThreadID() - const meta = useConversationThreadSelector(s => s.meta) const participantInfo = useConversationParticipants(conversationIDKey) const _participants = participantInfo.all - const _resetParticipants = meta.resetParticipants + const _resetParticipants = useThreadMeta(m => m.resetParticipants) const _viewProfile = navToProfile const username = [..._resetParticipants][0] || '' const nonResetUsers = new Set(_participants) diff --git a/shared/chat/conversation/messages/retention-notice.tsx b/shared/chat/conversation/messages/retention-notice.tsx index 80c453972738..591fa83d26cb 100644 --- a/shared/chat/conversation/messages/retention-notice.tsx +++ b/shared/chat/conversation/messages/retention-notice.tsx @@ -1,7 +1,8 @@ import type * as T from '@/constants/types' +import * as C from '@/constants' import * as Kb from '@/common-adapters' import {useChatTeam} from '../team-hooks' -import {useConversationShowInfoPanel, useConversationThreadSelector} from '../thread-context' +import {useConversationShowInfoPanel, useThreadMeta} from '../thread-context' // Parses retention policies into a string suitable for display at the top of a conversation function makeRetentionNotice( @@ -40,7 +41,15 @@ function makeRetentionNotice( } function RetentionNoticeContainer() { - const meta = useConversationThreadSelector(s => s.meta) + const meta = useThreadMeta( + C.useShallow(m => ({ + retentionPolicy: m.retentionPolicy, + teamID: m.teamID, + teamRetentionPolicy: m.teamRetentionPolicy, + teamType: m.teamType, + teamname: m.teamname, + })) + ) const {teamType, retentionPolicy: policy, teamRetentionPolicy: teamPolicy} = meta const {yourOperations} = useChatTeam(meta.teamID, meta.teamname) const canChange = meta.teamType !== 'adhoc' ? yourOperations.setRetentionPolicy : true diff --git a/shared/chat/conversation/messages/special-bottom-message.tsx b/shared/chat/conversation/messages/special-bottom-message.tsx index 3e4c27762ae4..8fd109fb1d87 100644 --- a/shared/chat/conversation/messages/special-bottom-message.tsx +++ b/shared/chat/conversation/messages/special-bottom-message.tsx @@ -1,10 +1,17 @@ +import * as C from '@/constants' import * as Chat from '@/constants/chat' -import {useConversationThreadSelector} from '../thread-context' +import {useThreadMeta} from '../thread-context' import OldProfileReset from './system-old-profile-reset-notice/container' import ResetUser from './reset-user' function BottomMessageContainer() { - const meta = useConversationThreadSelector(s => s.meta) + const meta = useThreadMeta( + C.useShallow(m => ({ + resetParticipants: m.resetParticipants, + supersededBy: m.supersededBy, + wasFinalizedBy: m.wasFinalizedBy, + })) + ) const showResetParticipants = meta.resetParticipants.size !== 0 const showSuperseded = !!meta.wasFinalizedBy || meta.supersededBy !== Chat.noConversationIDKey diff --git a/shared/chat/conversation/messages/special-top-message.tsx b/shared/chat/conversation/messages/special-top-message.tsx index e97a33491aa5..d049bc3354cf 100644 --- a/shared/chat/conversation/messages/special-top-message.tsx +++ b/shared/chat/conversation/messages/special-top-message.tsx @@ -10,6 +10,7 @@ import {useChatThreadRouteParams} from '../thread-search-route' import { useConversationThreadID, useConversationThreadSelector, + useThreadMeta, } from '../thread-context' import {useConversationParticipants} from '../data-hooks' import * as FS from '@/constants/fs' @@ -111,15 +112,21 @@ const ErrorMessage = () => { function SpecialTopMessage() { const username = useCurrentUserState(s => s.username) const conversationIDKey = useConversationThreadID() - const {hasLoadedEver, meta, moreToLoadBack} = useConversationThreadSelector( + const {hasLoadedEver, moreToLoadBack} = useConversationThreadSelector( C.useShallow(s => ({ hasLoadedEver: s.messageOrdinals !== undefined, - meta: s.meta, moreToLoadBack: s.moreToLoadBack, })) ) + const {teamType, supersedes, retentionPolicy, teamRetentionPolicy} = useThreadMeta( + C.useShallow(m => ({ + retentionPolicy: m.retentionPolicy, + supersedes: m.supersedes, + teamRetentionPolicy: m.teamRetentionPolicy, + teamType: m.teamType, + })) + ) const participants = useConversationParticipants(conversationIDKey) - const {teamType, supersedes, retentionPolicy, teamRetentionPolicy} = meta const loadMoreType = moreToLoadBack ? 'moreToLoad' : 'noMoreToLoad' const pendingState = conversationIDKey === T.Chat.pendingWaitingConversationIDKey diff --git a/shared/chat/conversation/messages/system-added-to-team/wrapper.tsx b/shared/chat/conversation/messages/system-added-to-team/wrapper.tsx index 2e2c5a5ce4d2..d0794f9ae205 100644 --- a/shared/chat/conversation/messages/system-added-to-team/wrapper.tsx +++ b/shared/chat/conversation/messages/system-added-to-team/wrapper.tsx @@ -7,7 +7,7 @@ import {getAddedUsernames} from '../system-users-added-to-conv/container' import {indefiniteArticle} from '@/util/string' import {useCurrentUserState} from '@/stores/current-user' import {useChatTeamMembers} from '../../team-hooks' -import {useConversationShowInfoPanel, useConversationThreadID, useConversationThreadSelector} from '../../thread-context' +import {useConversationShowInfoPanel, useConversationThreadID, useThreadMeta} from '../../thread-context' import {makeMessageWrapper} from '../wrapper/wrapper' type OwnProps = {message: T.Chat.MessageSystemAddedToTeam} @@ -16,11 +16,11 @@ function SystemAddedToTeamContainer(p: OwnProps) { const {message} = p const {addee, adder, author, bulkAdds, role: _role, timestamp} = message const conversationIDKey = useConversationThreadID() - const {teamID, teamname, teamType} = useConversationThreadSelector( - C.useShallow(s => ({ - teamID: s.meta.teamID, - teamType: s.meta.teamType, - teamname: s.meta.teamname, + const {teamID, teamname, teamType} = useThreadMeta( + C.useShallow(m => ({ + teamID: m.teamID, + teamType: m.teamType, + teamname: m.teamname, })) ) const showInfoPanel = useConversationShowInfoPanel() diff --git a/shared/chat/conversation/messages/system-change-retention/wrapper.tsx b/shared/chat/conversation/messages/system-change-retention/wrapper.tsx index 6684506827d3..7c8ab5d39954 100644 --- a/shared/chat/conversation/messages/system-change-retention/wrapper.tsx +++ b/shared/chat/conversation/messages/system-change-retention/wrapper.tsx @@ -5,7 +5,7 @@ import * as T from '@/constants/types' import * as dateFns from 'date-fns' import {useCurrentUserState} from '@/stores/current-user' import {useChatTeam} from '../../team-hooks' -import {useConversationShowInfoPanel, useConversationThreadSelector} from '../../thread-context' +import {useConversationShowInfoPanel, useThreadMeta} from '../../thread-context' import {makeMessageWrapper} from '../wrapper/wrapper' type OwnProps = {message: T.Chat.MessageSystemChangeRetention} @@ -14,11 +14,11 @@ function SystemChangeRetentionContainer(p: OwnProps) { const {message} = p const {isInherit, isTeam, membersType, policy, user} = message const you = useCurrentUserState(s => s.username) - const {teamID, teamType, teamname} = useConversationThreadSelector( - C.useShallow(s => ({ - teamID: s.meta.teamID, - teamType: s.meta.teamType, - teamname: s.meta.teamname, + const {teamID, teamType, teamname} = useThreadMeta( + C.useShallow(m => ({ + teamID: m.teamID, + teamType: m.teamType, + teamname: m.teamname, })) ) const showInfoPanel = useConversationShowInfoPanel() diff --git a/shared/chat/conversation/messages/system-create-team/wrapper.tsx b/shared/chat/conversation/messages/system-create-team/wrapper.tsx index 707dc1221da1..1e74c3678405 100644 --- a/shared/chat/conversation/messages/system-create-team/wrapper.tsx +++ b/shared/chat/conversation/messages/system-create-team/wrapper.tsx @@ -6,15 +6,15 @@ import type * as T from '@/constants/types' import {useCurrentUserState} from '@/stores/current-user' import {useChatTeam} from '../../team-hooks' import {makeAddMembersWizard} from '@/teams/add-members-wizard/state' -import {useConversationShowInfoPanel, useConversationThreadSelector} from '../../thread-context' +import {useConversationShowInfoPanel, useThreadMeta} from '../../thread-context' import {makeMessageWrapper} from '../wrapper/wrapper' type OwnProps = {message: T.Chat.MessageSystemCreateTeam} function SystemCreateTeamContainer(p: OwnProps) { const {creator} = p.message - const {teamID, teamname} = useConversationThreadSelector( - C.useShallow(s => ({teamID: s.meta.teamID, teamname: s.meta.teamname})) + const {teamID, teamname} = useThreadMeta( + C.useShallow(m => ({teamID: m.teamID, teamname: m.teamname})) ) const showInfoPanel = useConversationShowInfoPanel() const {role} = useChatTeam(teamID, teamname) diff --git a/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx b/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx index 2659e23e4b7e..8020a253403d 100644 --- a/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx +++ b/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx @@ -4,7 +4,7 @@ import type * as T from '@/constants/types' import * as Kb from '@/common-adapters' import UserNotice from '../user-notice' import {useCurrentUserState} from '@/stores/current-user' -import {useConversationThreadSelector} from '../../thread-context' +import {useThreadMeta} from '../../thread-context' import {makeMessageWrapper} from '../wrapper/wrapper' type OwnProps = {message: T.Chat.MessageSystemInviteAccepted} @@ -12,7 +12,7 @@ type OwnProps = {message: T.Chat.MessageSystemInviteAccepted} function SystemInviteAcceptedContainer(p: OwnProps) { const {message} = p const {role} = message - const teamID = useConversationThreadSelector(s => s.meta.teamID) + const teamID = useThreadMeta(m => m.teamID) const you = useCurrentUserState(s => s.username) const navigateAppend = C.Router2.navigateAppend const onViewTeam = () => { diff --git a/shared/chat/conversation/messages/system-joined/container.tsx b/shared/chat/conversation/messages/system-joined/container.tsx index 1986d5ea8400..71864861f5ad 100644 --- a/shared/chat/conversation/messages/system-joined/container.tsx +++ b/shared/chat/conversation/messages/system-joined/container.tsx @@ -1,17 +1,19 @@ import type * as T from '@/constants/types' +import * as C from '@/constants' import * as Kb from '@/common-adapters' import UserNotice from '../user-notice' import {getAddedUsernames} from '../system-users-added-to-conv/container' import {formatTimeForChat} from '@/util/timestamp' -import {useConversationThreadSelector} from '../../thread-context' +import {useThreadMeta} from '../../thread-context' type OwnProps = {message: T.Chat.MessageSystemJoined} function JoinedContainer(p: OwnProps) { const {message} = p const {joiners, author, leavers, timestamp} = message - const meta = useConversationThreadSelector(s => s.meta) - const {channelname, teamType, teamname} = meta + const {channelname, teamType, teamname} = useThreadMeta( + C.useShallow(m => ({channelname: m.channelname, teamType: m.teamType, teamname: m.teamname})) + ) const joiners2 = !joiners?.length && !leavers?.length ? [author] : joiners const isBigTeam = teamType === 'big' const multiProps = {channelname, isBigTeam, teamname, timestamp} diff --git a/shared/chat/conversation/messages/system-left/wrapper.tsx b/shared/chat/conversation/messages/system-left/wrapper.tsx index 54a0b4457c0d..4822f0149b97 100644 --- a/shared/chat/conversation/messages/system-left/wrapper.tsx +++ b/shared/chat/conversation/messages/system-left/wrapper.tsx @@ -1,11 +1,13 @@ +import * as C from '@/constants' import * as Kb from '@/common-adapters' import UserNotice from '../user-notice' -import {useConversationThreadSelector} from '../../thread-context' +import {useThreadMeta} from '../../thread-context' import {makeMessageWrapper} from '../wrapper/wrapper' function SystemLeft() { - const meta = useConversationThreadSelector(s => s.meta) - const {channelname, teamType, teamname} = meta + const {channelname, teamType, teamname} = useThreadMeta( + C.useShallow(m => ({channelname: m.channelname, teamType: m.teamType, teamname: m.teamname})) + ) const isBigTeam = teamType === 'big' return ( diff --git a/shared/chat/conversation/messages/system-new-channel/wrapper.tsx b/shared/chat/conversation/messages/system-new-channel/wrapper.tsx index e92f47391aa1..9a176b905db0 100644 --- a/shared/chat/conversation/messages/system-new-channel/wrapper.tsx +++ b/shared/chat/conversation/messages/system-new-channel/wrapper.tsx @@ -2,14 +2,14 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import type * as T from '@/constants/types' import UserNotice from '../user-notice' -import {useConversationThreadSelector} from '../../thread-context' +import {useThreadMeta} from '../../thread-context' import {makeMessageWrapper} from '../wrapper/wrapper' type OwnProps = {message: T.Chat.MessageSystemNewChannel} function SystemNewChannelContainer(p: OwnProps) { const {message} = p - const teamID = useConversationThreadSelector(s => s.meta.teamID) + const teamID = useThreadMeta(m => m.teamID) const navigateAppend = C.Router2.navigateAppend const onManageChannels = () => navigateAppend({name: 'teamAddToChannels', params: {teamID}}) diff --git a/shared/chat/conversation/messages/system-old-profile-reset-notice/container.tsx b/shared/chat/conversation/messages/system-old-profile-reset-notice/container.tsx index 4787e687d460..dd8f4deedda8 100644 --- a/shared/chat/conversation/messages/system-old-profile-reset-notice/container.tsx +++ b/shared/chat/conversation/messages/system-old-profile-reset-notice/container.tsx @@ -1,13 +1,16 @@ import type * as T from '@/constants/types' +import * as C from '@/constants' import {navigateToThread, previewConversation} from '@/constants/router' import {Text} from '@/common-adapters' import UserNotice from '../user-notice' -import {useConversationThreadID, useConversationThreadSelector} from '../../thread-context' +import {useConversationThreadID, useThreadMeta} from '../../thread-context' import {useConversationParticipants} from '../../data-hooks' const SystemOldProfileResetNotice = () => { const conversationIDKey = useConversationThreadID() - const meta = useConversationThreadSelector(s => s.meta) + const meta = useThreadMeta( + C.useShallow(m => ({supersededBy: m.supersededBy, wasFinalizedBy: m.wasFinalizedBy})) + ) const participantInfo = useConversationParticipants(conversationIDKey) const _participants = participantInfo.all const nextConversationIDKey = meta.supersededBy diff --git a/shared/chat/conversation/messages/system-profile-reset-notice.tsx b/shared/chat/conversation/messages/system-profile-reset-notice.tsx index f7f374565578..c80d1abffe6f 100644 --- a/shared/chat/conversation/messages/system-profile-reset-notice.tsx +++ b/shared/chat/conversation/messages/system-profile-reset-notice.tsx @@ -2,10 +2,12 @@ import * as C from '@/constants' import type * as T from '@/constants/types' import * as Kb from '@/common-adapters' import UserNotice from './user-notice' -import {useConversationThreadSelector} from '../thread-context' +import {useThreadMeta} from '../thread-context' const SystemProfileResetNotice = () => { - const meta = useConversationThreadSelector(s => s.meta) + const meta = useThreadMeta( + C.useShallow(m => ({supersedes: m.supersedes, wasFinalizedBy: m.wasFinalizedBy})) + ) const prevConversationIDKey = meta.supersedes const username = meta.wasFinalizedBy || '' const _onOpenOlderConversation = (conversationIDKey: T.Chat.ConversationIDKey) => { diff --git a/shared/chat/conversation/messages/system-simple-to-complex/wrapper.tsx b/shared/chat/conversation/messages/system-simple-to-complex/wrapper.tsx index 5ad69e3159c5..2b125d4b406d 100644 --- a/shared/chat/conversation/messages/system-simple-to-complex/wrapper.tsx +++ b/shared/chat/conversation/messages/system-simple-to-complex/wrapper.tsx @@ -3,14 +3,14 @@ import type * as T from '@/constants/types' import * as Kb from '@/common-adapters' import UserNotice from '../user-notice' import {useCurrentUserState} from '@/stores/current-user' -import {useConversationThreadSelector} from '../../thread-context' +import {useThreadMeta} from '../../thread-context' import {makeMessageWrapper} from '../wrapper/wrapper' type OwnProps = {message: T.Chat.MessageSystemSimpleToComplex} function SystemSimpleToComplexContainer(p: OwnProps) { const {message} = p - const teamID = useConversationThreadSelector(s => s.meta.teamID) + const teamID = useThreadMeta(m => m.teamID) const you = useCurrentUserState(s => s.username) const navigateAppend = C.Router2.navigateAppend const onManageChannels = () => navigateAppend({name: 'teamAddToChannels', params: {teamID}}) diff --git a/shared/chat/conversation/messages/system-users-added-to-conv/container.tsx b/shared/chat/conversation/messages/system-users-added-to-conv/container.tsx index 370127e3b1f6..1df6290e51c2 100644 --- a/shared/chat/conversation/messages/system-users-added-to-conv/container.tsx +++ b/shared/chat/conversation/messages/system-users-added-to-conv/container.tsx @@ -3,13 +3,13 @@ import type * as T from '@/constants/types' import * as Kb from '@/common-adapters' import UserNotice from '../user-notice' import {useCurrentUserState} from '@/stores/current-user' -import {useConversationThreadSelector} from '../../thread-context' +import {useThreadMeta} from '../../thread-context' type OwnProps = {message: T.Chat.MessageSystemUsersAddedToConversation} function UsersAddedToConversationContainer(p: OwnProps) { const {usernames} = p.message - const channelname = useConversationThreadSelector(s => s.meta.channelname) + const channelname = useThreadMeta(m => m.channelname) const you = useCurrentUserState(s => s.username) let otherUsers: Array | undefined if (usernames.includes(you)) { diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index 5a98868181a9..118763fcb6f1 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -27,6 +27,7 @@ import { useConversationThreadID, useConversationThreadMessageActions, useConversationThreadSelector, + useThreadMeta, } from '../../thread-context' import {useConversationParticipants} from '../../data-hooks' import type {ConversationInputState} from '../../input-area/input-state' @@ -204,7 +205,7 @@ function AuthorSection(p: AuthorProps) { const getAuthorData = ( message: T.Chat.Message, - meta: T.Chat.ConversationMeta, + meta: Pick, participants: T.Chat.ParticipantInfo, showUsername: string ): FlatAuthorData => { @@ -376,6 +377,14 @@ export const useMessageData = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: bo const shownCache = React.useContext(ShownUsernameCacheContext) const conversationIDKey = useConversationThreadID() const participantInfo = useConversationParticipants(conversationIDKey) + const authorMeta = useThreadMeta( + C.useShallow(m => ({ + botAliases: m.botAliases, + teamID: m.teamID, + teamType: m.teamType, + teamname: m.teamname, + })) + ) return useConversationThreadSelector( C.useShallow(s => { @@ -401,7 +410,7 @@ export const useMessageData = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: bo ...commonData, ...getEditCancelRetryData(commonData.ecrType, message), ...getRowActions(messageActions, uiDispatch, retryMessage), - ...getAuthorData(message, s.meta, participantInfo, showUsername), + ...getAuthorData(message, authorMeta, participantInfo, showUsername), message, } }) diff --git a/shared/chat/conversation/normal/container.test.tsx b/shared/chat/conversation/normal/container.test.tsx index ff34b4c5d23e..a65902885a01 100644 --- a/shared/chat/conversation/normal/container.test.tsx +++ b/shared/chat/conversation/normal/container.test.tsx @@ -96,6 +96,7 @@ jest.mock('../thread-context', () => ({ useConversationThreadSelector: ( selector: (state: {loaded: boolean; meta: T.Chat.ConversationMeta}) => unknown ) => selector({loaded: mockLoaded, meta: mockMeta}), + useThreadMeta: (selector: (meta: T.Chat.ConversationMeta) => unknown) => selector(mockMeta), })) jest.mock('../team-hooks', () => { diff --git a/shared/chat/conversation/normal/container.tsx b/shared/chat/conversation/normal/container.tsx index 8cb4c726c46f..9cbf91796c54 100644 --- a/shared/chat/conversation/normal/container.tsx +++ b/shared/chat/conversation/normal/container.tsx @@ -12,6 +12,7 @@ import {ConversationInputProvider} from '../input-area/input-state' import { useConversationThreadID, useConversationThreadSelector, + useThreadMeta, } from '../thread-context' import {ConversationThreadLoadStatusProvider} from '../thread-load-status-context' import {MaybeMentionProvider} from '@/common-adapters/markdown/maybe-mention/context' @@ -53,10 +54,12 @@ const useOrangeLine = ( React.useLayoutEffect(() => { currentOrangeLineKeyRef.current = {conversationIDKey: id, mobileAppState} }, [id, mobileAppState]) - const meta = useConversationThreadSelector(s => s.meta) + const {maxVisibleMsgID, readMsgID} = useThreadMeta( + C.useShallow(m => ({maxVisibleMsgID: m.maxVisibleMsgID, readMsgID: m.readMsgID})) + ) // Keep the read position from when this conversation mounted. Mark-as-read updates - // meta.readMsgID shortly after navigation, but the open thread should retain its orange line. - const [initialReadMsgID] = React.useState(() => meta.readMsgID) + // readMsgID shortly after navigation, but the open thread should retain its orange line. + const [initialReadMsgID] = React.useState(() => readMsgID) const loadOrangeLine = React.useEffectEvent( (conversationIDKey: T.Chat.ConversationIDKey, readMsgID: T.Chat.MessageID) => { @@ -97,15 +100,13 @@ const useOrangeLine = ( } }, [id, loaded, initialReadMsgID]) - const maxVisibleMsgID = meta.maxVisibleMsgID - // just use the rpc for orange line if we're not active // if we are active we want to keep whatever state we had so it is maintained React.useEffect(() => { if (!active) { - loadOrangeLine(id, meta.readMsgID) + loadOrangeLine(id, readMsgID) } - }, [maxVisibleMsgID, active, id, meta.readMsgID]) + }, [maxVisibleMsgID, active, id, readMsgID]) const setOrangeLine = React.useEffectEvent((ordinal: T.Chat.Ordinal) => { const currentKey = currentOrangeLineKeyRef.current @@ -136,8 +137,8 @@ const useOrangeLine = ( const useShowManageChannels = () => { const navigateAppend = C.Router2.navigateAppend - const {teamID, teamname} = useConversationThreadSelector( - C.useShallow(s => ({teamID: s.meta.teamID, teamname: s.meta.teamname})) + const {teamID, teamname} = useThreadMeta( + C.useShallow(m => ({teamID: m.teamID, teamname: m.teamname})) ) useEngineActionListener('chat.1.chatUi.chatShowManageChannels', action => { if ( diff --git a/shared/chat/conversation/normal/index.tsx b/shared/chat/conversation/normal/index.tsx index d5bd9255acee..7155b2ed7616 100644 --- a/shared/chat/conversation/normal/index.tsx +++ b/shared/chat/conversation/normal/index.tsx @@ -11,8 +11,8 @@ import ThreadLoadStatus from '../load-status' import {useConversationCenterActions} from '../center-context' import { useConversationThreadID, - useConversationThreadSelector, useConversationThreadToggleSearch, + useThreadMeta, } from '../thread-context' import {useThreadSearchRoute} from '../thread-search-route' import {indefiniteArticle} from '@/util/string' @@ -54,9 +54,9 @@ const DesktopConversation = function DesktopConversation() { }) } const showThreadSearch = !!useThreadSearchRoute() - const meta = useConversationThreadSelector(s => s.meta) - const {cannotWrite, minWriterRole} = meta - const threadLoadedOffline = meta.offline + const {cannotWrite, minWriterRole, offline: threadLoadedOffline} = useThreadMeta( + C.useShallow(m => ({cannotWrite: m.cannotWrite, minWriterRole: m.minWriterRole, offline: m.offline})) + ) const dragAndDropRejectReason = cannotWrite ? `You must be at least ${indefiniteArticle(minWriterRole)} ${minWriterRole} to post.` : undefined @@ -133,7 +133,7 @@ const NativeConversation = function NativeConversation() { const safeStyle = {height, maxHeight: height, minHeight: height} - const threadLoadedOffline = useConversationThreadSelector(s => s.meta.offline) + const threadLoadedOffline = useThreadMeta(m => m.offline) const stickyOffset = React.useMemo(() => ({closed: -insets.bottom, opened: 0}), [insets.bottom]) diff --git a/shared/chat/conversation/pinned-message.tsx b/shared/chat/conversation/pinned-message.tsx index 2f020d523f64..56d5842321fb 100644 --- a/shared/chat/conversation/pinned-message.tsx +++ b/shared/chat/conversation/pinned-message.tsx @@ -7,17 +7,17 @@ import {useCurrentUserState} from '@/stores/current-user' import {useChatTeam} from './team-hooks' import {ZoomedImage} from './common' import {useConversationCenterActions} from './center-context' -import {useConversationThreadID, useConversationThreadSelector} from './thread-context' +import {useConversationThreadID, useThreadMeta} from './thread-context' import logger from '@/logger' import {RPCError} from '@/util/errors' const PinnedMessage = function PinnedMessage() { const conversationIDKey = useConversationThreadID() - const {pinnedMsg, teamID, teamname} = useConversationThreadSelector( - C.useShallow(s => ({ - pinnedMsg: s.meta.pinnedMsg, - teamID: s.meta.teamID, - teamname: s.meta.teamname, + const {pinnedMsg, teamID, teamname} = useThreadMeta( + C.useShallow(m => ({ + pinnedMsg: m.pinnedMsg, + teamID: m.teamID, + teamname: m.teamname, })) ) const {centerOnMessage} = useConversationCenterActions() diff --git a/shared/chat/conversation/rekey/container.tsx b/shared/chat/conversation/rekey/container.tsx index aa0fdb56a422..e420e15b9378 100644 --- a/shared/chat/conversation/rekey/container.tsx +++ b/shared/chat/conversation/rekey/container.tsx @@ -4,11 +4,11 @@ import * as T from '@/constants/types' import ParticipantRekey from './participant-rekey' import YouRekey from './you-rekey' import {navToProfile} from '@/constants/router' -import {useConversationThreadSelector} from '../thread-context' +import {useThreadMeta} from '../thread-context' const Container = () => { const _you = useCurrentUserState(s => s.username) - const rekeyers = useConversationThreadSelector(s => s.meta.rekeyers) + const rekeyers = useThreadMeta(m => m.rekeyers) const onBack = C.Router2.navigateUp const navigateAppend = C.Router2.navigateAppend const onEnterPaperkey = () => { diff --git a/shared/chat/conversation/send-actions.tsx b/shared/chat/conversation/send-actions.tsx index 6ae77b3b000b..7767dbb677c8 100644 --- a/shared/chat/conversation/send-actions.tsx +++ b/shared/chat/conversation/send-actions.tsx @@ -9,6 +9,7 @@ import { useConversationThreadActions, useConversationThreadID, useConversationThreadSelector, + useThreadMeta, } from './thread-context' type SendTextParams = { @@ -82,14 +83,14 @@ export const sendTextToConversation = ( export const useConversationSendActions = () => { const conversationIDKey = useConversationThreadID() const actions = useConversationThreadActions() - const {explodingMode, messageMap, messageOrdinals, meta} = useConversationThreadSelector( + const {explodingMode, messageMap, messageOrdinals} = useConversationThreadSelector( C.useShallow(s => ({ explodingMode: s.explodingMode, messageMap: s.messageMap, messageOrdinals: s.messageOrdinals, - meta: s.meta, })) ) + const meta = useThreadMeta(C.useShallow(m => ({tlfname: m.tlfname}))) const clientPrev = getClientPrevFromThread(messageMap, messageOrdinals) const editMessage = (ordinal: T.Chat.Ordinal, text: string) => { diff --git a/shared/chat/conversation/team-hooks.tsx b/shared/chat/conversation/team-hooks.tsx index fcb9e1bdee01..9e9732aeb2b9 100644 --- a/shared/chat/conversation/team-hooks.tsx +++ b/shared/chat/conversation/team-hooks.tsx @@ -8,7 +8,7 @@ import logger from '@/logger' import * as React from 'react' import {useTeamsListMap, useTeamsRoleMap} from '@/teams/use-teams-list' import {updateChosenChannelsTeamnames, useChosenChannelsTeamnames} from './manage-channels-badge' -import {useConversationThreadSelector} from './thread-context' +import {useThreadMeta} from './thread-context' type ChatTeamState = { allowPromote: boolean @@ -504,11 +504,11 @@ ChatTeamContext.displayName = 'ChatTeamContext' export const ChatTeamProvider = (props: React.PropsWithChildren) => { const {children} = props - const {teamID, teamType, teamname} = useConversationThreadSelector( - C.useShallow(s => ({ - teamID: s.meta.teamID, - teamType: s.meta.teamType, - teamname: s.meta.teamname, + const {teamID, teamType, teamname} = useThreadMeta( + C.useShallow(m => ({ + teamID: m.teamID, + teamType: m.teamType, + teamname: m.teamname, })) ) const outer = React.useContext(ChatTeamContext) diff --git a/shared/chat/conversation/thread-context.test.tsx b/shared/chat/conversation/thread-context.test.tsx index b60a38891d83..0b1f67bd9ce0 100644 --- a/shared/chat/conversation/thread-context.test.tsx +++ b/shared/chat/conversation/thread-context.test.tsx @@ -170,43 +170,6 @@ const makeIncomingOutboxReaction = ( pagination: null, }) -const makeUnverifiedInboxUIItem = (): T.RPCChat.UnverifiedInboxUIItem => ({ - commands: {typ: T.RPCChat.ConversationCommandGroupsTyp.none}, - convID: T.Chat.conversationIDKeyToString(convID), - convRetention: null, - draft: null, - finalizeInfo: null, - isDefaultConv: false, - isPublic: false, - localMetadata: { - channelName: '', - headline: '', - headlineDecorated: '', - resetParticipants: null, - snippet: '', - snippetDecoration: T.RPCChat.SnippetDecoration.none, - writerNames: null, - }, - localVersion: 1, - maxMsgID: T.Chat.messageIDToNumber(T.Chat.numberToMessageID(301)), - maxVisibleMsgID: T.Chat.messageIDToNumber(T.Chat.numberToMessageID(301)), - memberStatus: T.RPCChat.ConversationMemberStatus.active, - membersType: T.RPCChat.ConversationMembersType.impteamnative, - name: 'alice,bob,charlie', - notifications: null, - readMsgID: 0, - status: T.RPCChat.ConversationStatus.unfiled, - supersededBy: null, - supersedes: null, - teamRetention: null, - teamType: T.RPCChat.TeamType.simple, - time: 1, - tlfID: 'tlf-id', - topicType: T.RPCChat.TopicType.chat, - version: 1, - visibility: T.RPCGen.TLFVisibility.private, -}) - const makeFailedOutboxRecord = ( conversationIDKey: T.Chat.ConversationIDKey, outboxID: T.Chat.OutboxID @@ -1137,45 +1100,6 @@ test('toggleMessageReaction overlays locally without mutating server reactions', }) }) -test('mounted thread listener applies inbox failure metadata for the active conversation', () => { - const {result} = renderHook( - () => ({ - meta: useConversationThreadSelector(s => s.meta), - participants: useConversationParticipants(convID), - }), - {wrapper} - ) - - act(() => { - notifyEngineActionListeners({ - payload: { - params: { - convID: T.Chat.keyToConversationID(convID), - error: { - message: 'rekey needed', - rekeyInfo: { - readerNames: ['charlie'], - rekeyers: ['bob'], - tlfName: 'alice,bob,charlie', - tlfPublic: false, - writerNames: ['alice', 'bob'], - }, - remoteConv: makeUnverifiedInboxUIItem(), - typ: T.RPCChat.ConversationErrorType.otherrekeyneeded, - unverifiedTLFName: 'alice,bob,charlie', - }, - }, - }, - type: 'chat.1.chatUi.chatInboxFailed', - } as never) - }) - - expect(result.current.meta.trustedState).toBe('error') - expect(result.current.meta.snippet).toBe('rekey needed') - expect([...result.current.meta.rekeyers]).toEqual(['bob']) - expect(result.current.participants.name).toEqual(['alice', 'bob', 'charlie']) -}) - test('mounted thread listener applies request and payment decorators for the active conversation', () => { const {result} = renderHook( () => ({ diff --git a/shared/chat/conversation/thread-context.tsx b/shared/chat/conversation/thread-context.tsx index feec0df161fa..3ba88b015ea6 100644 --- a/shared/chat/conversation/thread-context.tsx +++ b/shared/chat/conversation/thread-context.tsx @@ -1,7 +1,6 @@ import * as Common from '@/constants/chat/common' import * as Message from '@/constants/chat/message' import * as Meta from '@/constants/chat/meta' -import * as TeamsUtil from '@/constants/teams' import * as React from 'react' import * as Strings from '@/constants/strings' import * as T from '@/constants/types' @@ -56,8 +55,9 @@ import { getInboxConversationMeta, getInboxConversationParticipants, metasReceived, - participantInfoReceived, unboxRows, + updateInboxConversationMeta, + useInboxMetadataState, } from '@/chat/inbox/metadata' import { loadThreadMessageIDAtIndex, @@ -184,6 +184,12 @@ const getClientPrevFromSnapshot = (snapshot: ConversationThreadState): T.Chat.Me return message?.id || T.Chat.numberToMessageID(0) } +// The inbox metadata store is the single owner of conversation meta; fall back to +// an empty meta for reads that predate an unbox. +const emptyConversationMeta = Meta.makeConversationMeta() +const getMeta = (id: T.Chat.ConversationIDKey) => + getInboxConversationMeta(id) ?? emptyConversationMeta + const ConversationThreadIDContext = React.createContext(undefined) ConversationThreadIDContext.displayName = 'ConversationThreadIDContext' @@ -193,7 +199,6 @@ export type ConversationThreadState = { flipStatusMap: Map loaded: boolean liveUpdateVersion: number - meta: T.Chat.ConversationMeta messageIDToOrdinal: Map messageMap: Map messageOrdinals?: ReadonlyArray @@ -232,7 +237,6 @@ const makeEmptyThreadState = (): ConversationThreadState => messageMap: new Map(), messageOrdinals: undefined as ReadonlyArray | undefined, messageTypeMap: new Map(), - meta: Meta.makeConversationMeta(), moreToLoadBack: false, moreToLoadForward: false, optimisticReactionMap: new Map(), @@ -246,11 +250,7 @@ const makeEmptyThreadState = (): ConversationThreadState => ) const makeInitialThreadState = (id: T.Chat.ConversationIDKey) => { - const meta = getInboxConversationMeta(id) return produce(makeEmptyThreadState(), s => { - if (meta) { - s.meta = T.castDraft(meta) - } s.explodingMode = getExplodingModeFromConfig(id) }) } @@ -344,7 +344,6 @@ type ConversationThreadActions = { receiveRequestInfo: (messageID: T.Chat.MessageID, requestInfo: T.Chat.ChatRequestInfo) => void retryMessage: (outboxID: T.Chat.OutboxID) => void setExplodingMode: (seconds: number, incoming?: boolean) => void - setMeta: (meta?: T.Chat.ConversationMeta) => void setMessageErrored: (outboxID: T.Chat.OutboxID, reason: string, errorTyp?: number) => void setMessageSubmitState: (ordinal: T.Chat.Ordinal, submitState: T.Chat.Message['submitState']) => void setMarkAsUnread: (readMsgID?: T.Chat.MessageID | false) => void @@ -371,7 +370,6 @@ type ConversationThreadActions = { bytesComplete?: number, bytesTotal?: number ) => void - updateMeta: (meta: Partial) => void } const ConversationThreadActionsContext = React.createContext( @@ -677,30 +675,6 @@ const applyEphemeralPurgeToThread = ( } } -const applyConversationMetaToThread = ( - meta: T.Chat.ConversationMeta | undefined, - actions: ConversationThreadActions -) => { - if (!meta) { - return - } - const oldMeta = actions.getSnapshot().meta - if (oldMeta.conversationIDKey === meta.conversationIDKey) { - actions.setMeta(Meta.updateMeta(oldMeta, meta)) - } else { - actions.setMeta(meta) - } -} - -const applyInboxUIItemToThread = ( - conv: T.RPCChat.InboxUIItem | null | undefined, - actions: ConversationThreadActions -) => { - if (conv) { - applyConversationMetaToThread(Meta.inboxUIItemToConversationMeta(conv), actions) - } -} - const loadConversationThreadMessages = ( conversationIDKey: T.Chat.ConversationIDKey, p: LoadMoreMessagesParams, @@ -734,7 +708,7 @@ const loadConversationThreadMessages = ( } const loadStartedSnapshot = actions.getSnapshot() - const currentMeta = loadStartedSnapshot.meta + const currentMeta = getMeta(conversationIDKey) if (currentMeta.membershipType === 'youAreReset' || currentMeta.rekeyers.size > 0) { logger.info('loadMoreMessages: bail: we are reset') return @@ -842,9 +816,7 @@ const loadConversationThreadMessages = ( if (!isCurrentThreadLoad()) { return } - if (actions.getSnapshot().meta.conversationIDKey === conversationIDKey) { - actions.updateMeta({offline: results.offline}) - } + updateInboxConversationMeta(conversationIDKey, {offline: results.offline}) } catch (error) { if (!isCurrentThreadLoad()) { return @@ -886,6 +858,16 @@ export const useConversationThreadStore = () => { return store } +// Reads the meta for the current thread from its single owner (the inbox metadata +// store). Pass a narrow selector (wrap object results in C.useShallow) so render-hot +// callers don't re-render on unrelated meta churn (e.g. draft updates). +export const useThreadMeta = ( + selector: (meta: T.Immutable) => TValue +): TValue => { + const id = useConversationThreadID() + return useInboxMetadataState(s => selector(s.metas.get(id) ?? emptyConversationMeta)) +} + type ConversationThreadProviderProps = React.PropsWithChildren<{ id: T.Chat.ConversationIDKey }> @@ -976,7 +958,7 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => logger.info(`marking read messages ${id} failed due to no id`) return } - if (snapshot.meta.conversationIDKey === id && readMsgID === snapshot.meta.readMsgID) { + if (readMsgID === getInboxConversationMeta(id)?.readMsgID) { logger.info(`marking read messages is noop bail: ${id} ${readMsgID}`) return } @@ -1049,7 +1031,7 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => // moreToLoadForward true would drop live incoming messages and block mark-read. let containsLatest = false if (p.centered && p.forceContainsLatestCalc) { - const {maxVisibleMsgID} = s.meta + const {maxVisibleMsgID} = getMeta(id) const ordinal = findLast(s.messageOrdinals ?? [], o => !!s.messageMap.get(o)?.id) const message = ordinal ? s.messageMap.get(ordinal) : undefined containsLatest = !!message?.id && maxVisibleMsgID > 0 && message.id >= maxVisibleMsgID @@ -1124,24 +1106,7 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => s.explodingMode = seconds }) if (!incoming) { - persistExplodingMode(id, getSnapshot().meta, seconds) - } - }) - const setMeta = React.useEffectEvent((meta?: T.Chat.ConversationMeta) => { - updateThreadState(s => { - s.meta = T.castDraft(meta ?? Meta.makeConversationMeta()) - }) - if (meta) { - metasReceived([getSnapshot().meta]) - } - }) - const updateMeta = React.useEffectEvent((meta: Partial) => { - updateThreadState(s => { - Object.assign(s.meta, meta) - }) - const nextMeta = getSnapshot().meta - if (nextMeta.conversationIDKey === id) { - metasReceived([nextMeta]) + persistExplodingMode(id, getMeta(id), seconds) } }) const setMarkAsUnread = React.useEffectEvent((readMsgID?: T.Chat.MessageID | false) => { @@ -1154,7 +1119,7 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => return } const snapshot = getSnapshot() - const unreadLineID = readMsgID ? readMsgID : snapshot.meta.maxVisibleMsgID + const unreadLineID = readMsgID ? readMsgID : getMeta(id).maxVisibleMsgID let msgID = unreadLineID if (snapshot.messageMap.size) { @@ -1211,7 +1176,7 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => revertDeleting() return } - if (snapshot.meta.conversationIDKey !== id) { + if (!getInboxConversationMeta(id)) { logger.warn('Deleting message w/ no meta') revertDeleting() return @@ -1230,7 +1195,7 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => await postConversationDelete({ conversationIDKey: id, messageID: message.id, - tlfName: snapshot.meta.tlfname, + tlfName: getMeta(id).tlfname, }) } catch (error) { revertDeleting() @@ -1367,7 +1332,7 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => conversationIDKey: id, messageID, outboxID, - tlfName: snapshot.meta.tlfname, + tlfName: getMeta(id).tlfname, }) } catch (error) { removeOptimisticReaction(localOutboxID) @@ -1380,15 +1345,14 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => }) const unfurlRemove = React.useEffectEvent((messageID: T.Chat.MessageID) => { const f = async () => { - const snapshot = getSnapshot() - if (snapshot.meta.conversationIDKey !== id) { + if (!getInboxConversationMeta(id)) { logger.debug('unfurl remove no meta found, aborting!') return } await postConversationDelete({ conversationIDKey: id, messageID, - tlfName: snapshot.meta.tlfname, + tlfName: getMeta(id).tlfname, }) } ignorePromise(f()) @@ -1580,7 +1544,6 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => setMarkReadBlocked, setMessageErrored, setMessageSubmitState, - setMeta, setTyping, showUnfurlPrompt, startAttachmentDownload, @@ -1590,7 +1553,6 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => updateAttachmentDownloadProgress, updateAttachmentUploadProgress, updateCoinFlipStatuses, - updateMeta, updateOptimisticReactionDecorated, updateReactions, } @@ -1609,48 +1571,10 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => const {incomingMessage} = activity const conversationIDKey = T.Chat.conversationIDToKey(incomingMessage.convID) if (conversationIDKey === id) { - applyInboxUIItemToThread(incomingMessage.conv, threadActions) applyIncomingMessageToThread(conversationIDKey, incomingMessage, threadActions) } break } - case T.RPCChat.ChatActivityType.setStatus: { - const {setStatus} = activity - const conversationIDKey = setStatus.conv - ? T.Chat.stringToConversationIDKey(setStatus.conv.convID) - : T.Chat.noConversationIDKey - if (conversationIDKey === id) { - applyInboxUIItemToThread(setStatus.conv, threadActions) - } - break - } - case T.RPCChat.ChatActivityType.readMessage: { - const {readMessage} = activity - const conversationIDKey = readMessage.conv - ? T.Chat.stringToConversationIDKey(readMessage.conv.convID) - : T.Chat.noConversationIDKey - if (conversationIDKey === id) { - applyInboxUIItemToThread(readMessage.conv, threadActions) - } - break - } - case T.RPCChat.ChatActivityType.newConversation: { - const {newConversation} = activity - const conversationIDKey = newConversation.conv - ? T.Chat.stringToConversationIDKey(newConversation.conv.convID) - : T.Chat.noConversationIDKey - if (conversationIDKey === id) { - applyInboxUIItemToThread(newConversation.conv, threadActions) - } - break - } - case T.RPCChat.ChatActivityType.setAppNotificationSettings: { - const {setAppNotificationSettings} = activity - if (T.Chat.conversationIDToKey(setAppNotificationSettings.convID) === id) { - threadActions.updateMeta(Meta.parseNotificationSettings(setAppNotificationSettings.settings)) - } - break - } case T.RPCChat.ChatActivityType.messagesUpdated: { const {messagesUpdated} = activity const conversationIDKey = T.Chat.conversationIDToKey(messagesUpdated.convID) @@ -1661,7 +1585,6 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => } case T.RPCChat.ChatActivityType.failedMessage: { const {failedMessage} = activity - applyInboxUIItemToThread(failedMessage.conv, threadActions) applyFailedMessageToThread(id, failedMessage, threadActions) break } @@ -1706,77 +1629,6 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => threadActions.setExplodingMode(seconds, true) } }) - useEngineActionListener('chat.1.NotifyChat.ChatConvUpdate', action => { - const {conv} = action.payload.params - const conversationIDKey = conv ? T.Chat.stringToConversationIDKey(conv.convID) : T.Chat.noConversationIDKey - if (conversationIDKey === id) { - applyInboxUIItemToThread(conv, threadActions) - } - }) - useEngineActionListener('chat.1.chatUi.chatInboxFailed', action => { - const {convID, error} = action.payload.params - if (T.Chat.conversationIDToKey(convID) !== id) { - return - } - const {meta, participants} = Meta.inboxUIItemErrorToConversationMetaAndParticipants( - error, - useCurrentUserState.getState().username, - threadActions.getSnapshot().meta - ) - if (meta) { - threadActions.setMeta(meta) - } - if (participants) { - participantInfoReceived(id, participants, meta) - } - }) - useEngineActionListener('chat.1.NotifyChat.ChatSetConvSettings', action => { - const {conv, convID} = action.payload.params - if (T.Chat.conversationIDToKey(convID) !== id) { - return - } - const newRole = conv?.convSettings?.minWriterRoleInfo?.role - const role = newRole && TeamsUtil.teamRoleByEnum[newRole] - const cannotWrite = conv?.convSettings?.minWriterRoleInfo?.cannotWrite || false - if (role) { - threadActions.updateMeta({cannotWrite, minWriterRole: role}) - } else { - logger.warn( - `got NotifyChat.ChatSetConvSettings with no valid minWriterRole for convID ${id}. The local version may be out of date.` - ) - } - }) - useEngineActionListener('chat.1.NotifyChat.ChatSetConvRetention', action => { - const {conv, convID} = action.payload.params - if (T.Chat.conversationIDToKey(convID) !== id) { - return - } - 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 - } - applyConversationMetaToThread(meta, threadActions) - }) - useEngineActionListener('chat.1.NotifyChat.ChatSetTeamRetention', action => { - const meta = (action.payload.params.convs ?? []).reduce( - (found, conv) => { - if (found) { - return found - } - const meta = Meta.inboxUIItemToConversationMeta(conv) - return meta?.conversationIDKey === id ? meta : undefined - }, - undefined - ) - if (meta) { - applyConversationMetaToThread(meta, threadActions) - } - }) useEngineActionListener('chat.1.NotifyChat.ChatRequestInfo', action => { const {convID, info, msgID} = action.payload.params if (T.Chat.conversationIDToKey(convID) !== id) { diff --git a/shared/teams/team/settings-tab/retention/index.tsx b/shared/teams/team/settings-tab/retention/index.tsx index ff7010d9edce..f51eb1ea05ea 100644 --- a/shared/teams/team/settings-tab/retention/index.tsx +++ b/shared/teams/team/settings-tab/retention/index.tsx @@ -8,7 +8,7 @@ import SaveIndicator from '@/common-adapters/save-indicator' import {useEngineActionListener} from '@/engine/action-listener' import {useLoadedTeam} from '../../use-loaded-team' import {useConfirm} from './use-confirm' -import {ConversationThreadProvider, useConversationThreadSelector} from '@/chat/conversation/thread-context' +import {ConversationThreadProvider, useThreadMeta} from '@/chat/conversation/thread-context' export type RetentionEntityType = 'adhoc' | 'channel' | 'small team' | 'big team' @@ -491,7 +491,7 @@ const Container = (ownProps: OwnProps) => { } const ConversationPolicyContainer = (ownProps: OwnProps & {conversationIDKey: T.Chat.ConversationIDKey}) => { - const policy = useConversationThreadSelector(s => s.meta.retentionPolicy) + const policy = useThreadMeta(m => m.retentionPolicy) return } From a47184526f0419907e2b8e9794bc8134629059ad Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 3 Jul 2026 11:52:33 -0400 Subject: [PATCH 06/18] feat(chat): dedicated inbox badge store --- plans/2026-07-03-chat-data-simplify.md | 372 ++++++++++++++++++ shared/chat/inbox/badge-state.test.ts | 46 +++ shared/chat/inbox/badge-state.tsx | 37 ++ shared/chat/inbox/metadata.tsx | 2 + .../inbox/row/teams-divider-container.tsx | 6 +- shared/chat/inbox/use-inbox-state.tsx | 6 +- 6 files changed, 463 insertions(+), 6 deletions(-) create mode 100644 plans/2026-07-03-chat-data-simplify.md create mode 100644 shared/chat/inbox/badge-state.test.ts create mode 100644 shared/chat/inbox/badge-state.tsx diff --git a/plans/2026-07-03-chat-data-simplify.md b/plans/2026-07-03-chat-data-simplify.md new file mode 100644 index 000000000000..f0139386791a --- /dev/null +++ b/plans/2026-07-03-chat-data-simplify.md @@ -0,0 +1,372 @@ +# Chat Data Layer Simplification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Collapse duplicated chat data ownership on the JS side — one owner for conversation meta, no hand-maintained inbox row cache, a decomposed thread-context — plus a set of small state cleanups. + +**Architecture:** Conversation meta/participants get a single owner (`useInboxMetadataState`); the per-conversation thread store stops holding copies and stops writing back. The `rows-state` materialized view is replaced by selector hooks computed from meta + a new tiny badge store + layout + typing. `thread-context.tsx` is split into store/actions, engine listeners, and load logic. Converters and parse paths are deduplicated. Each task is one commit on a fresh branch. + +**Tech Stack:** TypeScript, React, zustand (global via `Z.createZustand`, per-conversation via vanilla `createStore`), immer, jest. + +## Global Constraints + +- Repo root is `client/`; TS source in `shared/`. All Bash runs `cd /Users/chrisnojima/go/src/github.com/keybase/client/shared` first. File ops use absolute paths. +- After TS changes: `yarn lint` then `yarn tsc` (from `shared/`). Both must be clean before each commit. tsc errors are never "pre-existing" (exception: known `react-native-kb` `install` error in client2 — not in this tree's tsc run). +- Never use `npm`. Never touch Electron app or iOS simulator. User verifies visuals. +- No `Co-Authored-By` in commits. +- No DOM elements in plain `.tsx`; use `Kb.*`. +- Remove unused code (imports, vars, params, dead helpers) in every file touched. +- Comments only for non-obvious constraints; no refactoring-history comments. +- In tests use `testuser` / `testuser-mac` usernames. +- Do NOT push. Branch stays local until the user verifies the app. +- Behavior-preserving refactor: no feature/behavior drops. If a task forces a behavior question, STOP and surface options instead of deciding silently. +- `git diff` output is rtk-filtered to stats in this environment; to inspect content use `git show` / read files directly. +- Existing tests that must keep passing: `yarn jest stores chat` (covers `shared/stores/tests/*` and `shared/chat/**/*.test.*`). Run per task; full `yarn jest` before final handoff. + +--- + +### Task 0: Branch setup + +**Files:** none (git only) + +- [ ] **Step 1: Create branch** + +```bash +cd /Users/chrisnojima/go/src/github.com/keybase/client +git checkout -b nojima/chat-data-simplify +``` + +- [ ] **Step 2: Baseline validation** — record a clean starting point. + +```bash +cd shared && yarn lint && yarn tsc && yarn jest stores chat +``` + +Expected: all pass. If anything fails at baseline, STOP and report (do not fix pre-existing failures inside this plan). + +--- + +### Task 1: Unify InboxUIItem→ConversationMeta converters + +**Files:** +- Modify: `shared/constants/chat/meta.tsx` +- Test: `shared/stores/tests/chat.test.ts` (extend) or new `shared/constants/chat/meta.test.tsx` if converters aren't covered there. + +**Interfaces:** +- Produces: internal helper `baseMetaFromUIItem` in `meta.tsx`. Public exports (`inboxUIItemToConversationMeta`, `unverifiedInboxUIItemToConversationMeta`, `inboxUIItemErrorToConversationMetaAndParticipants`, `makeConversationMeta`, `updateMeta`, `parseNotificationSettings`, `getEffectiveRetentionPolicy`, `getRowParticipants`, `getTeams`, `getTeamType` if exported) unchanged in name and signature. + +`inboxUIItemToConversationMeta` (meta.tsx:276) and `unverifiedInboxUIItemToConversationMeta` (meta.tsx:27) share ~20 field mappings. Extract the shared subset. `InboxUIItem` and `UnverifiedInboxUIItem` share: `convID`, `name`, `status`, `membersType`, `memberStatus`, `visibility`, `time`, `version`, `localVersion`, `maxMsgID`, `maxVisibleMsgID`, `readMsgID`, `convRetention`, `teamRetention`, `notifications`, `supersededBy`, `supersedes`, `finalizeInfo`, `draft`, `commands`, `teamType`, `tlfID`. Verify the exact shared shape against `T.RPCChat` types before writing the helper — if a listed field is missing on one type, move it back to the specific converter. + +- [ ] **Step 1: Write characterization tests first** (before touching converters). Build a representative `InboxUIItem` and `UnverifiedInboxUIItem` fixture (team + adhoc + muted + retention-set variants), snapshot/assert the produced metas field-by-field for the important fields (`trustedState`, `snippet`, `channelname`, `teamname`, `resetParticipants`, `retentionPolicy`, `notificationsDesktop`, `supersededBy`). Run: `yarn jest ` — expect PASS against current code. + +```ts +// shape of the test (fill fixtures from T.RPCChat types): +describe('meta converters', () => { + it('trusted item maps fields', () => { + const meta = inboxUIItemToConversationMeta(trustedFixture) + expect(meta?.trustedState).toBe('trusted') + // ... field asserts + }) + it('unverified item maps fields', () => { + const meta = unverifiedInboxUIItemToConversationMeta(unverifiedFixture) + expect(meta?.trustedState).toBe('untrusted') + // ... field asserts; assert fields the unverified path must NOT set (botAliases etc. stay defaults) + }) +}) +``` + +- [ ] **Step 2: Extract `baseMetaFromUIItem`** covering the shared mappings (visibility guard, resetParticipants impteam logic, supersede decode, retention, notifications, membershipType, ids/versions/timestamps, draft, status, tlfname, wasFinalizedBy, teamType via `getTeamType`). Each public converter becomes: guard clauses specific to it + `{...makeConversationMeta(), ...baseMetaFromUIItem(i, isTeam), ...specific fields}`. +- [ ] **Step 3: Run tests** — `yarn jest ` PASS unchanged. +- [ ] **Step 4: Validate + commit** + +```bash +yarn lint && yarn tsc && yarn jest stores chat +git add -A && git commit -m "refactor(chat): dedupe InboxUIItem meta converters via shared base helper" +``` + +--- + +### Task 2: Shared UIMessages parse helper + +**Files:** +- Modify: `shared/constants/chat/message.tsx` (add `parseUIMessagesJSON`), `shared/chat/conversation/data-hooks.tsx:160-189`, `shared/chat/conversation/thread-context.tsx:781-796` + +**Interfaces:** +- Produces: `export const parseUIMessagesJSON = (conversationIDKey: T.Chat.ConversationIDKey, threadJSON: string, username: string, devicename: string, getLastOrdinal: () => T.Chat.Ordinal) => Array` in `constants/chat/message.tsx`. JSON.parse + per-message `uiMessageToMessage`, dropping nulls. Errors: catch, `logger.warn`, return `[]` (matching data-hooks behavior; thread-context currently doesn't catch — keep thread-context's call NOT swallowing? No: unify on catch+warn+[] and verify thread-context call sites tolerate empty array — they do, `applyThreadLoad` with 0 messages is a no-op add). +- Consumers keep their own `getLastOrdinal` semantics: thread-context passes its snapshot-based lambda; data-hooks passes its running-max lambda. + +- [ ] **Step 1: Add helper to `message.tsx`** (near `uiMessageToMessage`). +- [ ] **Step 2: Replace `parseThreadMessages` body in data-hooks** with a call to the helper (keep the running-max `getLastOrdinal` wrapper local). +- [ ] **Step 3: Replace the inline parse block in `loadConversationThreadMessages`** (`thread-context.tsx:782-796`) with the helper. +- [ ] **Step 4: Validate + commit** (same commands). Commit: `refactor(chat): shared UIMessages JSON parse helper` + +--- + +### Task 3: Meta single-ownership — remove participants copy from thread store + +Smallest slice of the ownership change first: participants. + +**Files:** +- Modify: `shared/chat/conversation/thread-context.tsx` +- Modify: any component reading `participants` from the thread store (grep below) + +**Interfaces:** +- Consumes: `useInboxMetadataState`, `getInboxConversationParticipants`, `participantInfoReceived` from `@/chat/inbox/metadata`; `useConversationParticipants` from `./data-hooks`. +- Produces: `ConversationThreadState` no longer has `participants`; `ConversationThreadActions` no longer has `setParticipants`. + +Current wiring to remove: store field (`thread-context.tsx:206,241,258-260`), mirror effect (`:1629-1637`), `setParticipants` action (`:1164-1169`), `ChatParticipantsInfo` listener (`:1813-1818` — global coverage already exists: `chat/inbox/engine.tsx` routes `ChatParticipantsInfo` → `syncInboxParticipantsFromParticipantMap`, which writes the inbox store the mirror effect was reading from), `chatInboxFailed` participants branch (`:1762-1764` — replace with direct `participantInfoReceived(id, participants, meta)`). + +- [ ] **Step 1: Find all readers** + +```bash +rg -n "s\.participants|snapshot\.participants|state\.participants" chat/ --glob '*.tsx' | rg -v inbox/ +rg -n "useConversationThreadSelector\(" chat/ -A2 | rg -i participants +``` + +- [ ] **Step 2: Repoint readers** to `useConversationParticipants(conversationIDKey)` (data-hooks) or, in non-hook code, `getInboxConversationParticipants(id) ?? emptyParticipantInfo`. `useConversationThreadSelectedConversation` (`thread-context.tsx:2057-2083`) reads `s.participants` — switch it to `getInboxConversationParticipants(conversationIDKey)` with an empty-info fallback. +- [ ] **Step 3: Delete** store field, `makeEmptyParticipantInfo` usage in state (keep the helper if still used elsewhere), initial seeding, mirror effect, `setParticipants` action + type, `ChatParticipantsInfo` listener. In the `chatInboxFailed` listener, replace `threadActions.setParticipants(participants)` with `participantInfoReceived(id, participants, meta)`. +- [ ] **Step 4: Verify no global-coverage regression.** Confirm `chat/inbox/engine.tsx` handles `ChatParticipantsInfo` unconditionally (not only for inbox-visible convs) — read `handleConvoEngineIncoming` and the wiring in `constants/init/shared.tsx:334-460`. If coverage is conditional, keep an equivalent write via `participantInfoReceived` where the listener was. Document finding in the commit message. +- [ ] **Step 5: Validate + commit.** `refactor(chat): thread store reads participants from inbox metadata store` + +--- + +### Task 4: Meta single-ownership — remove meta copy from thread store + +The core ownership change. The thread store's `meta` field, its bi-directional sync (`setMeta`/`updateMeta` → `metasReceived`), and every thread-context listener that exists only to refresh the local meta copy all go away. Reads come from `useInboxMetadataState`; writes flow one way: RPC/engine → `metasReceived` → inbox store → (subscribers). + +**Files:** +- Modify: `shared/chat/conversation/thread-context.tsx` (major) +- Modify: components reading `s.meta` from the thread store (grep) +- Test: existing `chat/conversation/*.test.tsx` must keep passing. + +**Interfaces:** +- Produces: `ConversationThreadState` without `meta`; `ConversationThreadActions` without `setMeta`/`updateMeta`. New module-local helper in thread-context: `const getMeta = (id: T.Chat.ConversationIDKey) => getInboxConversationMeta(id) ?? Meta.makeConversationMeta()`. +- Writes that used `setMeta`/`updateMeta` now call `metasReceived([...])` / `updateInboxConversationMeta(id, partial)` from `@/chat/inbox/metadata` directly. + +Internal read sites to repoint (all become `getMeta(id)`): +- `loadConversationThreadMessages` membershipType/rekeyers bail (`:745-749`), offline write (`:862-864` → `updateInboxConversationMeta(conversationIDKey, {offline: results.offline})`) +- `markThreadAsRead` readMsgID noop check (`:996-998`) +- `applyThreadLoad` `maxVisibleMsgID` containsLatest calc (`:1069`) +- `setExplodingMode` retention lookup (`:1144`) +- `messageDelete` tlfname + meta-presence check (`:1237-1257`) +- `toggleMessageReaction` tlfname (`:1393`) +- `unfurlRemove` tlfname + presence check (`:1406-1415`) +- `setMarkAsUnread` maxVisibleMsgID (`:1180`) +- Note: presence checks like `snapshot.meta.conversationIDKey === id` become `getInboxConversationMeta(id) !== undefined` (or drop where vacuous). + +Listeners to DELETE from thread-context (each only refreshed the local meta copy; global path already writes the inbox store): +- `NewChatActivity` sub-branches `setStatus`, `readMessage`, `newConversation` (`:1650-1679`) and the `applyInboxUIItemToThread` call inside `incomingMessage` (`:1645`) and `failedMessage` (`:1697`) — global: `inbox/engine.tsx` `onNewChatActivity` returns the `inboxUIItem`, `constants/init/shared.tsx` pushes it through `onIncomingInboxUIItem` → `hydrateInboxConversations` → `metasReceived`. +- `ChatConvUpdate` (`:1742-1748`) — global: engine routes to `metasReceived`. +- `ChatSetConvRetention` (`:1782-1797`), `ChatSetTeamRetention` (`:1798-1812`), `ChatSetConvSettings` (`:1766-1781`), `setAppNotificationSettings` branch (`:1680-1686`) — global: engine routes retention/settings to `metasReceived`/`updateInboxConversationMeta`; VERIFY each case exists in `handleConvoEngineIncoming` (`chat/inbox/engine.tsx:189+`) before deleting. `setAppNotificationSettings` in particular: engine's `onNewChatActivity` must handle it or return its conv; if not covered, replace the thread-context listener with a direct `updateInboxConversationMeta(id, Meta.parseNotificationSettings(...))` call instead of deleting. +- `chatInboxFailed` (`:1749-1765`) — global: engine routes `chatInboxFailed` → `metaReceivedError` which builds the same error meta + participants. Delete the thread-context copy entirely after confirming `metaReceivedError` covers rekey participants (it calls `participantInfoReceived`). + +Helper functions that die with this: `applyConversationMetaToThread`, `applyInboxUIItemToThread` (`:688-710`). `Meta.updateMeta` version-gating remains used by the inbox store path (`metasReceived` consumers) — verify `metasReceived` applies version gating; TODAY it does NOT (it overwrites). The thread store previously gated via `applyConversationMetaToThread`. To preserve behavior (no stale-version overwrite thrash), add gating into `metasReceived`: + +```ts +// metadata.tsx metasReceived body change: +metas.forEach(m => { + const old = s.metas.get(m.conversationIDKey) + const next = old ? Meta.updateMeta(old, m) : m + s.metas.set(m.conversationIDKey, T.castDraft(next)) +}) +``` + +CAREFUL: some callers intentionally overwrite (error metas from `metaReceivedError` have same version but `trustedState:'error'`). `updateMeta` keeps old on equal version unless untrusted→trusted or localVersion bump — that would SWALLOW error metas and the `unverifiedInboxUIItemToConversationMeta`-based incremental sync. Resolution: add an options param `metasReceived(metas, removals?, {force?: boolean})`; pass `force: true` from `metaReceivedError`, `onChatInboxSynced` incremental, `clearConversationsForInboxSync` path, and `updateInboxConversationMeta` (which merges from current already); default (unbox results, NewChatActivity hydration, thread-store-removal call sites) goes through gating. Write a unit test for both behaviors in `chat/inbox/metadata.test.tsx`. + +Components reading thread-store meta: + +- [ ] **Step 1: Enumerate readers** + +```bash +rg -n "useConversationThreadSelector\(s => s\.meta|snapshot\.meta|\.getState\(\)\.meta" chat/ --glob '*.tsx' +rg -n "s\.meta\b" chat/conversation --glob '*.tsx' +``` + +Repoint component readers to `useConversationMeta(id)` (data-hooks; id from `useConversationThreadID()`). For selectors that picked single fields (e.g. `s.meta.teamname`), use `useInboxMetadataState(s => s.metas.get(id)?.teamname ?? '')` or `useConversationMeta` + field access — prefer the narrow selector where the component is render-hot (message rows). + +- [ ] **Step 2: Add version gating + force flag to `metasReceived`** with tests (as specified above). Commit separately if it stands alone: `fix(chat): version-gate metasReceived like thread-store path did`. +- [ ] **Step 3: Remove `meta` from `ConversationThreadState`**, seeding in `makeInitialThreadState`, `setMeta`/`updateMeta` actions, repoint all internal reads per the list above, delete dead helpers/listeners per the list above (with the per-listener global-coverage verification described — record each verification in the commit message body). +- [ ] **Step 4: Validate + commit.** `refactor(chat): single-owner conversation meta in inbox metadata store` + +Run `yarn jest chat` and fix fallout in `chat/conversation/normal/container.test.tsx`, `chat/inbox/metadata.test.tsx`, `chat/inbox/engine.test.tsx` by updating them to the new ownership (tests asserting thread-store meta mirroring get deleted; tests asserting inbox-store writes stay). + +--- + +### Task 5: Badge store + retire rows-state (staged) + +Replace the hand-maintained `rows-state` materialized view with selector hooks over: metadata store + new badge store + layout store + a typing map. Five sync entry points and two divergent projections disappear. + +**Files:** +- Create: `shared/chat/inbox/badge-state.tsx` +- Modify: `shared/chat/inbox/layout-state.tsx` (add per-conv row index selectors) +- Modify: `shared/chat/inbox/rows-state.tsx` → shrinks to row view hooks, then everything else deleted +- Modify: `shared/chat/inbox/metadata.tsx` (drop `syncInboxRows*` fan-out calls; trusted-state handling) +- Modify: `shared/chat/inbox/engine.tsx` (typing + badge routing), `shared/constants/init/shared.tsx` (badge routing) +- Modify consumers: `chat/inbox/row/small-team/index.tsx`, `chat/inbox/row/big-team-channel.tsx`, `chat/selectable-small-team.tsx`, `chat/selectable-big-team-channel.tsx`, `chat/inbox/use-inbox-state.tsx:22-58`, `chat/inbox/row/teams-divider-container.tsx:25` +- Test: `shared/chat/inbox/rows-state.test.ts` → replaced by `badge-state.test.ts` + row-hook tests; `metadata.test.tsx` updated. + +**Interfaces:** +- Produces `badge-state.tsx`: + +```ts +type BadgeCounts = {badgeCount: number; unreadCount: number} +export const useInboxBadgeState: Z store {counts: Map} +export const syncInboxBadgeState = (badgeState?: T.RPCGen.BadgeState) => void // full-replace semantics: convs absent from payload get no entry (map rebuilt each sync) +export const getInboxBadge = (id: T.Chat.ConversationIDKey): BadgeCounts // {0,0} default +``` + +- Produces typing map (goes into badge-state.tsx or its own 30-line `typing-state.tsx`): `useInboxTypingState {typing: Map>}` + `updateInboxTyping(updates)`. `buildTypingSnippet` moves next to its consumer hook. +- Produces in `rows-state.tsx` (file renamed responsibility, keep path to limit churn): `useInboxRowSmall(id): InboxRowSmall` and `useInboxRowBig(id): InboxRowBig` with the SAME return shapes as today (so row components change minimally), but computed via `useShallow` selectors: + +```ts +export const useInboxRowSmall = (id: string): InboxRowSmall => { + const you = useCurrentUserState(s => s.username) + const meta = useInboxMetadataState(s => s.metas.get(id)) + const participantInfo = useInboxMetadataState(s => s.participants.get(id)) + const layoutRow = useInboxLayoutState(s => getSmallLayoutRow(s, id)) // memoized index + const counts = useInboxBadgeState(s => s.counts.get(id)) + const typing = useInboxTypingState(s => s.typing.get(id)) + return React.useMemo(() => computeSmallRow(id, you, meta, participantInfo, layoutRow, counts, typing), + [id, you, meta, participantInfo, layoutRow, counts, typing]) +} +``` + +`computeSmallRow` merges with ONE precedence rule (fixes today's divergent projections): meta wins when `trustedState === 'trusted' || 'error'`; layout row fills gaps otherwise (snippet, draft, time, isMuted, name-split participants). ONE definition of `isDecryptingSnippet = !!id && !snippet && !metaTrusted`. `hasBadge`/`hasUnread` computed from counts — the stale-boolean class disappears. + +- Trusted-state: rows-state's `trustedState`/`setInboxRowTrustedState` copy is replaced by: meta's own `trustedState` + the existing `inFlightUnboxRows` set. In `metadata.tsx`: `trustedStateForConversation(id) = metas.get(id)?.trustedState ?? (inFlightUnboxRows.has(id) ? 'requesting' : 'untrusted')`. `setInboxRowTrustedState` call sites: the 'requesting' marker (`metadata.tsx:444`) is covered by `inFlightUnboxRows`; the untrusted resets (`:79`, `:458`) are covered by removal from `inFlightUnboxRows` (finally block already deletes). The error case (`rows-state.tsx:315`) is covered by the error meta from `metaReceivedError`. `getInboxRowTrustedState` and `hasKnownMeta` fallback (`metadata.tsx:492-497`) become meta-store checks. + +Stages (each its own commit): + +- [ ] **Step 1: badge store.** Create `badge-state.tsx` + tests (badge applied, absent conv zeroed on next sync). Route `keybase.1.NotifyBadges.badgeState` (`constants/init/shared.tsx:346-352` via `syncBadgeState` in metadata.tsx) to it. Repoint aggregate consumers `use-inbox-state.tsx:22-58` and `teams-divider-container.tsx:25` to badge store. Keep rows-state badge sync temporarily (double-write) so rows stay correct. Commit: `feat(chat): dedicated inbox badge store`. +- [ ] **Step 2: typing map + layout index.** Add typing store; route `ChatTypingUpdate` in `engine.tsx` to it (keep rows-state write temporarily). Add memoized per-conv layout row index selectors to `layout-state.tsx` (`getSmallLayoutRow`, `getBigLayoutChannelRow`) — build Maps keyed by convID once per layout change (module-level WeakMap on the layout object or a zustand computed). Commit: `feat(chat): typing store + layout row index`. +- [ ] **Step 3: selector-based row hooks.** Rewrite `useInboxRowSmall`/`useInboxRowBig` as computed selectors (shapes unchanged). Port `rows-state.test.ts` assertions to drive the new hooks via store writes (use `@testing-library/react` renderHook if present in repo tests — check existing patterns in `chat/inbox/metadata.test.tsx` first and mirror them). Delete `applyMetaToRows`, `syncInboxRowsFromLayout`, `syncInboxRowsFrom*`, `syncInboxRowBadgeState`, `updateInboxRowTyping`, `setInboxRowTrustedState`, `getInboxRowTrustedState`, the `rowsBig/rowsSmall` store, and every `syncInboxRows*` call in `metadata.tsx` (`:108,125,129,155,269` etc.). Swap trusted-state logic in `metadata.tsx` per the interface above. Update `hydrateInboxLayout` — it keeps only the missing-snippet queueing. Commit: `refactor(chat): inbox rows computed from stores, delete rows-state cache`. +- [ ] **Step 4: sweep.** `rg -n "rows-state|InboxRow(Big|Small)|syncInboxRow" chat/ constants/` — no stale imports. `yarn jest chat stores`, lint, tsc. Fix `engine.test.tsx`/`metadata.test.tsx` fallout. Commit with step 3 if small. + +Perf note for executor: row hooks run per store update per mounted row; all selector bodies must be cheap map lookups; the merge lives in `useMemo`. Do not create new arrays/objects inside the zustand selector itself (breaks referential equality) — only inside `useMemo`. + +--- + +### Task 6: Split thread-context.tsx + +Pure file reorganization after Tasks 3–4 shrink it. No behavior change, no export renames. + +**Files:** +- Create: `shared/chat/conversation/thread-engine.tsx` — the `apply*ToThread` free functions (`applyMessagesUpdatedToThread`, `applyIncomingMutationToThread`, `applyIncomingMessageToThread`, `applyFailedMessageToThread`, `applyReactionUpdateToThread`, `applyExpungeToThread`, `applyEphemeralPurgeToThread`) + a `useThreadEngineListeners(id: T.Chat.ConversationIDKey, threadActions: ConversationThreadActions): void` hook containing every `useEngineActionListener` currently in `ConversationThreadProviderInner` (thread-context.tsx:1638-1892 minus ones deleted in Task 4). +- Create: `shared/chat/conversation/thread-load.tsx` — `loadConversationThreadMessages`, `scrollDirectionToPagination`, `numMessagesOnInitialLoad`/`numMessagesOnScrollback`, `getClientPrevFromSnapshot`, snapshot helpers (`getLastOrdinalFromSnapshot`, `getOrdinalForMessageIDInSnapshot`), exploding-mode gregor helpers (`getExplodingModeFromGregorItems`, `getExplodingModeFromConfig`, `persistExplodingMode`). +- Modify: `shared/chat/conversation/thread-context.tsx` — keeps: state type, contexts, provider, actions, hooks, `toggleConversationThreadSearch`, `showConversationInfoPanel`. Imports from the two new files. Type exports needed by new files (`ConversationThreadState`, `ConversationThreadActions`, `LoadMoreMessagesParams`, `ThreadLoadStatusOptions`, `ScrollDirection`) get exported from thread-context (some already are). + +- [ ] **Step 1: Move code** (cut/paste, adjust imports, export the types the new modules need). Watch the circular import: thread-load/thread-engine import types from thread-context; thread-context imports functions from them — type-only imports one way (`import type`) keep the cycle harmless, but if lint's import-cycle rule fires, move the shared types into `thread-context-types.ts` instead. +- [ ] **Step 2: Also remove the `threadActionsHolder` indirection** (`:1552-1623`): now that `loadConversationThreadMessages` lives in thread-load.tsx and takes `actions` as a param, build the actions object first with a plain `loadMoreMessages` stub assignment: + +```ts +const [threadActions] = React.useState(() => { + const impl = (p: LoadMoreMessagesParams) => loadConversationThreadMessages(id, p, threadActions) + const throttled = throttle(impl, 500) + const loadMoreMessages: LoadMoreMessages = Object.assign((p: LoadMoreMessagesParams) => { + if (p.centeredMessageID || p.messageIDControl || p.reason === 'jump to recent') { + throttled.cancel() + impl(p) + } else throttled(p) + }, {cancel: () => throttled.cancel()}) + const threadActions: ConversationThreadActions = { /* ...same object... */ } + return threadActions +}) +``` + +(A `const` referenced from a closure created before its initialization is fine at call time — the holder object was equivalent; keep the existing comment about throttle drop semantics.) +- [ ] **Step 3: Validate + commit.** `refactor(chat): split thread-context into engine/load modules` + +--- + +### Task 7: Orange line — keyed store + +**Files:** +- Modify: `shared/chat/conversation/orange-line-context.tsx`, `shared/chat/conversation/normal/container.tsx` (NormalOrangeLineProvider consumption), `shared/chat/conversation/data-hooks.tsx:323` (caller unchanged in signature) + +**Interfaces:** +- `useExplicitOrangeLineState` becomes `{updates: Map}`; `setOrangeLine` writes into the map; module-level `explicitOrangeLineVersion` counter moves inside the store creator closure (still monotonic). `setConversationOrangeLine` signature unchanged. Consumers that did `s.update?.conversationIDKey === id ? s.update : undefined` become `s.updates.get(id)`. + +- [ ] **Step 1: Find consumers** — `rg -n "useExplicitOrangeLineState|OrangeLineContext" chat/` (expect `NormalOrangeLineProvider` in `normal/container.tsx` or nearby). Rewrite store + consumers. +- [ ] **Step 2: Validate + commit.** `refactor(chat): per-conversation orange-line updates map` + +--- + +### Task 8: Merge Focus + Scroll contexts + +**Files:** +- Modify: `shared/chat/conversation/normal/context.tsx`, `shared/chat/conversation/normal/container.tsx` (provider tower), all `FocusContext`/`ScrollContext` consumers. + +**Interfaces:** +- Produces single `ThreadRefsContext` + `ThreadRefsProvider` exposing `{focusInput, setInputRef, scrollUp, scrollDown, scrollToBottom, setScrollRef}` — one context, one `useState`-stable value, two internal refs. Keep file at `normal/context.tsx`. + +- [ ] **Step 1:** `rg -ln "FocusContext|ScrollContext" chat/` → rewrite context file, update provider nesting (two providers become one), mechanical consumer updates (`React.useContext(ThreadRefsContext)`). +- [ ] **Step 2: Validate + commit.** `refactor(chat): merge focus/scroll contexts into ThreadRefsContext` + +Leave `MaxInputAreaContext` and `thread-search-overlay-context` alone: different value types with different update cadences (measured number / reanimated SharedValue); merging couples unrelated re-render paths. + +--- + +### Task 9: use-inbox-state username-guard removal + +**Files:** +- Modify: `shared/chat/inbox/use-inbox-state.tsx`, plus the inbox screen component that calls it (find via `rg -ln "useInboxState|inboxControls" chat/inbox`). + +The `state.username === username ? state.X : default` guards (~lines 82-208) reimplement reset-on-user-switch. Replace with a `key={username}` remount at the inbox screen boundary (component that owns this state), then store plain values in `useState` without embedded username. + +- [ ] **Step 1: Read the file + owner component.** Confirm state is component-local (agent report says yes). Apply `key={username}` where the stateful component is rendered; strip username from the state shape and all guards. +- [ ] **Step 2:** If remount at that boundary would drop other wanted state (scroll position across user switch is fine to lose — user switched accounts), proceed; otherwise keep guards and note why in the commit. +- [ ] **Step 3: Validate + commit.** `refactor(chat): inbox controls reset via username key remount` + +--- + +### Task 10: Emoji picker handoff (investigate-first) + +**Files:** +- Read first: `shared/chat/emoji-picker/use-picker.tsx` consumers (`rg -ln "usePickerState|updatePickerMap|PickKey"`). + +- [ ] **Step 1: Trace the three flows** (`addAlias`, `chatInput`, `reaction`): who opens the picker (route? overlay?), who writes the pick, who consumes + when it's cleared. +- [ ] **Step 2: Decide by precedent.** If the codebase already passes callbacks through navigation params or the picker renders in-tree (overlay/popup, not a separate route), replace the global mailbox with a direct `onPick` callback prop. If the picker is a routed screen and params are serializable-only here, keep the store but make handoff self-cleaning: consumer clears its key on read (`updatePickerMap(key, undefined)` after consumption) and document the mailbox pattern in the file header. +- [ ] **Step 3: Validate + commit.** `refactor(chat): emoji picker pick handoff via callback` (or the self-cleaning variant) + +--- + +### Task 11: Never-reset module state audit + +**Files:** +- Modify: `shared/chat/inbox/header-portal-state.tsx`, `shared/chat/blocking/block-buttons-state.tsx`; read `shared/chat/conversation/messages/wrapper/shared-timers.tsx`. + +- [ ] **Step 1: header-portal.** Verify the components that call `setInboxHeaderPortalNode/Content` clear on unmount (grep call sites). If they do, module `let`s are effectively lifecycle-managed — leave, add a header comment stating the invariant. If content can survive logout, add an explicit `resetInboxHeaderPortal()` and call it from the same place `Z.resetAllStores` is triggered (`stores/config.tsx:560` area) — read that site first and follow its pattern. +- [ ] **Step 2: block-buttons.** Move `loadGeneration` into the store (plain number field); `loadPromise` stays module-level (promises don't belong in immutable state) but rename to make the pairing with `resetState` obvious and keep the existing manual clear. Confirm `resetState` clears both. +- [ ] **Step 3: shared-timers.** Observers detach on unmount; logout unmounts all rows. No change unless a live timer outlasting logout is observable — read and confirm; comment the invariant. +- [ ] **Step 4: Validate + commit.** `chore(chat): lifecycle-audit module-level chat state` + +--- + +### Task 12: readme rewrite + final validation + +**Files:** +- Modify: `shared/chat/readme.md` + +- [ ] **Step 1: Rewrite** to describe the post-refactor architecture: per-conversation thread store (messages/ordinals, lifecycle via key remount), inbox metadata store as single meta/participants owner, layout store, badge store, computed row hooks, engine routing, converter layer, ordinal/pending model (keep the existing ordinal section — it's accurate). Note the intentional dualities: live typing (thread store) vs inbox typing snippet; composer draft (input-state) vs service draft (meta). +- [ ] **Step 2: Full validation** + +```bash +cd shared && yarn lint && yarn tsc && yarn jest +``` + +- [ ] **Step 3: Commit.** `docs(chat): update chat readme to current data architecture` +- [ ] **Step 4: STOP. Do not push.** Hand back to user for app verification (desktop + mobile chat smoke: open inbox, unread badges, open conversation, send/edit/delete/react, switch conversations, thread search jump, logout/login). + +--- + +## Explicitly deferred (from the evaluation, with reasons) + +- `mergeMessage` hand-rolled deep merge + 4-index consolidation + `messageTypeMap` partial denormalization: highest regression risk per unit of benefit; needs its own test-first plan against `thread-message-state.tsx` once the above has settled. +- typing/draft dual representations: intentional after analysis (live view vs snippet view; local composer vs service draft) — documented in readme instead. +- `ShownUsernameCacheContext` render-time mutation: works, isolated, and a fix requires rethinking sticky-header calculation; separate task if it ever misbehaves. + +## Self-review notes + +- Task 4 depends on Task 3 (participants first shrinks the diff). Task 5 depends on Task 4 (trusted-state move assumes meta gating in place). Task 6 depends on Tasks 3–4 (file contents). Tasks 1–2 and 7–11 are independent. +- Type consistency: `ConversationThreadState`/`ConversationThreadActions` field removals in Tasks 3–4 are referenced again in Task 6's file split — Task 6 lists only surviving members. +- Riskiest step: `metasReceived` gating change (Task 4 Step 2) — has dedicated tests + force flag for the overwrite paths. diff --git a/shared/chat/inbox/badge-state.test.ts b/shared/chat/inbox/badge-state.test.ts new file mode 100644 index 000000000000..46de21b179e2 --- /dev/null +++ b/shared/chat/inbox/badge-state.test.ts @@ -0,0 +1,46 @@ +/// +import * as T from '@/constants/types' +import {resetAllStores} from '@/util/zustand' +import {getInboxBadge, syncInboxBadgeState, useInboxBadgeState} from './badge-state' + +const convA = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) +const convB = T.Chat.conversationIDToKey(new Uint8Array([5, 6, 7, 8])) + +afterEach(() => { + resetAllStores() +}) + +test('syncInboxBadgeState applies badge and unread counts', () => { + syncInboxBadgeState({ + conversations: [ + {badgeCount: 2, convID: T.Chat.keyToConversationID(convA), unreadMessages: 5}, + {badgeCount: 0, convID: T.Chat.keyToConversationID(convB), unreadMessages: 3}, + ], + } as unknown as T.RPCGen.BadgeState) + + expect(getInboxBadge(convA)).toEqual({badgeCount: 2, unreadCount: 5}) + expect(getInboxBadge(convB)).toEqual({badgeCount: 0, unreadCount: 3}) + expect(useInboxBadgeState.getState().counts.get(convA)?.badgeCount).toBe(2) +}) + +test('conversations absent from a later sync are zeroed (full-replace)', () => { + syncInboxBadgeState({ + conversations: [ + {badgeCount: 2, convID: T.Chat.keyToConversationID(convA), unreadMessages: 5}, + {badgeCount: 1, convID: T.Chat.keyToConversationID(convB), unreadMessages: 1}, + ], + } as unknown as T.RPCGen.BadgeState) + + syncInboxBadgeState({ + conversations: [{badgeCount: 4, convID: T.Chat.keyToConversationID(convA), unreadMessages: 9}], + } as unknown as T.RPCGen.BadgeState) + + expect(getInboxBadge(convA)).toEqual({badgeCount: 4, unreadCount: 9}) + // convB dropped from payload, so it has no entry and reads as the {0,0} default + expect(useInboxBadgeState.getState().counts.has(convB)).toBe(false) + expect(getInboxBadge(convB)).toEqual({badgeCount: 0, unreadCount: 0}) +}) + +test('getInboxBadge defaults to zeroes for an unknown conversation', () => { + expect(getInboxBadge(convA)).toEqual({badgeCount: 0, unreadCount: 0}) +}) diff --git a/shared/chat/inbox/badge-state.tsx b/shared/chat/inbox/badge-state.tsx new file mode 100644 index 000000000000..dcf716817cbc --- /dev/null +++ b/shared/chat/inbox/badge-state.tsx @@ -0,0 +1,37 @@ +import * as T from '@/constants/types' +import * as Z from '@/util/zustand' + +export type BadgeCounts = {badgeCount: number; unreadCount: number} + +type State = T.Immutable<{ + counts: Map + dispatch: { + resetState: () => void + } +}> + +export const useInboxBadgeState = Z.createZustand('inboxBadge', () => ({ + counts: new Map(), + dispatch: {resetState: Z.defaultReset}, +})) + +const emptyCounts: BadgeCounts = {badgeCount: 0, unreadCount: 0} + +// Full-replace semantics: the map is rebuilt from the payload each sync, so a +// conversation absent from the payload gets no entry (reads default to {0,0}). +export const syncInboxBadgeState = (badgeState?: T.RPCGen.BadgeState) => { + if (!badgeState) { + return + } + const next = new Map() + badgeState.conversations?.forEach(conversation => { + const id = T.Chat.conversationIDToKey(conversation.convID) + next.set(id, {badgeCount: conversation.badgeCount, unreadCount: conversation.unreadMessages}) + }) + useInboxBadgeState.setState(s => { + s.counts = next + }) +} + +export const getInboxBadge = (id: T.Chat.ConversationIDKey): BadgeCounts => + useInboxBadgeState.getState().counts.get(id) ?? emptyCounts diff --git a/shared/chat/inbox/metadata.tsx b/shared/chat/inbox/metadata.tsx index 867ba8cbfa4c..2cd56749c9f1 100644 --- a/shared/chat/inbox/metadata.tsx +++ b/shared/chat/inbox/metadata.tsx @@ -16,6 +16,7 @@ import * as Z from '@/util/zustand' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useUsersState} from '@/stores/users' +import {syncInboxBadgeState} from '@/chat/inbox/badge-state' import { getInboxRowTrustedState, setInboxRowTrustedState, @@ -625,5 +626,6 @@ export const onChatInboxSynced = async ( } export const syncBadgeState = (badgeState?: T.RPCGen.BadgeState) => { + syncInboxBadgeState(badgeState) syncInboxRowBadgeState(badgeState) } diff --git a/shared/chat/inbox/row/teams-divider-container.tsx b/shared/chat/inbox/row/teams-divider-container.tsx index 7ca514e18f77..186490821578 100644 --- a/shared/chat/inbox/row/teams-divider-container.tsx +++ b/shared/chat/inbox/row/teams-divider-container.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import type {ChatInboxRowItem} from '../rowitem' import {useConfigState} from '@/stores/config' -import {useInboxRowsState} from '@/chat/inbox/rows-state' +import {useInboxBadgeState} from '@/chat/inbox/badge-state' import TeamsDivider from './teams-divider' type Props = Omit, 'badgeCount'> & { @@ -22,11 +22,11 @@ const TeamsDividerContainer = React.memo(function TeamsDividerContainer(props: P return ids }, [rows]) - const visibleBadges = useInboxRowsState( + const visibleBadges = useInboxBadgeState( React.useCallback(s => { let total = 0 for (const conversationIDKey of visibleSmallConvIDs) { - total += s.rowsSmall.get(conversationIDKey)?.badgeCount ?? 0 + total += s.counts.get(conversationIDKey)?.badgeCount ?? 0 } return total }, [visibleSmallConvIDs]) diff --git a/shared/chat/inbox/use-inbox-state.tsx b/shared/chat/inbox/use-inbox-state.tsx index 9837abe49cac..7f2b76fd7431 100644 --- a/shared/chat/inbox/use-inbox-state.tsx +++ b/shared/chat/inbox/use-inbox-state.tsx @@ -4,7 +4,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 '@/chat/inbox/rows-state' +import {useInboxBadgeState} from '@/chat/inbox/badge-state' import {useIsFocused} from '@react-navigation/core' import type {ChatInboxRowItem} from './rowitem' import {useInboxLayout, useInboxRetryState} from './layout-state' @@ -19,10 +19,10 @@ const useInboxBadges = ( return inboxRows.map(r => (r.type === 'big' ? r.conversationIDKey : '')) }, [inboxRows]) - const unreadBadges = useInboxRowsState( + const unreadBadges = useInboxBadgeState( C.useShallow(s => bigConvIds.map(conversationIDKey => - conversationIDKey ? (s.rowsBig.get(conversationIDKey)?.badgeCount ?? 0) : 0 + conversationIDKey ? (s.counts.get(conversationIDKey)?.badgeCount ?? 0) : 0 ) ) ) From 38f136a8195166184355a3c489973d9d7733206b Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 3 Jul 2026 11:53:39 -0400 Subject: [PATCH 07/18] feat(chat): typing store + layout row index --- shared/chat/inbox/engine.tsx | 2 ++ shared/chat/inbox/layout-state.tsx | 41 ++++++++++++++++++++++++++ shared/chat/inbox/typing-state.test.ts | 37 +++++++++++++++++++++++ shared/chat/inbox/typing-state.tsx | 28 ++++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 shared/chat/inbox/typing-state.test.ts create mode 100644 shared/chat/inbox/typing-state.tsx diff --git a/shared/chat/inbox/engine.tsx b/shared/chat/inbox/engine.tsx index 8abfa90d0fb8..0f129fd4a696 100644 --- a/shared/chat/inbox/engine.tsx +++ b/shared/chat/inbox/engine.tsx @@ -10,6 +10,7 @@ import {showMain} from '@/util/storeless-actions' import {useShellState} from '@/stores/shell' import {useUsersState} from '@/stores/users' import {updateInboxRowTyping} from '@/chat/inbox/rows-state' +import {updateInboxTyping} from '@/chat/inbox/typing-state' import { forceUnboxRowsForService, getInboxConversationMeta, @@ -243,6 +244,7 @@ export const handleConvoEngineIncoming = (action: EngineGen.Actions): ConvoEngin case 'chat.1.NotifyChat.NewChatActivity': return onNewChatActivity(action.payload.params.activity) case 'chat.1.NotifyChat.ChatTypingUpdate': { + updateInboxTyping(action.payload.params.typingUpdates) updateInboxRowTyping(action.payload.params.typingUpdates) return handledConvoEngineIncoming() } diff --git a/shared/chat/inbox/layout-state.tsx b/shared/chat/inbox/layout-state.tsx index fc4ca5064692..2d300479ebd3 100644 --- a/shared/chat/inbox/layout-state.tsx +++ b/shared/chat/inbox/layout-state.tsx @@ -98,6 +98,47 @@ export const useInboxLayoutState = Z.createZustand('chat-inbox-layout', ( } }) +// Per-conversation index over the current layout so row hooks can do a cheap +// map lookup instead of scanning smallTeams/bigTeams. Built once per layout +// object and memoized on it (a new layout object replaces the prior on change), +// so selector bodies stay allocation-free after the first read. +type LayoutIndex = { + bigChannels: Map + small: Map +} +const layoutIndexCache = new WeakMap() + +const getLayoutIndex = (layout?: T.RPCChat.UIInboxLayout): LayoutIndex | undefined => { + if (!layout) { + return undefined + } + const existing = layoutIndexCache.get(layout) + if (existing) { + return existing + } + const small = new Map() + layout.smallTeams?.forEach(row => { + small.set(T.Chat.stringToConversationIDKey(row.convID), row) + }) + const bigChannels = new Map() + layout.bigTeams?.forEach(row => { + if (row.state === T.RPCChat.UIInboxBigTeamRowTyp.channel) { + bigChannels.set(T.Chat.stringToConversationIDKey(row.channel.convID), row.channel) + } + }) + const index: LayoutIndex = {bigChannels, small} + layoutIndexCache.set(layout, index) + return index +} + +export const getSmallLayoutRow = (s: {layout?: T.RPCChat.UIInboxLayout}, id: T.Chat.ConversationIDKey) => + getLayoutIndex(s.layout)?.small.get(id) + +export const getBigLayoutChannelRow = ( + s: {layout?: T.RPCChat.UIInboxLayout}, + id: T.Chat.ConversationIDKey +) => getLayoutIndex(s.layout)?.bigChannels.get(id) + export const useInboxLayout = () => useInboxLayoutState( Z.useShallow(s => ({ diff --git a/shared/chat/inbox/typing-state.test.ts b/shared/chat/inbox/typing-state.test.ts new file mode 100644 index 000000000000..5f712833e2fc --- /dev/null +++ b/shared/chat/inbox/typing-state.test.ts @@ -0,0 +1,37 @@ +/// +import * as T from '@/constants/types' +import {resetAllStores} from '@/util/zustand' +import {updateInboxTyping, useInboxTypingState} from './typing-state' + +const convA = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) +const convB = T.Chat.conversationIDToKey(new Uint8Array([5, 6, 7, 8])) + +afterEach(() => { + resetAllStores() +}) + +test('updateInboxTyping stores typers per conversation and replaces prior sets', () => { + updateInboxTyping([ + { + convID: T.Chat.keyToConversationID(convA), + typers: [{deviceID: 'd', uid: 'u', username: 'carol'}], + }, + { + convID: T.Chat.keyToConversationID(convB), + typers: [ + {deviceID: 'd', uid: 'u', username: 'bob'}, + {deviceID: 'd', uid: 'u', username: 'dave'}, + ], + }, + ] as ReadonlyArray) + + expect([...(useInboxTypingState.getState().typing.get(convA) ?? [])]).toEqual(['carol']) + expect((useInboxTypingState.getState().typing.get(convB) ?? new Set()).size).toBe(2) + + // an update for convA with no typers replaces its set; convB is left untouched + updateInboxTyping([{convID: T.Chat.keyToConversationID(convA), typers: []}] as ReadonlyArray< + T.RPCChat.ConvTypingUpdate + >) + expect((useInboxTypingState.getState().typing.get(convA) ?? new Set()).size).toBe(0) + expect((useInboxTypingState.getState().typing.get(convB) ?? new Set()).size).toBe(2) +}) diff --git a/shared/chat/inbox/typing-state.tsx b/shared/chat/inbox/typing-state.tsx new file mode 100644 index 000000000000..1a7db1398be9 --- /dev/null +++ b/shared/chat/inbox/typing-state.tsx @@ -0,0 +1,28 @@ +import * as T from '@/constants/types' +import * as Z from '@/util/zustand' + +type State = T.Immutable<{ + typing: Map> + dispatch: { + resetState: () => void + } +}> + +export const useInboxTypingState = Z.createZustand('inboxTyping', () => ({ + dispatch: {resetState: Z.defaultReset}, + typing: new Map(), +})) + +// Each ChatTypingUpdate carries the current typers for the named convs, so we +// replace those convs' sets and leave the rest untouched. +export const updateInboxTyping = (updates?: ReadonlyArray | null) => { + if (!updates?.length) { + return + } + useInboxTypingState.setState(s => { + updates.forEach(update => { + const id = T.Chat.conversationIDToKey(update.convID) + s.typing.set(id, new Set(update.typers?.map(typer => typer.username))) + }) + }) +} From 992dc9a2382cb7186da00010166ed1b0109142b3 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 3 Jul 2026 12:03:56 -0400 Subject: [PATCH 08/18] refactor(chat): inbox rows computed from stores, delete rows-state cache --- shared/chat/conversation/bot/install.tsx | 5 +- shared/chat/conversation/info-panel/bot.tsx | 11 +- .../input-area/input-state.test.tsx | 14 - .../input-area/suggestors/channels.test.tsx | 28 +- .../chat/conversation/thread-context.test.tsx | 20 +- .../thread-load-status-context.test.tsx | 14 - shared/chat/inbox-and-conversation-header.tsx | 20 +- shared/chat/inbox/engine.test.tsx | 29 +- shared/chat/inbox/engine.tsx | 2 - shared/chat/inbox/layout-state.tsx | 20 +- shared/chat/inbox/metadata.tsx | 52 +-- shared/chat/inbox/rows-state.test.ts | 219 +++++---- shared/chat/inbox/rows-state.tsx | 429 +++++------------- shared/constants/router.tsx | 2 +- 14 files changed, 290 insertions(+), 575 deletions(-) diff --git a/shared/chat/conversation/bot/install.tsx b/shared/chat/conversation/bot/install.tsx index c1ebc0a9eb96..b10036f642e5 100644 --- a/shared/chat/conversation/bot/install.tsx +++ b/shared/chat/conversation/bot/install.tsx @@ -14,7 +14,7 @@ import {useFeaturedBot} from '@/util/featured-bots' import {RPCError} from '@/util/errors' import logger from '@/logger' import {useBotSettings} from './settings' -import {getInboxConversationMeta, metasReceived, participantInfoReceived} from '@/chat/inbox/metadata' +import {metasReceived, participantInfoReceived} from '@/chat/inbox/metadata' import {useConversationMeta} from '../data-hooks' const RestrictedItem = '---RESTRICTED---' @@ -42,8 +42,7 @@ export const useRefreshBotMembershipOnSuccess = ( preview => { participantInfoReceived( conversationIDKey, - ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []), - getInboxConversationMeta(conversationIDKey) + ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []) ) onSuccess() }, diff --git a/shared/chat/conversation/info-panel/bot.tsx b/shared/chat/conversation/info-panel/bot.tsx index 856d5215f800..358016c7973a 100644 --- a/shared/chat/conversation/info-panel/bot.tsx +++ b/shared/chat/conversation/info-panel/bot.tsx @@ -9,7 +9,7 @@ import {useUsersState} from '@/stores/users' import {useChatTeam, useChatTeamMembers} from '../team-hooks' import logger from '@/logger' import {useBotSettings} from '../bot/settings' -import {getInboxConversationMeta, participantInfoReceived} from '@/chat/inbox/metadata' +import {participantInfoReceived} from '@/chat/inbox/metadata' import {useConversationMetadata} from '../data-hooks' type AddToChannelProps = { @@ -74,8 +74,7 @@ const AddToChannel = (props: AddToChannelProps) => { preview => { participantInfoReceived( conversationIDKey, - ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []), - getInboxConversationMeta(conversationIDKey) + ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []) ) }, () => {} @@ -237,8 +236,7 @@ const BotTab = (props: Props) => { preview => { participantInfoReceived( conversationIDKey, - ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []), - getInboxConversationMeta(conversationIDKey) + ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []) ) }, () => {} @@ -262,8 +260,7 @@ const BotTab = (props: Props) => { preview => { participantInfoReceived( conversationIDKey, - ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []), - getInboxConversationMeta(conversationIDKey) + ChatCommon.uiParticipantsToParticipantInfo(preview.conv.participants ?? []) ) }, () => {} diff --git a/shared/chat/conversation/input-area/input-state.test.tsx b/shared/chat/conversation/input-area/input-state.test.tsx index 80f6e4792f42..ba6abdef4184 100644 --- a/shared/chat/conversation/input-area/input-state.test.tsx +++ b/shared/chat/conversation/input-area/input-state.test.tsx @@ -17,20 +17,6 @@ jest.mock('@react-navigation/native', () => ({ useRoute: () => ({name: 'chatConversation', params: mockRouteParams}), })) -jest.mock('@/chat/inbox/rows-state', () => ({ - flushInboxRowUpdates: jest.fn(), - getInboxRowTrustedState: jest.fn(() => undefined), - queueInboxRowUpdate: jest.fn(), - setInboxRowTrustedState: jest.fn(), - syncInboxRowBadgeState: jest.fn(), - syncInboxRowsFromLayout: jest.fn(), - syncInboxRowsFromMetaAndParticipants: jest.fn(), - syncInboxRowsFromMetas: jest.fn(), - syncInboxRowsFromParticipantMap: jest.fn(), - syncInboxRowsFromParticipants: jest.fn(), - updateInboxRowTyping: jest.fn(), -})) - const convID = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) const otherConvID = T.Chat.conversationIDToKey(new Uint8Array([5, 6, 7, 8])) diff --git a/shared/chat/conversation/input-area/suggestors/channels.test.tsx b/shared/chat/conversation/input-area/suggestors/channels.test.tsx index 0f0348e6bb5d..6e4b2247e3a9 100644 --- a/shared/chat/conversation/input-area/suggestors/channels.test.tsx +++ b/shared/chat/conversation/input-area/suggestors/channels.test.tsx @@ -21,20 +21,6 @@ jest.mock('./common', () => ({ }, })) -jest.mock('@/chat/inbox/rows-state', () => ({ - flushInboxRowUpdates: jest.fn(), - getInboxRowTrustedState: jest.fn(() => undefined), - queueInboxRowUpdate: jest.fn(), - setInboxRowTrustedState: jest.fn(), - syncInboxRowBadgeState: jest.fn(), - syncInboxRowsFromLayout: jest.fn(), - syncInboxRowsFromMetaAndParticipants: jest.fn(), - syncInboxRowsFromMetas: jest.fn(), - syncInboxRowsFromParticipantMap: jest.fn(), - syncInboxRowsFromParticipants: jest.fn(), - updateInboxRowTyping: jest.fn(), -})) - const convID = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) const flushPromises = async () => { @@ -83,15 +69,11 @@ beforeEach(() => { teamType: 'adhoc', } metasReceived([meta]) - participantInfoReceived( - convID, - { - all: ['alice', 'bob', 'carol'], - contactName: new Map(), - name: ['alice', 'bob', 'carol'], - }, - meta - ) + participantInfoReceived(convID, { + all: ['alice', 'bob', 'carol'], + contactName: new Map(), + name: ['alice', 'bob', 'carol'], + }) useInboxLayoutState.getState().dispatch.updateLayout( JSON.stringify({ bigTeams: [ diff --git a/shared/chat/conversation/thread-context.test.tsx b/shared/chat/conversation/thread-context.test.tsx index 0b1f67bd9ce0..eecd52dcc497 100644 --- a/shared/chat/conversation/thread-context.test.tsx +++ b/shared/chat/conversation/thread-context.test.tsx @@ -1,7 +1,6 @@ /** @jest-environment jsdom */ /// import * as Common from '@/constants/chat/common' -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' @@ -29,20 +28,6 @@ import { } from './thread-context' import {useConversationParticipants} from './data-hooks' -jest.mock('@/chat/inbox/rows-state', () => ({ - flushInboxRowUpdates: jest.fn(), - getInboxRowTrustedState: jest.fn(() => undefined), - queueInboxRowUpdate: jest.fn(), - setInboxRowTrustedState: jest.fn(), - syncInboxRowBadgeState: jest.fn(), - syncInboxRowsFromLayout: jest.fn(), - syncInboxRowsFromMetaAndParticipants: jest.fn(), - syncInboxRowsFromMetas: jest.fn(), - syncInboxRowsFromParticipantMap: jest.fn(), - syncInboxRowsFromParticipants: jest.fn(), - updateInboxRowTyping: jest.fn(), -})) - const convID = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) const emptyStringSet = new Set() @@ -303,10 +288,7 @@ test('mounted thread syncs participant updates received outside its provider', ( } act(() => { - participantInfoReceived(convID, participantInfo, { - ...Meta.makeConversationMeta(), - conversationIDKey: convID, - }) + participantInfoReceived(convID, participantInfo) }) expect(result.current.all).toEqual(['alice', 'helperbot']) diff --git a/shared/chat/conversation/thread-load-status-context.test.tsx b/shared/chat/conversation/thread-load-status-context.test.tsx index ebcd485d3602..797ac75ffebc 100644 --- a/shared/chat/conversation/thread-load-status-context.test.tsx +++ b/shared/chat/conversation/thread-load-status-context.test.tsx @@ -14,20 +14,6 @@ import { } from './thread-load-status-context' import {ConversationThreadProvider} from './thread-context' -jest.mock('@/chat/inbox/rows-state', () => ({ - flushInboxRowUpdates: jest.fn(), - getInboxRowTrustedState: jest.fn(() => undefined), - queueInboxRowUpdate: jest.fn(), - setInboxRowTrustedState: jest.fn(), - syncInboxRowBadgeState: jest.fn(), - syncInboxRowsFromLayout: jest.fn(), - syncInboxRowsFromMetaAndParticipants: jest.fn(), - syncInboxRowsFromMetas: jest.fn(), - syncInboxRowsFromParticipantMap: jest.fn(), - syncInboxRowsFromParticipants: jest.fn(), - updateInboxRowTyping: jest.fn(), -})) - const convID = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) const otherConvID = T.Chat.conversationIDToKey(new Uint8Array([5, 6, 7, 8])) diff --git a/shared/chat/inbox-and-conversation-header.tsx b/shared/chat/inbox-and-conversation-header.tsx index 8872e976b1b6..d1179712b06c 100644 --- a/shared/chat/inbox-and-conversation-header.tsx +++ b/shared/chat/inbox-and-conversation-header.tsx @@ -9,7 +9,7 @@ import {setInboxHeaderPortalNode, useInboxHeaderPortalContent} from '@/chat/inbo import {useChatTeam} from '@/chat/conversation/team-hooks' import {useRoute} from '@react-navigation/native' import {useInboxMetadataState} from '@/chat/inbox/metadata' -import {useInboxRowsState} from '@/chat/inbox/rows-state' +import {useInboxRowBig, useInboxRowSmall} from '@/chat/inbox/rows-state' import {useUsersState} from '@/stores/users' import {useCurrentUserState} from '@/stores/current-user' import {navToPath} from '@/constants/fs' @@ -33,17 +33,13 @@ const Header = () => { participantInfo: s.participants.get(conversationIDKey) ?? emptyParticipantInfo, })) ) - const inboxRow = useInboxRowsState( - C.useShallow(s => { - const big = s.rowsBig.get(conversationIDKey) - const small = s.rowsSmall.get(conversationIDKey) - return { - rowChannelname: big?.channelname ?? '', - rowParticipants: small?.participants ?? emptyParticipants, - rowTeamname: big?.teamname || small?.teamDisplayName || '', - } - }) - ) + const bigRow = useInboxRowBig(conversationIDKey) + const smallRow = useInboxRowSmall(conversationIDKey) + const inboxRow = { + rowChannelname: bigRow.channelname, + rowParticipants: smallRow.participants.length ? smallRow.participants : emptyParticipants, + rowTeamname: bigRow.teamname || smallRow.teamDisplayName, + } const { channelname: metaChannelname, descriptionDecorated, diff --git a/shared/chat/inbox/engine.test.tsx b/shared/chat/inbox/engine.test.tsx index bbf2c80f3896..aa83360138ce 100644 --- a/shared/chat/inbox/engine.test.tsx +++ b/shared/chat/inbox/engine.test.tsx @@ -4,22 +4,15 @@ import {resetAllStores} from '@/util/zustand' import {handleConvoEngineIncoming} from './engine' import {getInboxConversationMeta, getInboxConversationParticipants, syncBadgeState} from './metadata' import {useConfigState} from '@/stores/config' -import { - syncInboxRowBadgeState, - syncInboxRowsFromParticipantMap, - updateInboxRowTyping, -} from '@/chat/inbox/rows-state' +import {syncInboxBadgeState} from '@/chat/inbox/badge-state' +import {updateInboxTyping} from '@/chat/inbox/typing-state' -jest.mock('@/chat/inbox/rows-state', () => ({ - getInboxRowTrustedState: jest.fn(() => undefined), - setInboxRowTrustedState: jest.fn(), - syncInboxRowBadgeState: jest.fn(), - syncInboxRowsFromLayout: jest.fn(), - syncInboxRowsFromMetaAndParticipants: jest.fn(), - syncInboxRowsFromMetas: jest.fn(), - syncInboxRowsFromParticipantMap: jest.fn(), - syncInboxRowsFromParticipants: jest.fn(), - updateInboxRowTyping: jest.fn(), +jest.mock('@/chat/inbox/badge-state', () => ({ + syncInboxBadgeState: jest.fn(), +})) + +jest.mock('@/chat/inbox/typing-state', () => ({ + updateInboxTyping: jest.fn(), })) afterEach(() => { @@ -371,7 +364,7 @@ test('global typing and participant updates route to inbox rows', () => { type: 'chat.1.NotifyChat.ChatTypingUpdate', } as never) - expect(updateInboxRowTyping).toHaveBeenCalledWith(typingUpdates) + expect(updateInboxTyping).toHaveBeenCalledWith(typingUpdates) const participantMap = { [T.Chat.conversationIDKeyToString(convID)]: [ @@ -385,7 +378,7 @@ test('global typing and participant updates route to inbox rows', () => { type: 'chat.1.NotifyChat.ChatParticipantsInfo', } as never) - expect(syncInboxRowsFromParticipantMap).toHaveBeenCalledWith(participantMap) + expect(getInboxConversationParticipants(convID)?.name).toEqual(['alice', 'bob']) }) test('global inbox failure routing stores error metadata and rekey participants', () => { @@ -447,5 +440,5 @@ test('syncBadgeState delegates badge ownership to inbox rows', () => { syncBadgeState(badgeState) - expect(syncInboxRowBadgeState).toHaveBeenCalledWith(badgeState) + expect(syncInboxBadgeState).toHaveBeenCalledWith(badgeState) }) diff --git a/shared/chat/inbox/engine.tsx b/shared/chat/inbox/engine.tsx index 0f129fd4a696..75c6591bf652 100644 --- a/shared/chat/inbox/engine.tsx +++ b/shared/chat/inbox/engine.tsx @@ -9,7 +9,6 @@ import {NotifyPopup} from '@/util/misc' import {showMain} from '@/util/storeless-actions' import {useShellState} from '@/stores/shell' import {useUsersState} from '@/stores/users' -import {updateInboxRowTyping} from '@/chat/inbox/rows-state' import {updateInboxTyping} from '@/chat/inbox/typing-state' import { forceUnboxRowsForService, @@ -245,7 +244,6 @@ export const handleConvoEngineIncoming = (action: EngineGen.Actions): ConvoEngin return onNewChatActivity(action.payload.params.activity) case 'chat.1.NotifyChat.ChatTypingUpdate': { updateInboxTyping(action.payload.params.typingUpdates) - updateInboxRowTyping(action.payload.params.typingUpdates) return handledConvoEngineIncoming() } case 'chat.1.NotifyChat.ChatSetConvRetention': { diff --git a/shared/chat/inbox/layout-state.tsx b/shared/chat/inbox/layout-state.tsx index 2d300479ebd3..ababe6afd22a 100644 --- a/shared/chat/inbox/layout-state.tsx +++ b/shared/chat/inbox/layout-state.tsx @@ -102,13 +102,15 @@ export const useInboxLayoutState = Z.createZustand('chat-inbox-layout', ( // map lookup instead of scanning smallTeams/bigTeams. Built once per layout // object and memoized on it (a new layout object replaces the prior on change), // so selector bodies stay allocation-free after the first read. +export type SmallLayoutRow = T.Immutable +export type BigLayoutChannelRow = T.Immutable type LayoutIndex = { - bigChannels: Map - small: Map + bigChannels: Map + small: Map } const layoutIndexCache = new WeakMap() -const getLayoutIndex = (layout?: T.RPCChat.UIInboxLayout): LayoutIndex | undefined => { +const getLayoutIndex = (layout?: T.Immutable): LayoutIndex | undefined => { if (!layout) { return undefined } @@ -116,11 +118,11 @@ const getLayoutIndex = (layout?: T.RPCChat.UIInboxLayout): LayoutIndex | undefin if (existing) { return existing } - const small = new Map() + const small = new Map() layout.smallTeams?.forEach(row => { small.set(T.Chat.stringToConversationIDKey(row.convID), row) }) - const bigChannels = new Map() + const bigChannels = new Map() layout.bigTeams?.forEach(row => { if (row.state === T.RPCChat.UIInboxBigTeamRowTyp.channel) { bigChannels.set(T.Chat.stringToConversationIDKey(row.channel.convID), row.channel) @@ -131,11 +133,13 @@ const getLayoutIndex = (layout?: T.RPCChat.UIInboxLayout): LayoutIndex | undefin return index } -export const getSmallLayoutRow = (s: {layout?: T.RPCChat.UIInboxLayout}, id: T.Chat.ConversationIDKey) => - getLayoutIndex(s.layout)?.small.get(id) +export const getSmallLayoutRow = ( + s: {layout?: T.Immutable}, + id: T.Chat.ConversationIDKey +) => getLayoutIndex(s.layout)?.small.get(id) export const getBigLayoutChannelRow = ( - s: {layout?: T.RPCChat.UIInboxLayout}, + s: {layout?: T.Immutable}, id: T.Chat.ConversationIDKey ) => getLayoutIndex(s.layout)?.bigChannels.get(id) diff --git a/shared/chat/inbox/metadata.tsx b/shared/chat/inbox/metadata.tsx index 2cd56749c9f1..271ae717f6b6 100644 --- a/shared/chat/inbox/metadata.tsx +++ b/shared/chat/inbox/metadata.tsx @@ -17,16 +17,6 @@ import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useUsersState} from '@/stores/users' import {syncInboxBadgeState} from '@/chat/inbox/badge-state' -import { - getInboxRowTrustedState, - setInboxRowTrustedState, - syncInboxRowBadgeState, - syncInboxRowsFromMetaAndParticipants, - syncInboxRowsFromLayout, - syncInboxRowsFromMetas, - syncInboxRowsFromParticipantMap, - syncInboxRowsFromParticipants, -} from '@/chat/inbox/rows-state' type InboxMetadataState = T.Immutable<{ metas: Map @@ -81,8 +71,6 @@ export const metaReceivedError = ( logger.info( `metaReceivedError: ignoring transient error for convID: ${conversationIDKey} error: ${error.message}` ) - // Allow a later unbox to retry; a row left 'requesting' is never re-requested. - setInboxRowTrustedState([conversationIDKey], 'untrusted') return } logger.info( @@ -100,21 +88,17 @@ export const metaReceivedError = ( // gating would swallow that, so force the overwrite. metasReceived([meta], undefined, {force: true}) if (participants) { - participantInfoReceived(conversationIDKey, participants, meta) + participantInfoReceived(conversationIDKey, participants) } } export const participantInfoReceived = ( conversationIDKey: T.Chat.ConversationIDKey, - participantInfo: T.Chat.ParticipantInfo, - meta?: T.Chat.ConversationMeta + participantInfo: T.Chat.ParticipantInfo ) => { useInboxMetadataState.setState(s => { s.participants.set(conversationIDKey, T.castDraft(participantInfo)) }) - if (meta) { - syncInboxRowsFromMetaAndParticipants([{meta, participantInfo}]) - } } export const metasReceived = ( @@ -140,11 +124,9 @@ export const metasReceived = ( s.metas.set(next.conversationIDKey, T.castDraft(next)) }) }) - syncInboxRowsFromMetas(nextMetas, removals) } const updateInboxParticipants = (inboxUIItems: ReadonlyArray) => { - syncInboxRowsFromParticipants(inboxUIItems) const participantEntries = new Array<{ conversationIDKey: T.Chat.ConversationIDKey participantInfo: T.Chat.ParticipantInfo @@ -170,7 +152,6 @@ const updateInboxParticipants = (inboxUIItems: ReadonlyArray | null} | null ) => { - syncInboxRowsFromParticipantMap(participantMap) useInboxMetadataState.setState(s => { Object.keys(participantMap ?? {}).forEach(convIDStr => { const participants = participantMap?.[convIDStr] @@ -284,7 +265,6 @@ export const onChatRouteChanged = ( } export const hydrateInboxLayout = (layout: T.RPCChat.UIInboxLayout) => { - syncInboxRowsFromLayout(layout) const missingSnippetIds = (layout.smallTeams ?? []) .filter(row => !row.snippet) .map(row => T.Chat.stringToConversationIDKey(row.convID)) @@ -314,8 +294,14 @@ export const clearConversationsForInboxSync = () => { }) } -const trustedStateForConversation = (id: T.Chat.ConversationIDKey) => - useInboxMetadataState.getState().metas.get(id)?.trustedState ?? getInboxRowTrustedState(id) +const inFlightUnboxRows = new Set() +const pendingForcedUnboxRows = new Set() + +// Trusted state now lives on the meta itself; a conv with no meta is 'requesting' +// while its unbox is in flight, otherwise 'untrusted'. +const trustedStateForConversation = (id: T.Chat.ConversationIDKey): T.Chat.MetaTrustedState => + useInboxMetadataState.getState().metas.get(id)?.trustedState ?? + (inFlightUnboxRows.has(id) ? 'requesting' : 'untrusted') const untrustedConversationIDKeys = (ids: ReadonlyArray) => ids.filter(id => { @@ -323,9 +309,6 @@ const untrustedConversationIDKeys = (ids: ReadonlyArray() -const pendingForcedUnboxRows = new Set() - type ConvoMetaQueueState = T.Immutable<{ generation: number inFlight: boolean @@ -459,7 +442,6 @@ const requestInboxUnboxRows = (ids: ReadonlyArray, for return } conversationIDKeys.forEach(id => inFlightUnboxRows.add(id)) - setInboxRowTrustedState(conversationIDKeys, 'requesting') logger.info( `unboxRows: unboxing len: ${conversationIDKeys.length} convs: ${conversationIDKeys.join(',')}` ) @@ -471,9 +453,8 @@ const requestInboxUnboxRows = (ids: ReadonlyArray, for if (error instanceof RPCError) { logger.info(`unboxRows: failed ${error.desc}`) } - // No per-conversation results arrived; leaving rows 'requesting' would make - // every future non-forced unbox skip them permanently. - setInboxRowTrustedState(conversationIDKeys, 'untrusted') + // No per-conversation results arrived; the finally block clears the + // in-flight marker so these convs fall back to 'untrusted' and can retry. } finally { conversationIDKeys.forEach(id => inFlightUnboxRows.delete(id)) const rerunIDs = conversationIDKeys.filter(id => { @@ -507,12 +488,8 @@ export const queueMetaToRequest = (ids: ReadonlyArray) useConvoMetaQueueState.getState().dispatch.queueMetaToRequest(ids) } -const hasKnownMeta = (id: T.Chat.ConversationIDKey) => { - if (useInboxMetadataState.getState().metas.has(id)) { - return true - } - return getInboxRowTrustedState(id) === 'trusted' -} +const hasKnownMeta = (id: T.Chat.ConversationIDKey) => + useInboxMetadataState.getState().metas.has(id) export const ensureWidgetMetas = ( widgetList: ReadonlyArray<{convID: T.Chat.ConversationIDKey}> | null | undefined @@ -627,5 +604,4 @@ export const onChatInboxSynced = async ( export const syncBadgeState = (badgeState?: T.RPCGen.BadgeState) => { syncInboxBadgeState(badgeState) - syncInboxRowBadgeState(badgeState) } diff --git a/shared/chat/inbox/rows-state.test.ts b/shared/chat/inbox/rows-state.test.ts index 8b5f88867ecf..003351a5c7f0 100644 --- a/shared/chat/inbox/rows-state.test.ts +++ b/shared/chat/inbox/rows-state.test.ts @@ -1,45 +1,51 @@ +/** @jest-environment jsdom */ /// -import * as T from '@/constants/types' import * as Meta from '@/constants/chat/meta' +import * as T from '@/constants/types' +import {act, cleanup, renderHook} from '@testing-library/react' import {resetAllStores} from '@/util/zustand' import {useCurrentUserState} from '@/stores/current-user' -import { - syncInboxRowBadgeState, - syncInboxRowsFromLayout, - syncInboxRowsFromMetaAndParticipants, - syncInboxRowsFromMetas, - syncInboxRowsFromParticipantMap, - syncInboxRowsFromParticipants, - updateInboxRowTyping, - useInboxRowsState, -} from './rows-state' +import {metasReceived, participantInfoReceived} from './metadata' +import {syncInboxBadgeState} from './badge-state' +import {updateInboxTyping} from './typing-state' +import {useInboxLayoutState} from './layout-state' +import {useInboxRowBig, useInboxRowSmall} from './rows-state' -afterEach(() => { - resetAllStores() -}) +const convID = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) + +const setLayout = (layout: Partial) => { + useInboxLayoutState.getState().dispatch.updateLayout( + JSON.stringify({bigTeams: null, smallTeams: null, totalSmallTeams: 0, ...layout}) + ) +} -test('explicit meta and participant updates merge into the row caches', () => { - const convID = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) +beforeEach(() => { useCurrentUserState.getState().dispatch.setBootstrap({ deviceID: 'device-id', deviceName: 'device-name', uid: 'uid', username: 'alice', }) +}) - syncInboxRowBadgeState({ - conversations: [{badgeCount: 2, convID: T.Chat.keyToConversationID(convID), unreadMessages: 1}], - } as unknown as T.RPCGen.BadgeState) - updateInboxRowTyping([ - { - convID: T.Chat.keyToConversationID(convID), - typers: [{deviceID: 'device-id', uid: 'uid', username: 'carol'}], - }, - ]) +afterEach(() => { + cleanup() + resetAllStores() +}) - syncInboxRowsFromMetaAndParticipants([ - { - meta: { +test('meta, participant, badge and typing stores merge into the computed small/big rows', () => { + act(() => { + syncInboxBadgeState({ + conversations: [{badgeCount: 2, convID: T.Chat.keyToConversationID(convID), unreadMessages: 1}], + } as unknown as T.RPCGen.BadgeState) + updateInboxTyping([ + { + convID: T.Chat.keyToConversationID(convID), + typers: [{deviceID: 'device-id', uid: 'uid', username: 'carol'}], + }, + ] as ReadonlyArray) + metasReceived([ + { ...Meta.makeConversationMeta(), channelname: 'general', conversationIDKey: convID, @@ -53,21 +59,12 @@ test('explicit meta and participant updates merge into the row caches', () => { timestamp: 1234, trustedState: 'requesting', }, - participantInfo: {all: ['alice', 'bob'], contactName: new Map(), name: ['alice', 'bob']}, - }, - ]) - expect(useInboxRowsState.getState().rowsBig.get(convID)?.badgeCount).toBe(2) - - expect(useInboxRowsState.getState().rowsBig.get(convID)).toMatchObject({ - badgeCount: 2, - channelname: 'general', - hasBadge: true, - hasDraft: true, - hasUnread: true, - snippetDecoration: T.RPCChat.SnippetDecoration.pendingMessage, - unreadCount: 1, + ]) + participantInfoReceived(convID, {all: ['alice', 'bob'], contactName: new Map(), name: ['alice', 'bob']}) }) - expect(useInboxRowsState.getState().rowsSmall.get(convID)).toMatchObject({ + + const {result: small} = renderHook(() => useInboxRowSmall(convID)) + expect(small.current).toMatchObject({ badgeCount: 2, draft: 'draft text', hasBadge: true, @@ -87,82 +84,84 @@ test('explicit meta and participant updates merge into the row caches', () => { youAreReset: false, youNeedToRekey: true, }) -}) -test('layout and meta sync populate inbox rows without a convo store lookup', () => { - const convID = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) - useCurrentUserState.getState().dispatch.setBootstrap({ - deviceID: 'device-id', - deviceName: 'device-name', - uid: 'uid', - username: 'alice', + const {result: big} = renderHook(() => useInboxRowBig(convID)) + expect(big.current).toMatchObject({ + badgeCount: 2, + channelname: 'general', + hasBadge: true, + hasDraft: true, + hasUnread: true, + snippetDecoration: T.RPCChat.SnippetDecoration.pendingMessage, + unreadCount: 1, }) +}) - syncInboxRowsFromLayout({ - bigTeams: [ - { - channel: { - channelname: 'general', +test('layout fills gaps until a trusted meta wins; participant store overrides name-split', () => { + const {result: small} = renderHook(() => useInboxRowSmall(convID)) + const {result: big} = renderHook(() => useInboxRowBig(convID)) + + // layout only: untrusted, so the layout row supplies snippet/time/muted/participants + act(() => { + setLayout({ + bigTeams: [ + { + channel: { + channelname: 'general', + convID: T.Chat.conversationIDKeyToString(convID), + draft: 'big draft', + isMuted: false, + teamname: 'team', + }, + state: T.RPCChat.UIInboxBigTeamRowTyp.channel, + }, + ], + smallTeams: [ + { convID: T.Chat.conversationIDKeyToString(convID), - draft: 'big draft', - isMuted: false, - teamname: 'team', + draft: '', + isMuted: true, + isTeam: false, + lastSendTime: 0, + name: 'alice,bob', + snippet: 'layout snippet', + snippetDecoration: T.RPCChat.SnippetDecoration.none, + time: 123, }, - state: T.RPCChat.UIInboxBigTeamRowTyp.channel, - }, - ], - smallTeams: [ - { - convID: T.Chat.conversationIDKeyToString(convID), - draft: '', - isMuted: true, - isTeam: false, - lastSendTime: 0, - name: 'alice,bob', - snippet: 'layout snippet', - snippetDecoration: T.RPCChat.SnippetDecoration.none, - time: 123, - }, - ], - totalSmallTeams: 1, + ], + totalSmallTeams: 1, + }) }) - - expect(useInboxRowsState.getState().rowsSmall.get(convID)).toMatchObject({ + expect(small.current).toMatchObject({ isMuted: true, participants: ['bob'], snippet: 'layout snippet', timestamp: 123, }) - expect(useInboxRowsState.getState().rowsBig.get(convID)).toMatchObject({ - channelname: 'general', - hasDraft: true, - teamname: 'team', - }) + expect(big.current).toMatchObject({channelname: 'general', hasDraft: true, teamname: 'team'}) - syncInboxRowsFromParticipants([ - { - convID: T.Chat.conversationIDKeyToString(convID), - participants: [ - {assertion: 'alice', inConvName: true, type: T.RPCChat.UIParticipantType.user}, - {assertion: 'carol', inConvName: true, type: T.RPCChat.UIParticipantType.user}, - ], - } as unknown as T.RPCChat.InboxUIItem, - ]) - expect(useInboxRowsState.getState().rowsSmall.get(convID)?.participants).toEqual(['carol']) + // participant store wins over the layout name-split + act(() => { + participantInfoReceived(convID, {all: ['alice', 'carol'], contactName: new Map(), name: ['alice', 'carol']}) + }) + expect(small.current.participants).toEqual(['carol']) - syncInboxRowsFromMetas([ - { - ...Meta.makeConversationMeta(), - conversationIDKey: convID, - draft: 'meta draft', - isMuted: false, - snippetDecorated: 'meta snippet', - teamname: 'meta-team', - timestamp: 456, - trustedState: 'trusted', - }, - ]) - expect(useInboxRowsState.getState().rowsSmall.get(convID)).toMatchObject({ + // a trusted meta takes precedence over the layout row for the gap fields + act(() => { + metasReceived([ + { + ...Meta.makeConversationMeta(), + conversationIDKey: convID, + draft: 'meta draft', + isMuted: false, + snippetDecorated: 'meta snippet', + teamname: 'meta-team', + timestamp: 456, + trustedState: 'trusted', + }, + ]) + }) + expect(small.current).toMatchObject({ draft: 'meta draft', isMuted: false, snippet: 'meta snippet', @@ -170,11 +169,9 @@ test('layout and meta sync populate inbox rows without a convo store lookup', () timestamp: 456, }) - syncInboxRowsFromParticipantMap({ - [convID]: [ - {assertion: 'alice', inConvName: true, type: T.RPCChat.UIParticipantType.user}, - {assertion: 'dave', inConvName: true, type: T.RPCChat.UIParticipantType.user}, - ], + // later participant updates still flow through + act(() => { + participantInfoReceived(convID, {all: ['alice', 'dave'], contactName: new Map(), name: ['alice', 'dave']}) }) - expect(useInboxRowsState.getState().rowsSmall.get(convID)?.participants).toEqual(['dave']) + expect(small.current.participants).toEqual(['dave']) }) diff --git a/shared/chat/inbox/rows-state.tsx b/shared/chat/inbox/rows-state.tsx index 9e9861df4e94..3e54550add1a 100644 --- a/shared/chat/inbox/rows-state.tsx +++ b/shared/chat/inbox/rows-state.tsx @@ -1,8 +1,16 @@ +import * as React from 'react' import * as T from '@/constants/types' -import * as Common from '@/constants/chat/common' -import * as Z from '@/util/zustand' import {useCurrentUserState} from '@/stores/current-user' -import {shallowEqual} from '@/constants/utils' +import {useInboxMetadataState} from '@/chat/inbox/metadata' +import { + getBigLayoutChannelRow, + getSmallLayoutRow, + useInboxLayoutState, + type BigLayoutChannelRow, + type SmallLayoutRow, +} from '@/chat/inbox/layout-state' +import {useInboxBadgeState, type BadgeCounts} from '@/chat/inbox/badge-state' +import {useInboxTypingState} from '@/chat/inbox/typing-state' export type InboxRowBig = { badgeCount: number @@ -41,73 +49,13 @@ export type InboxRowSmall = { youNeedToRekey: boolean } -const defaultInboxRowBig = { - badgeCount: 0, - channelname: '', - hasBadge: false, - hasDraft: false, - hasUnread: false, - isError: false, - isMuted: false, - snippet: '', - snippetDecoration: 0, - teamname: '', - trustedState: 'untrusted', - unreadCount: 0, -} satisfies InboxRowBig +type Meta = T.Immutable | undefined +type ParticipantInfo = T.Immutable | undefined -const defaultInboxRowSmall: InboxRowSmall = { - 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, - trustedState: 'untrusted', - typingSnippet: '', - unreadCount: 0, - youAreReset: false, - youNeedToRekey: false, -} - -type State = T.Immutable<{ - rowsBig: Map - rowsSmall: Map - dispatch: { - resetState: () => void - } -}> - -const ensureBigRow = (rowsBig: Map, id: string) => { - if (!rowsBig.has(id)) { - rowsBig.set(id, {...defaultInboxRowBig}) - } - return rowsBig.get(id)! -} - -const ensureSmallRow = (rowsSmall: Map, id: string) => { - if (!rowsSmall.has(id)) { - rowsSmall.set(id, {...defaultInboxRowSmall, participants: [] as string[]}) +const buildTypingSnippet = (typing?: ReadonlySet): string => { + if (!typing?.size) { + return '' } - return rowsSmall.get(id)! -} - -export const useInboxRowsState = Z.createZustand('inboxRows', () => ({ - dispatch: {resetState: Z.defaultReset}, - rowsBig: new Map(), - rowsSmall: new Map(), -})) - -const buildTypingSnippet = (typing: ReadonlySet): string => { - if (!typing.size) return '' if (typing.size === 1) { const [t] = typing return `${t} is typing...` @@ -125,251 +73,122 @@ const bigSnippetDecoration = (sd: T.RPCChat.SnippetDecoration): number => { } } -const applyParticipantsToSmallRow = ( - small: InboxRowSmall, - participantInfo: T.Chat.ParticipantInfo, - you: string -) => { - const filtered = participantInfo.name.length - ? participantInfo.name.filter((pp, _, list) => list.length === 1 || pp !== you) - : [] - if (!shallowEqual(small.participants, filtered)) { - small.participants = filtered - } -} +const filterParticipants = (names: ReadonlyArray, you: string): Array => + names.length ? names.filter((pp, _i, list) => list.length === 1 || pp !== you) : [] -const applyMetaToRows = ( - rowsBig: Map, - rowsSmall: Map, - meta: T.Chat.ConversationMeta, - you: string, - participantInfo?: T.Chat.ParticipantInfo -) => { - const id = meta.conversationIDKey - const snippet = meta.snippetDecorated ?? meta.snippet ?? '' +const isMetaTrusted = (trustedState: T.Chat.MetaTrustedState) => + trustedState === 'trusted' || trustedState === 'error' - const big = ensureBigRow(rowsBig, id) - big.channelname = meta.channelname - big.hasBadge = big.badgeCount > 0 - big.hasDraft = !!meta.draft - big.hasUnread = big.unreadCount > 0 - big.isError = meta.trustedState === 'error' - big.isMuted = meta.isMuted - big.snippet = snippet - big.snippetDecoration = bigSnippetDecoration(meta.snippetDecoration) - big.teamname = meta.teamname - big.trustedState = meta.trustedState - - const small = ensureSmallRow(rowsSmall, id) - small.draft = meta.draft || '' - small.hasBadge = small.badgeCount > 0 - small.hasResetUsers = meta.resetParticipants.size > 0 - small.hasUnread = small.unreadCount > 0 - small.isDecryptingSnippet = - !!id && !snippet && (meta.trustedState === 'requesting' || meta.trustedState === 'untrusted') - small.isLocked = meta.rekeyers.size > 0 || !!meta.wasFinalizedBy - small.isMuted = meta.isMuted - small.participantNeedToRekey = meta.rekeyers.size > 0 - if (participantInfo) { - applyParticipantsToSmallRow(small, participantInfo, you) +const computeSmallRow = ( + id: string, + you: string, + meta: Meta, + participantInfo: ParticipantInfo, + layoutRow: SmallLayoutRow | undefined, + counts: BadgeCounts | undefined, + typing: ReadonlySet | undefined +): InboxRowSmall => { + const trustedState: T.Chat.MetaTrustedState = meta?.trustedState ?? 'untrusted' + const metaTrusted = isMetaTrusted(trustedState) + const badgeCount = counts?.badgeCount ?? 0 + const unreadCount = counts?.unreadCount ?? 0 + + const metaSnippet = meta ? (meta.snippetDecorated ?? meta.snippet ?? '') : '' + // ONE precedence rule: meta wins when trusted/error; otherwise the layout row + // fills the gaps (snippet, draft, time, isMuted, name-split participants). + const useLayout = !metaTrusted && !!layoutRow + + const snippet = useLayout ? (layoutRow.snippet ?? '') : metaSnippet + const snippetDecoration = useLayout + ? layoutRow.snippetDecoration + : (meta?.snippetDecoration ?? T.RPCChat.SnippetDecoration.none) + const draft = (useLayout ? layoutRow.draft : meta?.draft) || '' + const timestamp = (useLayout ? layoutRow.time : meta?.timestamp) || 0 + const isMuted = useLayout ? layoutRow.isMuted : (meta?.isMuted ?? false) + const teamDisplayName = useLayout + ? layoutRow.isTeam + ? (layoutRow.name.split('#')[0] ?? '') + : '' + : meta?.teamname + ? (meta.teamname.split('#')[0] ?? '') + : '' + + let participants = filterParticipants(participantInfo?.name ?? [], you) + if (participants.length === 0 && layoutRow && !layoutRow.isTeam && layoutRow.name) { + const names = layoutRow.name + .split(',') + .map(n => n.trim()) + .filter(Boolean) + participants = filterParticipants(names, you) } - small.snippet = snippet - small.snippetDecoration = meta.snippetDecoration - small.teamDisplayName = meta.teamname ? meta.teamname.split('#')[0] ?? '' : '' - small.timestamp = meta.timestamp || 0 - small.trustedState = meta.trustedState - small.youAreReset = meta.membershipType === 'youAreReset' - small.youNeedToRekey = meta.rekeyers.has(you) -} - -export const syncInboxRowsFromMetaAndParticipants = ( - entries: ReadonlyArray<{ - meta: T.Chat.ConversationMeta - participantInfo?: T.Chat.ParticipantInfo - }> -) => { - const you = useCurrentUserState.getState().username - useInboxRowsState.setState(s => { - entries.forEach(({meta, participantInfo}) => { - applyMetaToRows(s.rowsBig, s.rowsSmall, meta, you, participantInfo) - }) - }) -} -export const syncInboxRowsFromMetas = ( - metas: ReadonlyArray, - removals?: ReadonlyArray -) => { - const you = useCurrentUserState.getState().username - useInboxRowsState.setState(s => { - removals?.forEach(id => { - s.rowsBig.delete(id) - s.rowsSmall.delete(id) - }) - metas.forEach(meta => { - applyMetaToRows(s.rowsBig, s.rowsSmall, meta, you) - }) - }) -} - -export const syncInboxRowsFromParticipants = (inboxUIItems: ReadonlyArray) => { - const you = useCurrentUserState.getState().username - useInboxRowsState.setState(s => { - inboxUIItems.forEach(inboxUIItem => { - const participantInfo = Common.uiParticipantsToParticipantInfo(inboxUIItem.participants ?? []) - if (participantInfo.all.length > 0) { - const id = T.Chat.stringToConversationIDKey(inboxUIItem.convID) - applyParticipantsToSmallRow(ensureSmallRow(s.rowsSmall, id), participantInfo, you) - } - }) - }) -} - -export const syncInboxRowsFromParticipantMap = ( - participantMap?: {[key: string]: ReadonlyArray | null} | null -) => { - const you = useCurrentUserState.getState().username - useInboxRowsState.setState(s => { - Object.keys(participantMap ?? {}).forEach(convIDStr => { - const participants = participantMap?.[convIDStr] - if (!participants) { - return - } - const participantInfo = Common.uiParticipantsToParticipantInfo(participants) - if (participantInfo.all.length > 0) { - const id = T.Chat.stringToConversationIDKey(convIDStr) - applyParticipantsToSmallRow(ensureSmallRow(s.rowsSmall, id), participantInfo, you) - } - }) - }) -} - -export const syncInboxRowsFromLayout = (layout: T.RPCChat.UIInboxLayout) => { - const you = useCurrentUserState.getState().username - useInboxRowsState.setState(s => { - layout.smallTeams?.forEach(row => { - const id = T.Chat.stringToConversationIDKey(row.convID) - const small = ensureSmallRow(s.rowsSmall, id) - const snippet = row.snippet ?? '' - small.draft = row.draft || '' - small.hasBadge = small.badgeCount > 0 - small.hasUnread = small.unreadCount > 0 - small.isDecryptingSnippet = !!id && !snippet && small.trustedState !== 'trusted' - small.isMuted = row.isMuted - small.snippet = snippet - small.snippetDecoration = row.snippetDecoration - small.teamDisplayName = row.isTeam ? row.name.split('#')[0] ?? '' : '' - small.timestamp = row.time || 0 - if (!row.isTeam && row.name && small.participants.length === 0) { - const names = row.name - .split(',') - .map(n => n.trim()) - .filter(Boolean) - const participantInfo: T.Chat.ParticipantInfo = {all: names, contactName: new Map(), name: names} - applyParticipantsToSmallRow(small, participantInfo, you) - } - - const big = ensureBigRow(s.rowsBig, id) - big.hasBadge = big.badgeCount > 0 - big.hasDraft = !!row.draft - big.hasUnread = big.unreadCount > 0 - big.isMuted = row.isMuted - big.snippet = snippet - big.snippetDecoration = bigSnippetDecoration(row.snippetDecoration) - big.teamname = row.isTeam ? row.name : '' - }) - layout.bigTeams?.forEach(row => { - if (row.state !== T.RPCChat.UIInboxBigTeamRowTyp.channel) { - return - } - const id = T.Chat.stringToConversationIDKey(row.channel.convID) - const big = ensureBigRow(s.rowsBig, id) - big.channelname = row.channel.channelname - big.hasBadge = big.badgeCount > 0 - big.hasDraft = !!row.channel.draft - big.hasUnread = big.unreadCount > 0 - big.isMuted = row.channel.isMuted - big.teamname = row.channel.teamname - }) - }) -} - -export const getInboxRowTrustedState = (id: T.Chat.ConversationIDKey) => { - const {rowsBig, rowsSmall} = useInboxRowsState.getState() - return rowsSmall.get(id)?.trustedState ?? rowsBig.get(id)?.trustedState -} - -export const setInboxRowTrustedState = ( - ids: ReadonlyArray, - trustedState: T.Chat.MetaTrustedState -) => { - useInboxRowsState.setState(s => { - ids.forEach(id => { - const small = ensureSmallRow(s.rowsSmall, id) - small.trustedState = trustedState - small.isDecryptingSnippet = - !!id && !small.snippet && (trustedState === 'requesting' || trustedState === 'untrusted') - - const big = ensureBigRow(s.rowsBig, id) - big.trustedState = trustedState - big.isError = trustedState === 'error' - }) - }) + const rekeyersSize = meta?.rekeyers.size ?? 0 + return { + badgeCount, + draft, + hasBadge: badgeCount > 0, + hasResetUsers: (meta?.resetParticipants.size ?? 0) > 0, + hasUnread: unreadCount > 0, + isDecryptingSnippet: !!id && !snippet && !metaTrusted, + isLocked: rekeyersSize > 0 || !!meta?.wasFinalizedBy, + isMuted, + participantNeedToRekey: rekeyersSize > 0, + participants, + snippet, + snippetDecoration, + teamDisplayName, + timestamp, + trustedState, + typingSnippet: buildTypingSnippet(typing), + unreadCount, + youAreReset: meta?.membershipType === 'youAreReset', + youNeedToRekey: !!meta && meta.rekeyers.has(you), + } } -export const syncInboxRowBadgeState = (badgeState?: T.RPCGen.BadgeState) => { - if (!badgeState) { - return +const computeBigRow = ( + meta: Meta, + layoutChannel: BigLayoutChannelRow | undefined, + counts: BadgeCounts | undefined +): InboxRowBig => { + const trustedState: T.Chat.MetaTrustedState = meta?.trustedState ?? 'untrusted' + const metaTrusted = isMetaTrusted(trustedState) + const badgeCount = counts?.badgeCount ?? 0 + const unreadCount = counts?.unreadCount ?? 0 + const useLayout = !metaTrusted && !!layoutChannel + const metaSnippet = meta ? (meta.snippetDecorated ?? meta.snippet ?? '') : '' + return { + badgeCount, + channelname: useLayout ? layoutChannel.channelname : (meta?.channelname ?? ''), + hasBadge: badgeCount > 0, + hasDraft: useLayout ? !!layoutChannel.draft : !!meta?.draft, + hasUnread: unreadCount > 0, + isError: trustedState === 'error', + isMuted: useLayout ? layoutChannel.isMuted : (meta?.isMuted ?? false), + snippet: metaSnippet, + snippetDecoration: bigSnippetDecoration(meta?.snippetDecoration ?? T.RPCChat.SnippetDecoration.none), + teamname: useLayout ? layoutChannel.teamname : (meta?.teamname ?? ''), + trustedState, + unreadCount, } - const updated = new Set() - useInboxRowsState.setState(s => { - badgeState.conversations?.forEach(conversation => { - const id = T.Chat.conversationIDToKey(conversation.convID) - updated.add(id) - - const big = ensureBigRow(s.rowsBig, id) - big.badgeCount = conversation.badgeCount - big.hasBadge = conversation.badgeCount > 0 - big.hasUnread = conversation.unreadMessages > 0 - big.unreadCount = conversation.unreadMessages - - const small = ensureSmallRow(s.rowsSmall, id) - small.badgeCount = conversation.badgeCount - small.hasBadge = conversation.badgeCount > 0 - small.hasUnread = conversation.unreadMessages > 0 - small.unreadCount = conversation.unreadMessages - }) - - for (const [id, big] of s.rowsBig) { - if (updated.has(id)) continue - big.badgeCount = 0 - big.hasBadge = false - big.hasUnread = false - big.unreadCount = 0 - } - for (const [id, small] of s.rowsSmall) { - if (updated.has(id)) continue - small.badgeCount = 0 - small.hasBadge = false - small.hasUnread = false - small.unreadCount = 0 - } - }) } -export const updateInboxRowTyping = (updates?: ReadonlyArray | null) => { - useInboxRowsState.setState(s => { - updates?.forEach(update => { - const id = T.Chat.conversationIDToKey(update.convID) - const typing = new Set(update.typers?.map(typer => typer.username)) - const small = ensureSmallRow(s.rowsSmall, id) - small.typingSnippet = buildTypingSnippet(typing) - }) - }) +export const useInboxRowSmall = (id: string): InboxRowSmall => { + const you = useCurrentUserState(s => s.username) + const meta = useInboxMetadataState(s => s.metas.get(id)) + const participantInfo = useInboxMetadataState(s => s.participants.get(id)) + const layoutRow = useInboxLayoutState(s => getSmallLayoutRow(s, id)) + const counts = useInboxBadgeState(s => s.counts.get(id)) + const typing = useInboxTypingState(s => s.typing.get(id)) + return React.useMemo( + () => computeSmallRow(id, you, meta, participantInfo, layoutRow, counts, typing), + [id, you, meta, participantInfo, layoutRow, counts, typing] + ) } -export const useInboxRowBig = (id: string): InboxRowBig => - useInboxRowsState(s => s.rowsBig.get(id)) ?? defaultInboxRowBig - -export const useInboxRowSmall = (id: string): InboxRowSmall => - useInboxRowsState(s => s.rowsSmall.get(id)) ?? defaultInboxRowSmall +export const useInboxRowBig = (id: string): InboxRowBig => { + const meta = useInboxMetadataState(s => s.metas.get(id)) + const layoutChannel = useInboxLayoutState(s => getBigLayoutChannelRow(s, id)) + const counts = useInboxBadgeState(s => s.counts.get(id)) + return React.useMemo(() => computeBigRow(meta, layoutChannel, counts), [meta, layoutChannel, counts]) +} diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index e4ffcd43b477..4cdb3fede4a4 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -554,7 +554,7 @@ export const createConversation = ( const participantInfo = uiParticipantsToParticipantInfo(uiConv.participants ?? []) if (participantInfo.all.length > 0) { const {participantInfoReceived} = require('@/chat/inbox/metadata') as typeof ChatInboxMetadataType - participantInfoReceived(conversationIDKey, participantInfo, meta) + participantInfoReceived(conversationIDKey, participantInfo) } navigateToThread(conversationIDKey, 'justCreated', highlightMessageID) From bc84c5866a6dba562418673ca624667b1c324b2d Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 3 Jul 2026 12:18:40 -0400 Subject: [PATCH 09/18] refactor(chat): split thread-context into engine/load modules --- shared/chat/conversation/thread-context.tsx | 704 +------------------- shared/chat/conversation/thread-engine.tsx | 375 +++++++++++ shared/chat/conversation/thread-load.tsx | 320 +++++++++ 3 files changed, 716 insertions(+), 683 deletions(-) create mode 100644 shared/chat/conversation/thread-engine.tsx create mode 100644 shared/chat/conversation/thread-load.tsx diff --git a/shared/chat/conversation/thread-context.tsx b/shared/chat/conversation/thread-context.tsx index 3ba88b015ea6..d4bfb3c74472 100644 --- a/shared/chat/conversation/thread-context.tsx +++ b/shared/chat/conversation/thread-context.tsx @@ -1,17 +1,9 @@ import * as Common from '@/constants/chat/common' -import * as Message from '@/constants/chat/message' import * as Meta from '@/constants/chat/meta' import * as React from 'react' import * as Strings from '@/constants/strings' import * as T from '@/constants/types' -import { - getVisibleScreen, - navigateAppend, - navigateToInbox, - navigateToThread, - navigateUp, - setChatRootParams, -} from '@/constants/router' +import {getVisibleScreen, navigateAppend, navigateToThread, navigateUp, setChatRootParams} from '@/constants/router' import {isPhone} from '@/constants/platform' import logger from '@/logger' import throttle from 'lodash/throttle' @@ -19,9 +11,6 @@ import {clearChatTimeCache} from '@/util/timestamp' import {findLast} from '@/util/arrays' import {ignorePromise} from '@/constants/utils' import {RPCError} from '@/util/errors' -import {persistRoute} from '@/util/storeless-actions' -import {uint8ArrayToString} from '@/util/uint8array' -import {useEngineActionListener} from '@/engine/action-listener' import {useCurrentUserState} from '@/stores/current-user' import {useUsersState} from '@/stores/users' import {useConfigState} from '@/stores/config' @@ -40,7 +29,6 @@ import { explodeMessagesInThreadState, failAttachmentDownloadInThreadState, finishAttachmentDownloadInThreadState, - getOrdinalForMessageID, type OptimisticReaction, retryMessageInThreadState, setMessageSubmitStateInThreadState, @@ -56,15 +44,9 @@ import { getInboxConversationParticipants, metasReceived, unboxRows, - updateInboxConversationMeta, useInboxMetadataState, } from '@/chat/inbox/metadata' -import { - loadThreadMessageIDAtIndex, - loadThreadNonblock, - markConversationRead, - threadLoadReasonToRPCReason, -} from './thread-rpc' +import {loadThreadMessageIDAtIndex, markConversationRead} from './thread-rpc' import { cancelConversationPost, createAdhocConversation, @@ -73,9 +55,15 @@ import { postConversationReaction, } from './message-rpc' import {cancelActiveThreadSearchRPC} from '../search-rpc' - -const numMessagesOnInitialLoad = isMobile ? 20 : 100 -const numMessagesOnScrollback = 100 +import { + getClientPrevFromSnapshot, + getExplodingModeFromConfig, + loadConversationThreadMessages, + numMessagesOnInitialLoad, + numMessagesOnScrollback, + persistExplodingMode, +} from './thread-load' +import {useThreadEngineListeners} from './thread-engine' const sameStringSet = (a: ReadonlySet, b: ReadonlySet) => { if (a.size !== b.size) { @@ -89,101 +77,18 @@ const sameStringSet = (a: ReadonlySet, b: ReadonlySet) => { return true } -const ignoreErrors = [ - T.RPCGen.StatusCode.scgenericapierror, - T.RPCGen.StatusCode.scapinetworkerror, - T.RPCGen.StatusCode.sctimeout, -] - const emptyParticipantInfo: T.Chat.ParticipantInfo = { all: [], contactName: new Map(), name: [], } -const getExplodingModeFromGregorItems = ( - conversationIDKey: T.Chat.ConversationIDKey, - items: ReadonlyArray<{item: T.RPCGen.Gregor1.Item}> -) => { - const explodingItems = items.filter(i => i.item.category.startsWith(Common.explodingModeGregorKeyPrefix)) - if (!explodingItems.length) { - return 0 - } - const category = `${Common.explodingModeGregorKeyPrefix}${conversationIDKey}` - const item = explodingItems.find(i => i.item.category === category) - if (!item) { - // Other conversations have exploding modes but this one's category is absent, - // meaning it was dismissed: the mode is off. - return 0 - } - const secondsString = uint8ArrayToString(item.item.body) - const seconds = parseInt(secondsString, 10) - if (isNaN(seconds)) { - logger.warn(`Got dirty exploding mode ${secondsString} for category ${category}`) - return undefined - } - return seconds -} - -const getExplodingModeFromConfig = (conversationIDKey: T.Chat.ConversationIDKey) => - getExplodingModeFromGregorItems(conversationIDKey, useConfigState.getState().gregorPushState) ?? 0 - -const persistExplodingMode = ( - conversationIDKey: T.Chat.ConversationIDKey, - meta: T.Chat.ConversationMeta, - seconds: number -) => { - const f = async () => { - logger.info(`Setting exploding mode for conversation ${conversationIDKey} to ${seconds}`) - const category = `${Common.explodingModeGregorKeyPrefix}${conversationIDKey}` - const convRetention = Meta.getEffectiveRetentionPolicy(meta) - try { - if (seconds === 0 || seconds === convRetention.seconds) { - await T.RPCGen.gregorDismissCategoryRpcPromise({category}) - } else { - await T.RPCGen.gregorUpdateCategoryRpcPromise({ - body: seconds.toString(), - category, - dtime: {offset: 0, time: 0}, - }) - logger.info(`Successfully set exploding mode for conversation ${conversationIDKey} to ${seconds}`) - } - } catch (error) { - if (error instanceof RPCError) { - if (seconds !== 0) { - logger.error( - `Failed to set exploding mode for conversation ${conversationIDKey} to ${seconds}. Service responded with: ${error.message}` - ) - } else { - logger.error( - `Failed to unset exploding mode for conversation ${conversationIDKey}. Service responded with: ${error.message}` - ) - } - if (ignoreErrors.includes(error.code)) { - return - } - } - throw error - } - } - ignorePromise(f()) -} - const formatTextForQuoting = (text: string) => text .split('\n') .map(line => `> ${line}\n`) .join('') -const getClientPrevFromSnapshot = (snapshot: ConversationThreadState): T.Chat.MessageID => { - const ordinal = findLast(snapshot.messageOrdinals ?? [], o => { - const m = snapshot.messageMap.get(o) - return !!m?.id - }) - const message = ordinal ? snapshot.messageMap.get(ordinal) : undefined - return message?.id || T.Chat.numberToMessageID(0) -} - // The inbox metadata store is the single owner of conversation meta; fall back to // an empty meta for reads that predate an unbox. const emptyConversationMeta = Meta.makeConversationMeta() @@ -268,8 +173,8 @@ type SelectedConversationOptions = ThreadLoadStatusOptions & { skipThreadLoad?: boolean } -type ScrollDirection = 'none' | 'back' | 'forward' -type LoadMoreMessagesParams = ThreadLoadStatusOptions & { +export type ScrollDirection = 'none' | 'back' | 'forward' +export type LoadMoreMessagesParams = ThreadLoadStatusOptions & { allowMarkAsRead?: boolean centeredMessageID?: { conversationIDKey: T.Chat.ConversationIDKey @@ -300,7 +205,7 @@ type LoadNewerMessagesDueToScroll = ( type JumpToRecent = (options?: ThreadLoadStatusOptions) => void type MessagesClear = () => void type SelectedConversation = (options?: SelectedConversationOptions) => void -type ConversationThreadActions = { +export type ConversationThreadActions = { addMessages: ( messages: ReadonlyArray, opt?: { @@ -418,428 +323,6 @@ const useScrollLoadGate = () => { } } -const scrollDirectionToPagination = ( - scrollDirection: ScrollDirection, - numberOfMessagesToLoad: number -) => { - const pagination = { - last: false, - next: '', - num: numberOfMessagesToLoad, - previous: '', - } - switch (scrollDirection) { - case 'none': - break - case 'back': - pagination.next = 'deadbeef' - break - case 'forward': - pagination.previous = 'deadbeef' - } - return pagination -} - -const getCurrentUser = () => { - const s = useCurrentUserState.getState() - return {devicename: s.deviceName, username: s.username} -} - -const getLastOrdinalFromSnapshot = (snapshot: ConversationThreadState) => - snapshot.messageOrdinals?.at(-1) ?? T.Chat.numberToOrdinal(0) - -const getOrdinalForMessageIDInSnapshot = ( - snapshot: ConversationThreadState, - messageID: T.Chat.MessageID -) => - getOrdinalForMessageID( - snapshot.messageMap, - snapshot.pendingOutboxToOrdinal, - messageID, - snapshot.messageIDToOrdinal - ) - -const applyMessagesUpdatedToThread = ( - conversationIDKey: T.Chat.ConversationIDKey, - messagesUpdated: T.RPCChat.MessagesUpdated, - actions: ConversationThreadActions -) => { - if (!messagesUpdated.updates) return - const snapshot = actions.getSnapshot() - const activelyLookingAtThread = Common.isUserActivelyLookingAtThisThread(conversationIDKey) - if (!snapshot.loaded && !activelyLookingAtThread) { - return - } - - const {username, devicename} = getCurrentUser() - const messages = messagesUpdated.updates.flatMap(uimsg => { - if (!Message.getMessageID(uimsg)) return [] - const message = Message.uiMessageToMessage( - conversationIDKey, - uimsg, - username, - () => getLastOrdinalFromSnapshot(actions.getSnapshot()), - devicename - ) - return message ? [message] : [] - }) - if (messages.length === 0) { - return - } - actions.addMessages(messages, {liveUpdate: true, markAsRead: activelyLookingAtThread}) -} - -const applyIncomingMutationToThread = ( - conversationIDKey: T.Chat.ConversationIDKey, - valid: T.RPCChat.UIMessageValid, - modifiedMessage: T.RPCChat.UIMessage | null | undefined, - actions: ConversationThreadActions -) => { - const body = valid.messageBody - logger.info(`Got chat incoming message of messageType: ${body.messageType}`) - const mutationOrdinal = T.Chat.numberToOrdinal(valid.messageID) - if (actions.getSnapshot().messageMap.has(mutationOrdinal)) { - actions.deleteMessages({liveUpdate: true, ordinals: [mutationOrdinal]}) - } - - switch (body.messageType) { - case T.RPCChat.MessageType.edit: - if (modifiedMessage) { - const {username, devicename} = getCurrentUser() - const modMessage = Message.uiMessageToMessage( - conversationIDKey, - modifiedMessage, - username, - () => getLastOrdinalFromSnapshot(actions.getSnapshot()), - devicename - ) - if (modMessage) { - actions.addMessages([modMessage], {liveUpdate: true}) - } - } - return true - case T.RPCChat.MessageType.delete: { - const {delete: d} = body - if (d.messageIDs) { - const messageIDs = T.Chat.numbersToMessageIDs(d.messageIDs) - const snapshot = actions.getSnapshot() - const isExplodeNow = messageIDs.some(id => { - const ordinal = getOrdinalForMessageIDInSnapshot(snapshot, id) - const message = ordinal ? snapshot.messageMap.get(ordinal) : undefined - return !!((message?.type === 'text' || message?.type === 'attachment') && message.exploding) - }) - - if (isExplodeNow) { - actions.explodeMessages(messageIDs, valid.senderUsername, true) - } else { - actions.deleteMessages({liveUpdate: true, messageIDs}) - } - } - return true - } - default: - return false - } -} - -const applyIncomingMessageToThread = ( - conversationIDKey: T.Chat.ConversationIDKey, - incomingMessage: T.RPCChat.IncomingMessage, - actions: ConversationThreadActions -) => { - const snapshot = actions.getSnapshot() - const activelyLookingAtThread = Common.isUserActivelyLookingAtThisThread(conversationIDKey) - if (!snapshot.loaded && !activelyLookingAtThread) { - return - } - const {message: cMsg, modifiedMessage} = incomingMessage - const {username, devicename} = getCurrentUser() - - if ( - cMsg.state === T.RPCChat.MessageUnboxedState.outbox && - cMsg.outbox.messageType === T.RPCChat.MessageType.reaction - ) { - actions.updateOptimisticReactionDecorated( - T.Chat.stringToOutboxID(cMsg.outbox.outboxID), - cMsg.outbox.decoratedTextBody ?? cMsg.outbox.body - ) - return - } - - if (cMsg.state === T.RPCChat.MessageUnboxedState.valid) { - const {valid} = cMsg - const {messageType} = valid.messageBody - if ( - (messageType === T.RPCChat.MessageType.edit || messageType === T.RPCChat.MessageType.delete) && - applyIncomingMutationToThread(conversationIDKey, valid, modifiedMessage, actions) - ) { - return - } - } - - const message = Message.uiMessageToMessage( - conversationIDKey, - cMsg, - username, - () => getLastOrdinalFromSnapshot(actions.getSnapshot()), - devicename - ) - if (!message) return - - if ( - cMsg.state === T.RPCChat.MessageUnboxedState.valid && - cMsg.valid.messageBody.messageType === T.RPCChat.MessageType.attachmentuploaded && - message.type === 'attachment' - ) { - const placeholderID = cMsg.valid.messageBody.attachmentuploaded.messageID - const snapshot = actions.getSnapshot() - const ordinal = getOrdinalForMessageIDInSnapshot(snapshot, T.Chat.numberToMessageID(placeholderID)) - const existing = ordinal ? snapshot.messageMap.get(ordinal) : undefined - if (ordinal && existing) { - actions.addMessages([Message.upgradeMessage(existing, {...message, ordinal})], { - liveUpdate: true, - markAsRead: activelyLookingAtThread, - }) - } else { - if (snapshot.moreToLoadForward) { - return - } - actions.addMessages([message], {liveUpdate: true, markAsRead: activelyLookingAtThread}) - } - } else { - if (actions.getSnapshot().moreToLoadForward) { - return - } - actions.addMessages([message], {liveUpdate: true, markAsRead: activelyLookingAtThread}) - } -} - -const applyFailedMessageToThread = ( - conversationIDKey: T.Chat.ConversationIDKey, - failedMessage: T.RPCChat.FailedMessageInfo, - actions: ConversationThreadActions -) => { - const {outboxRecords} = failedMessage - if (!outboxRecords) return - for (const outboxRecord of outboxRecords) { - if (T.Chat.conversationIDToKey(outboxRecord.convID) !== conversationIDKey) { - continue - } - const s = outboxRecord.state - if (s.state !== T.RPCChat.OutboxStateType.error) { - continue - } - const {error} = s - const outboxID = T.Chat.rpcOutboxIDToOutboxID(outboxRecord.outboxID) - actions.setMessageErrored(outboxID, Message.rpcErrorToString(error), error.typ) - } -} - -const applyReactionUpdateToThread = ( - reactionUpdate: T.RPCChat.ReactionUpdateNotif, - actions: ConversationThreadActions -) => { - if (!reactionUpdate.reactionUpdates || reactionUpdate.reactionUpdates.length === 0) { - return - } - const updates = reactionUpdate.reactionUpdates.map(ru => ({ - reactions: Message.reactionMapToReactions(ru.reactions), - targetMsgID: T.Chat.numberToMessageID(ru.targetMsgID), - })) - actions.updateReactions(updates) -} - -const applyExpungeToThread = (expunge: T.RPCChat.ExpungeInfo, actions: ConversationThreadActions) => { - const deletableMessageTypes = - useConfigState.getState().chatDeletableByDeleteHistory || Common.allMessageTypes - actions.deleteMessages({ - deletableMessageTypes, - liveUpdate: true, - upToMessageID: T.Chat.numberToMessageID(expunge.expunge.upto), - }) -} - -const applyEphemeralPurgeToThread = ( - ephemeralPurge: T.RPCChat.EphemeralPurgeNotifInfo, - actions: ConversationThreadActions -) => { - const messageIDs = ephemeralPurge.msgs?.reduce>((arr, msg) => { - const msgID = Message.getMessageID(msg) - if (msgID) { - arr.push(msgID) - } - return arr - }, []) - if (messageIDs) { - actions.explodeMessages(messageIDs, undefined, true) - } -} - -const loadConversationThreadMessages = ( - conversationIDKey: T.Chat.ConversationIDKey, - p: LoadMoreMessagesParams, - actions: ConversationThreadActions -) => { - if (!T.Chat.isValidConversationIDKey(conversationIDKey)) { - return - } - const {scrollDirection = 'none', numberOfMessagesToLoad = numMessagesOnInitialLoad} = p - const { - allowMarkAsRead = true, - reason, - forceContainsLatestCalc, - messageIDControl, - knownRemotes, - centeredMessageID, - isThreadLoadCurrent, - onThreadLoadStatus, - } = p - const isCurrentThreadLoad = () => isThreadLoadCurrent?.() ?? true - - const f = async () => { - if (!isCurrentThreadLoad()) { - logger.info('loadMoreMessages: bail: stale mounted thread load') - return - } - - if (!conversationIDKey || !T.Chat.isValidConversationIDKey(conversationIDKey)) { - logger.info('loadMoreMessages: bail: no conversationIDKey') - return - } - - const loadStartedSnapshot = actions.getSnapshot() - const currentMeta = getMeta(conversationIDKey) - if (currentMeta.membershipType === 'youAreReset' || currentMeta.rekeyers.size > 0) { - logger.info('loadMoreMessages: bail: we are reset') - return - } - const loadStartedLiveUpdateVersion = loadStartedSnapshot.liveUpdateVersion - const protectLoadedFocusRefresh = - loadStartedSnapshot.loaded && - scrollDirection === 'none' && - !centeredMessageID && - !messageIDControl && - (reason === 'focused' || reason === 'tab selected') - logger.info( - `loadMoreMessages: calling rpc convo: ${conversationIDKey} num: ${numberOfMessagesToLoad} reason: ${reason}` - ) - - const loadingKey = Strings.waitingKeyChatThreadLoad(conversationIDKey) - let reconciled = false - const onGotThread = (thread: string, why: string) => { - if (!thread) { - return - } - if (!isCurrentThreadLoad()) { - logger.info(`loadMoreMessages: stale response ignored: ${why}`) - return - } - if ( - protectLoadedFocusRefresh && - actions.getSnapshot().liveUpdateVersion !== loadStartedLiveUpdateVersion - ) { - logger.info( - `loadMoreMessages: stale response ignored after live update: ${why} reason=${reason} convID=${conversationIDKey}` - ) - return - } - - const {username, devicename} = getCurrentUser() - const {messages, pagination} = Message.parseUIMessagesJSON( - conversationIDKey, - thread, - username, - devicename, - () => getLastOrdinalFromSnapshot(actions.getSnapshot()) - ) - const moreToLoad = pagination ? !pagination.last : true - const canMarkReadForThreadWindow = - allowMarkAsRead && - !centeredMessageID && - !messageIDControl && - scrollDirection !== 'back' && - reason !== 'findNewestConversation' && - reason !== 'findNewestConversationFromLayout' - let validatedRange: {from: T.Chat.Ordinal; to: T.Chat.Ordinal} | undefined - if (messages.length) { - if (scrollDirection === 'none' && !reconciled) { - const ords = messages - .filter(m => m.conversationMessage !== false && m.type !== 'deleted') - .map(m => m.ordinal) - if (ords.length > 0) { - validatedRange = { - from: Math.min(...ords) as T.Chat.Ordinal, - to: Math.max(...ords) as T.Chat.Ordinal, - } - } - reconciled = true - } - } - actions.applyThreadLoad({ - centered: !!centeredMessageID, - disableActiveMarkRead: !allowMarkAsRead || !!centeredMessageID || !!messageIDControl, - enableActiveMarkRead: canMarkReadForThreadWindow, - forceContainsLatestCalc, - messages, - moreToLoad, - scrollDirection, - validatedRange, - }) - - if (canMarkReadForThreadWindow) { - actions.markThreadAsRead() - } - } - - const pagination = messageIDControl - ? null - : scrollDirectionToPagination(scrollDirection, numberOfMessagesToLoad) - try { - const results = await loadThreadNonblock({ - conversationIDKey, - knownRemotes, - messageIDControl, - onCachedThread: thread => onGotThread(thread, 'cached'), - onFullThread: thread => onGotThread(thread, 'full'), - onThreadStatus: status => { - logger.info( - `loadMoreMessages: thread status received: convID: ${conversationIDKey} typ: ${status.typ}` - ) - if (isCurrentThreadLoad()) { - onThreadLoadStatus?.(conversationIDKey, status.typ) - } - }, - pagination, - reason: threadLoadReasonToRPCReason(reason), - waitingKey: loadingKey, - }) - if (!isCurrentThreadLoad()) { - return - } - updateInboxConversationMeta(conversationIDKey, {offline: results.offline}) - } catch (error) { - if (!isCurrentThreadLoad()) { - return - } - if (error instanceof RPCError) { - logger.warn(`loadMoreMessages: error: ${error.desc}`) - if (error.code === T.RPCGen.StatusCode.scchatnotinteam) { - // We're no longer in this conv's team. Clear the persisted last-route - // (ui.routeState2) so app startup doesn't keep restoring and reloading - // this conv, which would re-trigger this error on every launch. - persistRoute(true, true, () => useConfigState.getState().startup.loaded) - navigateToInbox(true, 'maybeKickedFromTeam') - } - if (error.code !== T.RPCGen.StatusCode.scteamreaderror) { - throw error - } - } - } - } - - ignorePromise(f()) -} - export const useConversationThreadSelector = ( selector: (snapshot: ConversationThreadState) => TValue ) => { @@ -1491,29 +974,23 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => } ) const [threadActions] = React.useState(() => { - const threadActionsHolder: {current?: ConversationThreadActions} = {} - const loadMoreMessagesImpl = (p: LoadMoreMessagesParams) => { - const actions = threadActionsHolder.current - if (actions) { - loadConversationThreadMessages(id, p, actions) - } - } - const throttledLoadMoreMessages = throttle(loadMoreMessagesImpl, 500) + const impl = (p: LoadMoreMessagesParams) => loadConversationThreadMessages(id, p, threadActions) + const throttled = throttle(impl, 500) // The throttle keeps only the last trailing call, so a centered or jump-to-recent // load issued between two other loads would be silently dropped — after // loadMessagesCentered already cleared the thread. Run those immediately instead. const loadMoreMessages: LoadMoreMessages = Object.assign( (p: LoadMoreMessagesParams) => { if (p.centeredMessageID || p.messageIDControl || p.reason === 'jump to recent') { - throttledLoadMoreMessages.cancel() - loadMoreMessagesImpl(p) + throttled.cancel() + impl(p) } else { - throttledLoadMoreMessages(p) + throttled(p) } }, { cancel: () => { - throttledLoadMoreMessages.cancel() + throttled.cancel() }, } ) @@ -1556,7 +1033,6 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => updateOptimisticReactionDecorated, updateReactions, } - threadActionsHolder.current = threadActions return threadActions }) React.useEffect(() => { @@ -1564,145 +1040,7 @@ const ConversationThreadProviderInner = (p: ConversationThreadProviderProps) => threadActions.loadMoreMessages.cancel() } }, [threadActions]) - useEngineActionListener('chat.1.NotifyChat.NewChatActivity', action => { - const {activity} = action.payload.params - switch (activity.activityType) { - case T.RPCChat.ChatActivityType.incomingMessage: { - const {incomingMessage} = activity - const conversationIDKey = T.Chat.conversationIDToKey(incomingMessage.convID) - if (conversationIDKey === id) { - applyIncomingMessageToThread(conversationIDKey, incomingMessage, threadActions) - } - break - } - case T.RPCChat.ChatActivityType.messagesUpdated: { - const {messagesUpdated} = activity - const conversationIDKey = T.Chat.conversationIDToKey(messagesUpdated.convID) - if (conversationIDKey === id) { - applyMessagesUpdatedToThread(conversationIDKey, messagesUpdated, threadActions) - } - break - } - case T.RPCChat.ChatActivityType.failedMessage: { - const {failedMessage} = activity - applyFailedMessageToThread(id, failedMessage, threadActions) - break - } - case T.RPCChat.ChatActivityType.reactionUpdate: { - const {reactionUpdate} = activity - const conversationIDKey = T.Chat.conversationIDToKey(reactionUpdate.convID) - if (conversationIDKey === id) { - applyReactionUpdateToThread(reactionUpdate, threadActions) - } - break - } - case T.RPCChat.ChatActivityType.expunge: { - const {expunge} = activity - const conversationIDKey = T.Chat.conversationIDToKey(expunge.convID) - if (conversationIDKey === id) { - applyExpungeToThread(expunge, threadActions) - } - break - } - case T.RPCChat.ChatActivityType.ephemeralPurge: { - const {ephemeralPurge} = activity - const conversationIDKey = T.Chat.conversationIDToKey(ephemeralPurge.convID) - if (conversationIDKey === id) { - applyEphemeralPurgeToThread(ephemeralPurge, threadActions) - } - break - } - default: - } - }) - useEngineActionListener('keybase.1.gregorUI.pushState', action => { - const items = (action.payload.params.state.items ?? []).reduce< - Array<{md: T.RPCGen.Gregor1.Metadata; item: T.RPCGen.Gregor1.Item}> - >((arr, {md, item}) => { - if (md && item) { - arr.push({item, md}) - } - return arr - }, []) - const seconds = getExplodingModeFromGregorItems(id, items) - if (seconds !== undefined) { - threadActions.setExplodingMode(seconds, true) - } - }) - useEngineActionListener('chat.1.NotifyChat.ChatRequestInfo', action => { - const {convID, info, msgID} = action.payload.params - if (T.Chat.conversationIDToKey(convID) !== id) { - return - } - const requestInfo = Message.uiRequestInfoToChatRequestInfo(info) - if (!requestInfo) { - logger.error( - `got 'NotifyChat.ChatRequestInfo' with no valid requestInfo for convID ${id} messageID: ${msgID}. The local version may be absent or out of date.` - ) - return - } - threadActions.receiveRequestInfo(T.Chat.numberToMessageID(msgID), requestInfo) - }) - useEngineActionListener('chat.1.NotifyChat.ChatPaymentInfo', action => { - const {convID, info, msgID} = action.payload.params - if (T.Chat.conversationIDToKey(convID) !== id) { - return - } - const paymentInfo = Message.uiPaymentInfoToChatPaymentInfo([info]) - if (!paymentInfo) { - logger.error( - `got 'NotifyChat.ChatPaymentInfo' with no valid paymentInfo for convID ${id} messageID: ${msgID}. The local version may be absent or out of date.` - ) - return - } - threadActions.receivePaymentInfo(T.Chat.numberToMessageID(msgID), paymentInfo) - }) - useEngineActionListener('chat.1.NotifyChat.ChatPromptUnfurl', action => { - const {convID, domain, msgID} = action.payload.params - if (T.Chat.conversationIDToKey(convID) !== id) { - return - } - threadActions.showUnfurlPrompt(T.Chat.numberToMessageID(msgID), domain) - }) - useEngineActionListener('chat.1.chatUi.chatCoinFlipStatus', action => { - const statuses = action.payload.params.statuses?.filter(status => { - return T.Chat.stringToConversationIDKey(status.convID) === id - }) - if (statuses?.length) { - threadActions.updateCoinFlipStatuses(statuses) - } - }) - useEngineActionListener('chat.1.NotifyChat.ChatTypingUpdate', action => { - action.payload.params.typingUpdates?.forEach(update => { - if (T.Chat.conversationIDToKey(update.convID) === id) { - threadActions.setTyping(new Set(update.typers?.map(typer => typer.username))) - } - }) - }) - useEngineActionListener('chat.1.NotifyChat.ChatAttachmentDownloadProgress', action => { - const {bytesComplete, bytesTotal, convID, msgID} = action.payload.params - if (T.Chat.conversationIDToKey(convID) === id) { - threadActions.updateAttachmentDownloadProgress(msgID, bytesComplete, bytesTotal) - } - }) - useEngineActionListener('chat.1.NotifyChat.ChatAttachmentDownloadComplete', action => { - const {convID, msgID} = action.payload.params - if (T.Chat.conversationIDToKey(convID) === id) { - threadActions.completeAttachmentDownload(msgID) - } - }) - useEngineActionListener('chat.1.NotifyChat.ChatAttachmentUploadStart', action => { - const {convID, outboxID} = action.payload.params - if (T.Chat.conversationIDToKey(convID) === id) { - threadActions.updateAttachmentUploadProgress(outboxID) - } - }) - useEngineActionListener('chat.1.NotifyChat.ChatAttachmentUploadProgress', action => { - const {bytesComplete, bytesTotal, convID, outboxID} = action.payload.params - if (T.Chat.conversationIDToKey(convID) === id) { - threadActions.updateAttachmentUploadProgress(outboxID, bytesComplete, bytesTotal) - } - }) + useThreadEngineListeners(id, threadActions) return ( { + const s = useCurrentUserState.getState() + return {devicename: s.deviceName, username: s.username} +} + +export const applyMessagesUpdatedToThread = ( + conversationIDKey: T.Chat.ConversationIDKey, + messagesUpdated: T.RPCChat.MessagesUpdated, + actions: ConversationThreadActions +) => { + if (!messagesUpdated.updates) return + const snapshot = actions.getSnapshot() + const activelyLookingAtThread = Common.isUserActivelyLookingAtThisThread(conversationIDKey) + if (!snapshot.loaded && !activelyLookingAtThread) { + return + } + + const {username, devicename} = getCurrentUser() + const messages = messagesUpdated.updates.flatMap(uimsg => { + if (!Message.getMessageID(uimsg)) return [] + const message = Message.uiMessageToMessage( + conversationIDKey, + uimsg, + username, + () => getLastOrdinalFromSnapshot(actions.getSnapshot()), + devicename + ) + return message ? [message] : [] + }) + if (messages.length === 0) { + return + } + actions.addMessages(messages, {liveUpdate: true, markAsRead: activelyLookingAtThread}) +} + +export const applyIncomingMutationToThread = ( + conversationIDKey: T.Chat.ConversationIDKey, + valid: T.RPCChat.UIMessageValid, + modifiedMessage: T.RPCChat.UIMessage | null | undefined, + actions: ConversationThreadActions +) => { + const body = valid.messageBody + logger.info(`Got chat incoming message of messageType: ${body.messageType}`) + const mutationOrdinal = T.Chat.numberToOrdinal(valid.messageID) + if (actions.getSnapshot().messageMap.has(mutationOrdinal)) { + actions.deleteMessages({liveUpdate: true, ordinals: [mutationOrdinal]}) + } + + switch (body.messageType) { + case T.RPCChat.MessageType.edit: + if (modifiedMessage) { + const {username, devicename} = getCurrentUser() + const modMessage = Message.uiMessageToMessage( + conversationIDKey, + modifiedMessage, + username, + () => getLastOrdinalFromSnapshot(actions.getSnapshot()), + devicename + ) + if (modMessage) { + actions.addMessages([modMessage], {liveUpdate: true}) + } + } + return true + case T.RPCChat.MessageType.delete: { + const {delete: d} = body + if (d.messageIDs) { + const messageIDs = T.Chat.numbersToMessageIDs(d.messageIDs) + const snapshot = actions.getSnapshot() + const isExplodeNow = messageIDs.some(id => { + const ordinal = getOrdinalForMessageIDInSnapshot(snapshot, id) + const message = ordinal ? snapshot.messageMap.get(ordinal) : undefined + return !!((message?.type === 'text' || message?.type === 'attachment') && message.exploding) + }) + + if (isExplodeNow) { + actions.explodeMessages(messageIDs, valid.senderUsername, true) + } else { + actions.deleteMessages({liveUpdate: true, messageIDs}) + } + } + return true + } + default: + return false + } +} + +export const applyIncomingMessageToThread = ( + conversationIDKey: T.Chat.ConversationIDKey, + incomingMessage: T.RPCChat.IncomingMessage, + actions: ConversationThreadActions +) => { + const snapshot = actions.getSnapshot() + const activelyLookingAtThread = Common.isUserActivelyLookingAtThisThread(conversationIDKey) + if (!snapshot.loaded && !activelyLookingAtThread) { + return + } + const {message: cMsg, modifiedMessage} = incomingMessage + const {username, devicename} = getCurrentUser() + + if ( + cMsg.state === T.RPCChat.MessageUnboxedState.outbox && + cMsg.outbox.messageType === T.RPCChat.MessageType.reaction + ) { + actions.updateOptimisticReactionDecorated( + T.Chat.stringToOutboxID(cMsg.outbox.outboxID), + cMsg.outbox.decoratedTextBody ?? cMsg.outbox.body + ) + return + } + + if (cMsg.state === T.RPCChat.MessageUnboxedState.valid) { + const {valid} = cMsg + const {messageType} = valid.messageBody + if ( + (messageType === T.RPCChat.MessageType.edit || messageType === T.RPCChat.MessageType.delete) && + applyIncomingMutationToThread(conversationIDKey, valid, modifiedMessage, actions) + ) { + return + } + } + + const message = Message.uiMessageToMessage( + conversationIDKey, + cMsg, + username, + () => getLastOrdinalFromSnapshot(actions.getSnapshot()), + devicename + ) + if (!message) return + + if ( + cMsg.state === T.RPCChat.MessageUnboxedState.valid && + cMsg.valid.messageBody.messageType === T.RPCChat.MessageType.attachmentuploaded && + message.type === 'attachment' + ) { + const placeholderID = cMsg.valid.messageBody.attachmentuploaded.messageID + const snapshot = actions.getSnapshot() + const ordinal = getOrdinalForMessageIDInSnapshot(snapshot, T.Chat.numberToMessageID(placeholderID)) + const existing = ordinal ? snapshot.messageMap.get(ordinal) : undefined + if (ordinal && existing) { + actions.addMessages([Message.upgradeMessage(existing, {...message, ordinal})], { + liveUpdate: true, + markAsRead: activelyLookingAtThread, + }) + } else { + if (snapshot.moreToLoadForward) { + return + } + actions.addMessages([message], {liveUpdate: true, markAsRead: activelyLookingAtThread}) + } + } else { + if (actions.getSnapshot().moreToLoadForward) { + return + } + actions.addMessages([message], {liveUpdate: true, markAsRead: activelyLookingAtThread}) + } +} + +export const applyFailedMessageToThread = ( + conversationIDKey: T.Chat.ConversationIDKey, + failedMessage: T.RPCChat.FailedMessageInfo, + actions: ConversationThreadActions +) => { + const {outboxRecords} = failedMessage + if (!outboxRecords) return + for (const outboxRecord of outboxRecords) { + if (T.Chat.conversationIDToKey(outboxRecord.convID) !== conversationIDKey) { + continue + } + const s = outboxRecord.state + if (s.state !== T.RPCChat.OutboxStateType.error) { + continue + } + const {error} = s + const outboxID = T.Chat.rpcOutboxIDToOutboxID(outboxRecord.outboxID) + actions.setMessageErrored(outboxID, Message.rpcErrorToString(error), error.typ) + } +} + +export const applyReactionUpdateToThread = ( + reactionUpdate: T.RPCChat.ReactionUpdateNotif, + actions: ConversationThreadActions +) => { + if (!reactionUpdate.reactionUpdates || reactionUpdate.reactionUpdates.length === 0) { + return + } + const updates = reactionUpdate.reactionUpdates.map(ru => ({ + reactions: Message.reactionMapToReactions(ru.reactions), + targetMsgID: T.Chat.numberToMessageID(ru.targetMsgID), + })) + actions.updateReactions(updates) +} + +export const applyExpungeToThread = (expunge: T.RPCChat.ExpungeInfo, actions: ConversationThreadActions) => { + const deletableMessageTypes = + useConfigState.getState().chatDeletableByDeleteHistory || Common.allMessageTypes + actions.deleteMessages({ + deletableMessageTypes, + liveUpdate: true, + upToMessageID: T.Chat.numberToMessageID(expunge.expunge.upto), + }) +} + +export const applyEphemeralPurgeToThread = ( + ephemeralPurge: T.RPCChat.EphemeralPurgeNotifInfo, + actions: ConversationThreadActions +) => { + const messageIDs = ephemeralPurge.msgs?.reduce>((arr, msg) => { + const msgID = Message.getMessageID(msg) + if (msgID) { + arr.push(msgID) + } + return arr + }, []) + if (messageIDs) { + actions.explodeMessages(messageIDs, undefined, true) + } +} + +export const useThreadEngineListeners = ( + id: T.Chat.ConversationIDKey, + threadActions: ConversationThreadActions +): void => { + useEngineActionListener('chat.1.NotifyChat.NewChatActivity', action => { + const {activity} = action.payload.params + switch (activity.activityType) { + case T.RPCChat.ChatActivityType.incomingMessage: { + const {incomingMessage} = activity + const conversationIDKey = T.Chat.conversationIDToKey(incomingMessage.convID) + if (conversationIDKey === id) { + applyIncomingMessageToThread(conversationIDKey, incomingMessage, threadActions) + } + break + } + case T.RPCChat.ChatActivityType.messagesUpdated: { + const {messagesUpdated} = activity + const conversationIDKey = T.Chat.conversationIDToKey(messagesUpdated.convID) + if (conversationIDKey === id) { + applyMessagesUpdatedToThread(conversationIDKey, messagesUpdated, threadActions) + } + break + } + case T.RPCChat.ChatActivityType.failedMessage: { + const {failedMessage} = activity + applyFailedMessageToThread(id, failedMessage, threadActions) + break + } + case T.RPCChat.ChatActivityType.reactionUpdate: { + const {reactionUpdate} = activity + const conversationIDKey = T.Chat.conversationIDToKey(reactionUpdate.convID) + if (conversationIDKey === id) { + applyReactionUpdateToThread(reactionUpdate, threadActions) + } + break + } + case T.RPCChat.ChatActivityType.expunge: { + const {expunge} = activity + const conversationIDKey = T.Chat.conversationIDToKey(expunge.convID) + if (conversationIDKey === id) { + applyExpungeToThread(expunge, threadActions) + } + break + } + case T.RPCChat.ChatActivityType.ephemeralPurge: { + const {ephemeralPurge} = activity + const conversationIDKey = T.Chat.conversationIDToKey(ephemeralPurge.convID) + if (conversationIDKey === id) { + applyEphemeralPurgeToThread(ephemeralPurge, threadActions) + } + break + } + default: + } + }) + useEngineActionListener('keybase.1.gregorUI.pushState', action => { + const items = (action.payload.params.state.items ?? []).reduce< + Array<{md: T.RPCGen.Gregor1.Metadata; item: T.RPCGen.Gregor1.Item}> + >((arr, {md, item}) => { + if (md && item) { + arr.push({item, md}) + } + return arr + }, []) + const seconds = getExplodingModeFromGregorItems(id, items) + if (seconds !== undefined) { + threadActions.setExplodingMode(seconds, true) + } + }) + useEngineActionListener('chat.1.NotifyChat.ChatRequestInfo', action => { + const {convID, info, msgID} = action.payload.params + if (T.Chat.conversationIDToKey(convID) !== id) { + return + } + const requestInfo = Message.uiRequestInfoToChatRequestInfo(info) + if (!requestInfo) { + logger.error( + `got 'NotifyChat.ChatRequestInfo' with no valid requestInfo for convID ${id} messageID: ${msgID}. The local version may be absent or out of date.` + ) + return + } + threadActions.receiveRequestInfo(T.Chat.numberToMessageID(msgID), requestInfo) + }) + useEngineActionListener('chat.1.NotifyChat.ChatPaymentInfo', action => { + const {convID, info, msgID} = action.payload.params + if (T.Chat.conversationIDToKey(convID) !== id) { + return + } + const paymentInfo = Message.uiPaymentInfoToChatPaymentInfo([info]) + if (!paymentInfo) { + logger.error( + `got 'NotifyChat.ChatPaymentInfo' with no valid paymentInfo for convID ${id} messageID: ${msgID}. The local version may be absent or out of date.` + ) + return + } + threadActions.receivePaymentInfo(T.Chat.numberToMessageID(msgID), paymentInfo) + }) + useEngineActionListener('chat.1.NotifyChat.ChatPromptUnfurl', action => { + const {convID, domain, msgID} = action.payload.params + if (T.Chat.conversationIDToKey(convID) !== id) { + return + } + threadActions.showUnfurlPrompt(T.Chat.numberToMessageID(msgID), domain) + }) + useEngineActionListener('chat.1.chatUi.chatCoinFlipStatus', action => { + const statuses = action.payload.params.statuses?.filter(status => { + return T.Chat.stringToConversationIDKey(status.convID) === id + }) + if (statuses?.length) { + threadActions.updateCoinFlipStatuses(statuses) + } + }) + useEngineActionListener('chat.1.NotifyChat.ChatTypingUpdate', action => { + action.payload.params.typingUpdates?.forEach(update => { + if (T.Chat.conversationIDToKey(update.convID) === id) { + threadActions.setTyping(new Set(update.typers?.map(typer => typer.username))) + } + }) + }) + useEngineActionListener('chat.1.NotifyChat.ChatAttachmentDownloadProgress', action => { + const {bytesComplete, bytesTotal, convID, msgID} = action.payload.params + if (T.Chat.conversationIDToKey(convID) === id) { + threadActions.updateAttachmentDownloadProgress(msgID, bytesComplete, bytesTotal) + } + }) + useEngineActionListener('chat.1.NotifyChat.ChatAttachmentDownloadComplete', action => { + const {convID, msgID} = action.payload.params + if (T.Chat.conversationIDToKey(convID) === id) { + threadActions.completeAttachmentDownload(msgID) + } + }) + useEngineActionListener('chat.1.NotifyChat.ChatAttachmentUploadStart', action => { + const {convID, outboxID} = action.payload.params + if (T.Chat.conversationIDToKey(convID) === id) { + threadActions.updateAttachmentUploadProgress(outboxID) + } + }) + useEngineActionListener('chat.1.NotifyChat.ChatAttachmentUploadProgress', action => { + const {bytesComplete, bytesTotal, convID, outboxID} = action.payload.params + if (T.Chat.conversationIDToKey(convID) === id) { + threadActions.updateAttachmentUploadProgress(outboxID, bytesComplete, bytesTotal) + } + }) +} diff --git a/shared/chat/conversation/thread-load.tsx b/shared/chat/conversation/thread-load.tsx new file mode 100644 index 000000000000..6a7299a12b59 --- /dev/null +++ b/shared/chat/conversation/thread-load.tsx @@ -0,0 +1,320 @@ +import * as Common from '@/constants/chat/common' +import * as Message from '@/constants/chat/message' +import * as Meta from '@/constants/chat/meta' +import * as Strings from '@/constants/strings' +import * as T from '@/constants/types' +import {navigateToInbox} from '@/constants/router' +import logger from '@/logger' +import {findLast} from '@/util/arrays' +import {ignorePromise} from '@/constants/utils' +import {RPCError} from '@/util/errors' +import {persistRoute} from '@/util/storeless-actions' +import {uint8ArrayToString} from '@/util/uint8array' +import {useCurrentUserState} from '@/stores/current-user' +import {useConfigState} from '@/stores/config' +import {getOrdinalForMessageID} from './thread-message-state' +import {getInboxConversationMeta, updateInboxConversationMeta} from '@/chat/inbox/metadata' +import {loadThreadNonblock, threadLoadReasonToRPCReason} from './thread-rpc' +import type { + ConversationThreadActions, + ConversationThreadState, + LoadMoreMessagesParams, + ScrollDirection, +} from './thread-context' + +export const numMessagesOnInitialLoad = isMobile ? 20 : 100 +export const numMessagesOnScrollback = 100 + +const ignoreErrors = [ + T.RPCGen.StatusCode.scgenericapierror, + T.RPCGen.StatusCode.scapinetworkerror, + T.RPCGen.StatusCode.sctimeout, +] + +// The inbox metadata store is the single owner of conversation meta; fall back to +// an empty meta for reads that predate an unbox. +const emptyConversationMeta = Meta.makeConversationMeta() +const getMeta = (id: T.Chat.ConversationIDKey) => getInboxConversationMeta(id) ?? emptyConversationMeta + +const getCurrentUser = () => { + const s = useCurrentUserState.getState() + return {devicename: s.deviceName, username: s.username} +} + +export const getExplodingModeFromGregorItems = ( + conversationIDKey: T.Chat.ConversationIDKey, + items: ReadonlyArray<{item: T.RPCGen.Gregor1.Item}> +) => { + const explodingItems = items.filter(i => i.item.category.startsWith(Common.explodingModeGregorKeyPrefix)) + if (!explodingItems.length) { + return 0 + } + const category = `${Common.explodingModeGregorKeyPrefix}${conversationIDKey}` + const item = explodingItems.find(i => i.item.category === category) + if (!item) { + // Other conversations have exploding modes but this one's category is absent, + // meaning it was dismissed: the mode is off. + return 0 + } + const secondsString = uint8ArrayToString(item.item.body) + const seconds = parseInt(secondsString, 10) + if (isNaN(seconds)) { + logger.warn(`Got dirty exploding mode ${secondsString} for category ${category}`) + return undefined + } + return seconds +} + +export const getExplodingModeFromConfig = (conversationIDKey: T.Chat.ConversationIDKey) => + getExplodingModeFromGregorItems(conversationIDKey, useConfigState.getState().gregorPushState) ?? 0 + +export const persistExplodingMode = ( + conversationIDKey: T.Chat.ConversationIDKey, + meta: T.Chat.ConversationMeta, + seconds: number +) => { + const f = async () => { + logger.info(`Setting exploding mode for conversation ${conversationIDKey} to ${seconds}`) + const category = `${Common.explodingModeGregorKeyPrefix}${conversationIDKey}` + const convRetention = Meta.getEffectiveRetentionPolicy(meta) + try { + if (seconds === 0 || seconds === convRetention.seconds) { + await T.RPCGen.gregorDismissCategoryRpcPromise({category}) + } else { + await T.RPCGen.gregorUpdateCategoryRpcPromise({ + body: seconds.toString(), + category, + dtime: {offset: 0, time: 0}, + }) + logger.info(`Successfully set exploding mode for conversation ${conversationIDKey} to ${seconds}`) + } + } catch (error) { + if (error instanceof RPCError) { + if (seconds !== 0) { + logger.error( + `Failed to set exploding mode for conversation ${conversationIDKey} to ${seconds}. Service responded with: ${error.message}` + ) + } else { + logger.error( + `Failed to unset exploding mode for conversation ${conversationIDKey}. Service responded with: ${error.message}` + ) + } + if (ignoreErrors.includes(error.code)) { + return + } + } + throw error + } + } + ignorePromise(f()) +} + +export const getClientPrevFromSnapshot = (snapshot: ConversationThreadState): T.Chat.MessageID => { + const ordinal = findLast(snapshot.messageOrdinals ?? [], o => { + const m = snapshot.messageMap.get(o) + return !!m?.id + }) + const message = ordinal ? snapshot.messageMap.get(ordinal) : undefined + return message?.id || T.Chat.numberToMessageID(0) +} + +export const getLastOrdinalFromSnapshot = (snapshot: ConversationThreadState) => + snapshot.messageOrdinals?.at(-1) ?? T.Chat.numberToOrdinal(0) + +export const getOrdinalForMessageIDInSnapshot = ( + snapshot: ConversationThreadState, + messageID: T.Chat.MessageID +) => + getOrdinalForMessageID( + snapshot.messageMap, + snapshot.pendingOutboxToOrdinal, + messageID, + snapshot.messageIDToOrdinal + ) + +export const scrollDirectionToPagination = ( + scrollDirection: ScrollDirection, + numberOfMessagesToLoad: number +) => { + const pagination = { + last: false, + next: '', + num: numberOfMessagesToLoad, + previous: '', + } + switch (scrollDirection) { + case 'none': + break + case 'back': + pagination.next = 'deadbeef' + break + case 'forward': + pagination.previous = 'deadbeef' + } + return pagination +} + +export const loadConversationThreadMessages = ( + conversationIDKey: T.Chat.ConversationIDKey, + p: LoadMoreMessagesParams, + actions: ConversationThreadActions +) => { + if (!T.Chat.isValidConversationIDKey(conversationIDKey)) { + return + } + const {scrollDirection = 'none', numberOfMessagesToLoad = numMessagesOnInitialLoad} = p + const { + allowMarkAsRead = true, + reason, + forceContainsLatestCalc, + messageIDControl, + knownRemotes, + centeredMessageID, + isThreadLoadCurrent, + onThreadLoadStatus, + } = p + const isCurrentThreadLoad = () => isThreadLoadCurrent?.() ?? true + + const f = async () => { + if (!isCurrentThreadLoad()) { + logger.info('loadMoreMessages: bail: stale mounted thread load') + return + } + + if (!conversationIDKey || !T.Chat.isValidConversationIDKey(conversationIDKey)) { + logger.info('loadMoreMessages: bail: no conversationIDKey') + return + } + + const loadStartedSnapshot = actions.getSnapshot() + const currentMeta = getMeta(conversationIDKey) + if (currentMeta.membershipType === 'youAreReset' || currentMeta.rekeyers.size > 0) { + logger.info('loadMoreMessages: bail: we are reset') + return + } + const loadStartedLiveUpdateVersion = loadStartedSnapshot.liveUpdateVersion + const protectLoadedFocusRefresh = + loadStartedSnapshot.loaded && + scrollDirection === 'none' && + !centeredMessageID && + !messageIDControl && + (reason === 'focused' || reason === 'tab selected') + logger.info( + `loadMoreMessages: calling rpc convo: ${conversationIDKey} num: ${numberOfMessagesToLoad} reason: ${reason}` + ) + + const loadingKey = Strings.waitingKeyChatThreadLoad(conversationIDKey) + let reconciled = false + const onGotThread = (thread: string, why: string) => { + if (!thread) { + return + } + if (!isCurrentThreadLoad()) { + logger.info(`loadMoreMessages: stale response ignored: ${why}`) + return + } + if ( + protectLoadedFocusRefresh && + actions.getSnapshot().liveUpdateVersion !== loadStartedLiveUpdateVersion + ) { + logger.info( + `loadMoreMessages: stale response ignored after live update: ${why} reason=${reason} convID=${conversationIDKey}` + ) + return + } + + const {username, devicename} = getCurrentUser() + const {messages, pagination} = Message.parseUIMessagesJSON( + conversationIDKey, + thread, + username, + devicename, + () => getLastOrdinalFromSnapshot(actions.getSnapshot()) + ) + const moreToLoad = pagination ? !pagination.last : true + const canMarkReadForThreadWindow = + allowMarkAsRead && + !centeredMessageID && + !messageIDControl && + scrollDirection !== 'back' && + reason !== 'findNewestConversation' && + reason !== 'findNewestConversationFromLayout' + let validatedRange: {from: T.Chat.Ordinal; to: T.Chat.Ordinal} | undefined + if (messages.length) { + if (scrollDirection === 'none' && !reconciled) { + const ords = messages + .filter(m => m.conversationMessage !== false && m.type !== 'deleted') + .map(m => m.ordinal) + if (ords.length > 0) { + validatedRange = { + from: Math.min(...ords) as T.Chat.Ordinal, + to: Math.max(...ords) as T.Chat.Ordinal, + } + } + reconciled = true + } + } + actions.applyThreadLoad({ + centered: !!centeredMessageID, + disableActiveMarkRead: !allowMarkAsRead || !!centeredMessageID || !!messageIDControl, + enableActiveMarkRead: canMarkReadForThreadWindow, + forceContainsLatestCalc, + messages, + moreToLoad, + scrollDirection, + validatedRange, + }) + + if (canMarkReadForThreadWindow) { + actions.markThreadAsRead() + } + } + + const pagination = messageIDControl + ? null + : scrollDirectionToPagination(scrollDirection, numberOfMessagesToLoad) + try { + const results = await loadThreadNonblock({ + conversationIDKey, + knownRemotes, + messageIDControl, + onCachedThread: thread => onGotThread(thread, 'cached'), + onFullThread: thread => onGotThread(thread, 'full'), + onThreadStatus: status => { + logger.info( + `loadMoreMessages: thread status received: convID: ${conversationIDKey} typ: ${status.typ}` + ) + if (isCurrentThreadLoad()) { + onThreadLoadStatus?.(conversationIDKey, status.typ) + } + }, + pagination, + reason: threadLoadReasonToRPCReason(reason), + waitingKey: loadingKey, + }) + if (!isCurrentThreadLoad()) { + return + } + updateInboxConversationMeta(conversationIDKey, {offline: results.offline}) + } catch (error) { + if (!isCurrentThreadLoad()) { + return + } + if (error instanceof RPCError) { + logger.warn(`loadMoreMessages: error: ${error.desc}`) + if (error.code === T.RPCGen.StatusCode.scchatnotinteam) { + // We're no longer in this conv's team. Clear the persisted last-route + // (ui.routeState2) so app startup doesn't keep restoring and reloading + // this conv, which would re-trigger this error on every launch. + persistRoute(true, true, () => useConfigState.getState().startup.loaded) + navigateToInbox(true, 'maybeKickedFromTeam') + } + if (error.code !== T.RPCGen.StatusCode.scteamreaderror) { + throw error + } + } + } + } + + ignorePromise(f()) +} From 2992db39d80e2d1b5f97ded2e52237d1de5a8261 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 3 Jul 2026 12:23:44 -0400 Subject: [PATCH 10/18] refactor(chat): per-conversation orange-line updates map --- shared/chat/conversation/normal/container.tsx | 5 +-- .../chat/conversation/orange-line-context.tsx | 35 +++++++++---------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/shared/chat/conversation/normal/container.tsx b/shared/chat/conversation/normal/container.tsx index 9cbf91796c54..981b777f7580 100644 --- a/shared/chat/conversation/normal/container.tsx +++ b/shared/chat/conversation/normal/container.tsx @@ -119,16 +119,13 @@ const useOrangeLine = ( }) }) - const explicitOrangeLine = useExplicitOrangeLineState(s => s.update) + const explicitOrangeLine = useExplicitOrangeLineState(s => s.updates.get(id)) const explicitOrangeLineVersionRef = React.useRef(explicitOrangeLine?.version ?? 0) React.useEffect(() => { if (!explicitOrangeLine || explicitOrangeLine.version <= explicitOrangeLineVersionRef.current) { return } explicitOrangeLineVersionRef.current = explicitOrangeLine.version - if (explicitOrangeLine.conversationIDKey !== id) { - return - } setOrangeLine(explicitOrangeLine.ordinal) }, [explicitOrangeLine, id]) diff --git a/shared/chat/conversation/orange-line-context.tsx b/shared/chat/conversation/orange-line-context.tsx index 95121569b0e5..3baa33242644 100644 --- a/shared/chat/conversation/orange-line-context.tsx +++ b/shared/chat/conversation/orange-line-context.tsx @@ -3,38 +3,37 @@ import * as T from '@/constants/types' import * as Z from '@/util/zustand' type ExplicitOrangeLine = T.Immutable<{ - conversationIDKey: T.Chat.ConversationIDKey ordinal: T.Chat.Ordinal version: number }> type ExplicitOrangeLineState = T.Immutable<{ - update?: ExplicitOrangeLine + updates: Map dispatch: { resetState: () => void setOrangeLine: (conversationIDKey: T.Chat.ConversationIDKey, ordinal: T.Chat.Ordinal) => void } }> -let explicitOrangeLineVersion = 0 - export const useExplicitOrangeLineState = Z.createZustand( 'chat-explicit-orange-line', - set => ({ - dispatch: { - resetState: Z.defaultReset, - setOrangeLine: (conversationIDKey, ordinal) => { - set(s => { - s.update = { - conversationIDKey, - ordinal, - version: ++explicitOrangeLineVersion, - } - }) + set => { + let explicitOrangeLineVersion = 0 + return { + dispatch: { + resetState: Z.defaultReset, + setOrangeLine: (conversationIDKey, ordinal) => { + set(s => { + s.updates.set(conversationIDKey, { + ordinal, + version: ++explicitOrangeLineVersion, + }) + }) + }, }, - }, - update: undefined, - }) + updates: new Map(), + } + } ) export const setConversationOrangeLine = ( From 37da58904a9527151d26d5246c94ae61d2cb0c66 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 3 Jul 2026 12:25:47 -0400 Subject: [PATCH 11/18] refactor(chat): merge focus/scroll contexts into ThreadRefsContext --- .../conversation/input-area/normal/index.tsx | 6 +-- .../conversation/input-area/normal/input.tsx | 4 +- shared/chat/conversation/list-area/index.tsx | 11 ++--- .../messages/wrapper/long-pressable/index.tsx | 4 +- shared/chat/conversation/normal/container.tsx | 10 ++-- shared/chat/conversation/normal/context.tsx | 47 +++++++------------ 6 files changed, 33 insertions(+), 49 deletions(-) diff --git a/shared/chat/conversation/input-area/normal/index.tsx b/shared/chat/conversation/input-area/normal/index.tsx index d6092f97e532..1345f95228d1 100644 --- a/shared/chat/conversation/input-area/normal/index.tsx +++ b/shared/chat/conversation/input-area/normal/index.tsx @@ -12,7 +12,7 @@ import * as T from '@/constants/types' import {indefiniteArticle} from '@/util/string' import {infoPanelWidthTablet} from '../../info-panel/common' import {assertionToDisplay} from '@/common-adapters/usernames' -import {FocusContext, ScrollContext} from '@/chat/conversation/normal/context' +import {ThreadRefsContext} from '@/chat/conversation/normal/context' import type {RefType as InputRef} from './input.shared' import {useConversationCenter, useConversationCenterActions} from '../../center-context' import { @@ -183,7 +183,7 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() { doInjectText(inputRef, text, focus) } - const {scrollToBottom} = React.useContext(ScrollContext) + const {scrollToBottom} = React.useContext(ThreadRefsContext) const onSubmit = (text: string) => { if (!text) return injectText('', true) @@ -277,7 +277,7 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() { } }, [focusInputCounter, updateUnsentText, unsentText]) - const {setInputRef} = React.useContext(FocusContext) + const {setInputRef} = React.useContext(ThreadRefsContext) React.useEffect(() => { setInputRef(inputRef.current) }, [setInputRef]) diff --git a/shared/chat/conversation/input-area/normal/input.tsx b/shared/chat/conversation/input-area/normal/input.tsx index 66e4b724d2dc..d8730aff0983 100644 --- a/shared/chat/conversation/input-area/normal/input.tsx +++ b/shared/chat/conversation/input-area/normal/input.tsx @@ -12,7 +12,7 @@ import type {PlatformInputProps as Props} from './input.shared' export type {Selection, RefType, TextInfo, PlatformInputProps} from './input.shared' import {formatDurationShort} from '@/util/timestamp' import {useSuggestors} from '../suggestors' -import {ScrollContext} from '@/chat/conversation/normal/context' +import {ThreadRefsContext} from '@/chat/conversation/normal/context' import {getTextStyle} from '@/common-adapters/text.styles' import {useColorScheme} from 'react-native' import {useConversationThreadID} from '../../thread-context' @@ -646,7 +646,7 @@ const useKeyboard = (p: UseKeyboardProps) => { const {onChangeText, onEditLastMessage, showReplyPreview} = p const lastText = React.useRef('') const setReplyTo = InputState.useConversationInputDispatch(s => s.setReplyTo) - const {scrollDown, scrollUp} = React.useContext(ScrollContext) + const {scrollDown, scrollUp} = React.useContext(ThreadRefsContext) const onCancelReply = () => { setReplyTo(ChatTypes.numberToOrdinal(0)) } diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index c4dd3696e8aa..6144a0f067bb 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -8,7 +8,7 @@ import SpecialBottomMessage from '../messages/special-bottom-message' import SpecialTopMessage from '../messages/special-top-message' import {MessageRow} from '../messages/wrapper' import {PerfProfiler} from '@/perf/react-profiler' -import {ScrollContext} from '../normal/context' +import {ThreadRefsContext} from '../normal/context' import {useConversationCenter} from '../center-context' import { useConversationThreadID, @@ -24,7 +24,6 @@ import {getMessageRowType} from '../messages/row-metadata' import * as InputState from '../input-area/input-state' import sortedIndexOf from 'lodash/sortedIndexOf' import {copyToClipboard} from '@/util/storeless-actions' -import {FocusContext} from '../normal/context' import noop from 'lodash/noop' import {LegendList} from '@legendapp/list/react' import type {LegendListRef} from '@/common-adapters' @@ -196,7 +195,7 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { const getItemType = useGetItemType() - // Imperative scroll for ScrollContext + // Imperative scroll for ThreadRefsContext const scrollToBottom = React.useCallback(() => { void listRef.current?.scrollToEnd({animated: false}) }, []) @@ -219,7 +218,7 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { }) }, []) - const {setScrollRef} = React.useContext(ScrollContext) + const {setScrollRef} = React.useContext(ThreadRefsContext) React.useEffect(() => { setScrollRef({scrollDown, scrollToBottom, scrollUp}) }, [scrollDown, scrollToBottom, scrollUp, setScrollRef]) @@ -343,7 +342,7 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { const jumpToRecent = useJumpToRecent(scrollToBottom, messageOrdinals.length) - const {focusInput} = React.useContext(FocusContext) + const {focusInput} = React.useContext(ThreadRefsContext) const handleListClick = (ev: React.MouseEvent) => { const target = ev.target as { closest?: (s: string) => unknown @@ -510,7 +509,7 @@ const useNativeScrolling = (p: { listRef.current?.scrollToOffset({animated: false, offset}) }, [insetsBottom, keyboardAnimHeight, listRef]) - const {setScrollRef} = React.useContext(ScrollContext) + const {setScrollRef} = React.useContext(ThreadRefsContext) React.useEffect(() => { setScrollRef({scrollDown: noop, scrollToBottom, scrollUp: noop}) }, [setScrollRef, scrollToBottom]) diff --git a/shared/chat/conversation/messages/wrapper/long-pressable/index.tsx b/shared/chat/conversation/messages/wrapper/long-pressable/index.tsx index 864ec6f24360..88f059e38130 100644 --- a/shared/chat/conversation/messages/wrapper/long-pressable/index.tsx +++ b/shared/chat/conversation/messages/wrapper/long-pressable/index.tsx @@ -15,7 +15,7 @@ type Props = { } import {useConversationThreadToggleSearch} from '../../../thread-context' import Swipeable, {type SwipeableMethods} from '@/common-adapters/swipeable-row' -import {FocusContext} from '@/chat/conversation/normal/context' +import {ThreadRefsContext} from '@/chat/conversation/normal/context' function ReplyIcon({progress}: {progress: Animated.Value}) { const opacity = progress.interpolate({inputRange: [-20, 0], outputRange: [1, 0], extrapolate: 'clamp'}) @@ -30,7 +30,7 @@ function LongPressable(props: Props & {ref?: React.Ref}) { const toggleThreadSearch = useConversationThreadToggleSearch() const setReplyTo = InputState.useConversationInputDispatch(s => s.setReplyTo) const ordinal = useOrdinal() - const {focusInput} = React.useContext(FocusContext) + const {focusInput} = React.useContext(ThreadRefsContext) const swipeRef = React.useRef(null) if (!isMobile) { diff --git a/shared/chat/conversation/normal/container.tsx b/shared/chat/conversation/normal/container.tsx index 981b777f7580..e59c5e85cb0b 100644 --- a/shared/chat/conversation/normal/container.tsx +++ b/shared/chat/conversation/normal/container.tsx @@ -4,7 +4,7 @@ import * as React from 'react' import {useEngineActionListener} from '@/engine/action-listener' import Normal from '.' import * as T from '@/constants/types' -import {FocusProvider, ScrollProvider} from './context' +import {ThreadRefsProvider} from './context' import {OrangeLineContext, SetOrangeLineContext, useExplicitOrangeLineState} from '../orange-line-context' import {ChatTeamProvider} from '../team-hooks' import {ConversationCenterProvider} from '../center-context' @@ -192,11 +192,9 @@ const NormalWrapper = function NormalWrapper() { > - - - - - + + + diff --git a/shared/chat/conversation/normal/context.tsx b/shared/chat/conversation/normal/context.tsx index ecb27fad4e99..d23a2dac163f 100644 --- a/shared/chat/conversation/normal/context.tsx +++ b/shared/chat/conversation/normal/context.tsx @@ -2,30 +2,6 @@ import * as React from 'react' type FocusRefType = null | {focus: () => void} -type FocusContextType = { - focusInput: () => void - setInputRef: (inputRef: FocusRefType) => void -} - -export const FocusContext = React.createContext({ - focusInput: () => {}, - setInputRef: () => {}, -}) -FocusContext.displayName = 'FocusContext' - -export const FocusProvider = function FocusProvider({children}: {children: React.ReactNode}) { - const inputRef = React.useRef(null) - const [value] = React.useState(() => ({ - focusInput: () => { - inputRef.current?.focus() - }, - setInputRef: r => { - inputRef.current = r - }, - })) - return {children} -} - type ScrollType = { scrollUp: () => void scrollDown: () => void @@ -33,21 +9,29 @@ type ScrollType = { } type ScrollRefType = null | ScrollType -type ScrollContextType = ScrollType & { +type ThreadRefsType = ScrollType & { + focusInput: () => void + setInputRef: (inputRef: FocusRefType) => void setScrollRef: (scrollRef: ScrollRefType) => void } -export const ScrollContext = React.createContext({ +export const ThreadRefsContext = React.createContext({ + focusInput: () => {}, scrollDown: () => {}, scrollToBottom: () => {}, scrollUp: () => {}, + setInputRef: () => {}, setScrollRef: () => {}, }) -ScrollContext.displayName = 'ScrollContext' +ThreadRefsContext.displayName = 'ThreadRefsContext' -export const ScrollProvider = function ScrollProvider({children}: {children: React.ReactNode}) { +export const ThreadRefsProvider = function ThreadRefsProvider({children}: {children: React.ReactNode}) { + const inputRef = React.useRef(null) const scrollRef = React.useRef(null) - const [value] = React.useState(() => ({ + const [value] = React.useState(() => ({ + focusInput: () => { + inputRef.current?.focus() + }, scrollDown: () => { scrollRef.current?.scrollDown() }, @@ -57,9 +41,12 @@ export const ScrollProvider = function ScrollProvider({children}: {children: Rea scrollUp: () => { scrollRef.current?.scrollUp() }, + setInputRef: r => { + inputRef.current = r + }, setScrollRef: r => { scrollRef.current = r }, })) - return {children} + return {children} } From 555a3f8670c4171e6facb7e4c784d7e8246c23ac Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 3 Jul 2026 12:31:01 -0400 Subject: [PATCH 12/18] refactor(chat): inbox controls reset via username key remount Drop the state.username === username guard boilerplate in useInboxState; inboxControls is component-local state, so resetting it on user switch is handled by remounting InboxBody via key={username} at its render sites instead of threading username through every setInboxControls updater. --- shared/chat/inbox/index.tsx | 19 +++++++++++++-- shared/chat/inbox/use-inbox-state.tsx | 35 ++++++++------------------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/shared/chat/inbox/index.tsx b/shared/chat/inbox/index.tsx index d6c6b0956c21..8f0d557b957a 100644 --- a/shared/chat/inbox/index.tsx +++ b/shared/chat/inbox/index.tsx @@ -30,6 +30,7 @@ import * as TestIDs from '@/tests/e2e/shared/test-ids' import {createPortal} from 'react-dom' import SearchRow from './search-row' import {useOpenedRowState} from './row/opened-row-state' +import {useCurrentUserState} from '@/stores/current-user' import {Alert} from 'react-native' import {SafeAreaView as ScreensSafeAreaView} from 'react-native-screens/experimental' @@ -298,7 +299,15 @@ function InboxWithSearch(props: { refreshInbox?: T.Chat.ChatRootInboxRefresh }) { const search = useInboxSearch() - return + const username = useCurrentUserState(s => s.username) + return ( + + ) } // Desktop InboxBody @@ -595,8 +604,14 @@ function InboxBody(props: ControlledInboxProps) { } function Inbox(props: InboxProps) { + const username = useCurrentUserState(s => s.username) return props.search ? ( - + ) : ( ) diff --git a/shared/chat/inbox/use-inbox-state.tsx b/shared/chat/inbox/use-inbox-state.tsx index 7f2b76fd7431..1f7075483bb1 100644 --- a/shared/chat/inbox/use-inbox-state.tsx +++ b/shared/chat/inbox/use-inbox-state.tsx @@ -76,12 +76,8 @@ export function useInboxState( inboxNumSmallRowsLoaded: false, inboxNumSmallRowsUserChanged: false, smallTeamsExpanded: false, - username, })) - const controlsMatchUser = inboxControls.username === username - const inboxNumSmallRows = controlsMatchUser ? inboxControls.inboxNumSmallRows : 5 - const inboxNumSmallRowsLoaded = controlsMatchUser ? inboxControls.inboxNumSmallRowsLoaded : false - const smallTeamsExpanded = controlsMatchUser ? inboxControls.smallTeamsExpanded : false + const {inboxNumSmallRows, inboxNumSmallRowsLoaded, smallTeamsExpanded} = inboxControls const inboxNumSmallRowsLoadVersionRef = React.useRef(0) const setInboxNumSmallRows = React.useCallback((rows: number, persist = true) => { @@ -89,11 +85,10 @@ export function useInboxState( return } setInboxControls(state => ({ + ...state, inboxNumSmallRows: rows, inboxNumSmallRowsLoaded: true, inboxNumSmallRowsUserChanged: true, - smallTeamsExpanded: state.username === username ? state.smallTeamsExpanded : false, - username, })) if (!persist) { return @@ -107,17 +102,13 @@ export function useInboxState( } catch {} } C.ignorePromise(f()) - }, [username]) + }, []) const toggleSmallTeamsExpanded = React.useCallback(() => { setInboxControls(state => ({ - inboxNumSmallRows: state.username === username ? state.inboxNumSmallRows : 5, - inboxNumSmallRowsLoaded: state.username === username ? state.inboxNumSmallRowsLoaded : false, - inboxNumSmallRowsUserChanged: - state.username === username ? state.inboxNumSmallRowsUserChanged : false, - smallTeamsExpanded: !(state.username === username ? state.smallTeamsExpanded : false), - username, + ...state, + smallTeamsExpanded: !state.smallTeamsExpanded, })) - }, [username]) + }, []) const { allowShowFloatingButton, @@ -181,16 +172,14 @@ export function useInboxState( } const count = rows.i ?? -1 setInboxControls(state => { - if (state.username === username && state.inboxNumSmallRowsUserChanged) { + if (state.inboxNumSmallRowsUserChanged) { return state } return { - inboxNumSmallRows: - count > 0 ? count : state.username === username ? state.inboxNumSmallRows : 5, + ...state, + inboxNumSmallRows: count > 0 ? count : state.inboxNumSmallRows, inboxNumSmallRowsLoaded: true, inboxNumSmallRowsUserChanged: false, - smallTeamsExpanded: state.username === username ? state.smallTeamsExpanded : false, - username, } }) }, @@ -199,12 +188,8 @@ export function useInboxState( return } setInboxControls(state => ({ - inboxNumSmallRows: state.username === username ? state.inboxNumSmallRows : 5, + ...state, inboxNumSmallRowsLoaded: true, - inboxNumSmallRowsUserChanged: - state.username === username ? state.inboxNumSmallRowsUserChanged : false, - smallTeamsExpanded: state.username === username ? state.smallTeamsExpanded : false, - username, })) } ) From 863ebdfca398054c94e91a1e5ed4f861b2d1e618 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 3 Jul 2026 12:32:34 -0400 Subject: [PATCH 13/18] refactor(chat): emoji picker pick handoff via self-cleaning mailbox Traced all three pickKey flows (addAlias, chatInput, reaction): desktop already renders EmojiPicker in-tree inside a popup and hands picks back via a plain onPickAction callback, bypassing the store entirely. Mobile opens the picker as a separate routed screen (chatChooseEmoji), so a callback can't cross the nav-params boundary (serializable only) - keeping the store there is correct. Both mobile consumers (addAlias, chatInput) already clear their key with updatePickerMap(key, undefined) right after reading it, so the handoff is already self-cleaning; added a file-header comment documenting the pattern so the two-path split (callback on desktop, mailbox on mobile) isn't rediscovered from scratch. The 'reaction' pickKey is written by the picker route but never read back - the reaction toggle happens directly inside the picker via onPickAddToMessageID/conversationIDKey route params - a pre-existing dead write left untouched as out of scope for this pass. --- shared/chat/emoji-picker/use-picker.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/shared/chat/emoji-picker/use-picker.tsx b/shared/chat/emoji-picker/use-picker.tsx index fba031bbd56d..ef1825dfabf8 100644 --- a/shared/chat/emoji-picker/use-picker.tsx +++ b/shared/chat/emoji-picker/use-picker.tsx @@ -2,6 +2,13 @@ import * as Z from '@/util/zustand' import type * as T from '@/constants/types' import type {RenderableEmoji} from '@/common-adapters/emoji' +// Mailbox for handing an emoji pick back from the mobile chatChooseEmoji route +// to whichever screen pushed it. On mobile the picker is a separate routed +// screen, so its result can't come back as a callback prop (nav params must be +// serializable); on desktop the picker renders in-tree inside a popup and uses +// a plain onPickAction callback instead, bypassing this store entirely. +// Each consumer must clear its own key with updatePickerMap(key, undefined) +// once it reads a pick, so a stale value isn't replayed on the next mount. export type PickKey = 'addAlias' | 'chatInput' | 'reaction' type PickerValue = { emojiStr: string From 4ed72ee804197b3757b507ab8a8dbcc5c07c9581 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 3 Jul 2026 12:34:44 -0400 Subject: [PATCH 14/18] chore(chat): lifecycle-audit module-level chat state Audited three never-explicitly-reset module-level stores for logout survival: - header-portal-state: portalNode clears via its ref callback firing null on unmount, portalContent clears in an unmount effect cleanup; both owning components only live in the logged-in chat tree, so logout unmounts them and clears this state as a side effect. Left as-is, documented the invariant. - block-buttons-state: moved loadGeneration from a module let into the store as a plain number field so resetState's existing dispatch path covers it; loadPromise stays module-level (renamed to activeLoadPromise) since promises can't live in immer state, with a comment tying it to resetState's generation bump so a load in flight at reset time can't win the race. - shared-timers: observers are added/removed in message row effect cleanups, and logout unmounts every message row, so the timer/ref maps always drain on logout. No code change, documented the invariant. --- shared/chat/blocking/block-buttons-state.tsx | 29 ++++++++++++------- .../messages/wrapper/shared-timers.tsx | 5 ++++ shared/chat/inbox/header-portal-state.tsx | 5 ++++ 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/shared/chat/blocking/block-buttons-state.tsx b/shared/chat/blocking/block-buttons-state.tsx index a8487a41a59c..fab3b76bd0e6 100644 --- a/shared/chat/blocking/block-buttons-state.tsx +++ b/shared/chat/blocking/block-buttons-state.tsx @@ -28,11 +28,13 @@ const gregorItemsToBlockButtons = ( type Store = T.Immutable<{ blockButtonsMap: ReadonlyMap + loadGeneration: number loaded: boolean }> const makeInitialStore = (): Store => ({ blockButtonsMap: new Map(), + loadGeneration: 0, loaded: false, }) @@ -46,8 +48,11 @@ type State = Store & { } } -let loadPromise: Promise | undefined -let loadGeneration = 0 +// Promises can't live in immer-managed state, so this stays module-level. It's +// paired with the store's loadGeneration: resetState clears this AND bumps the +// generation so a load in flight at reset time can't win the race and write +// into the fresh state after it resolves. +let activeLoadPromise: Promise | undefined export const useBlockButtonsState = Z.createZustand('block-buttons', (set, get) => { const setFromGregorItems: State['dispatch']['updateFromGregorItems'] = items => { @@ -59,39 +64,41 @@ export const useBlockButtonsState = Z.createZustand('block-buttons', (set const dispatch: State['dispatch'] = { load: () => { - if (get().loaded || loadPromise) { + if (get().loaded || activeLoadPromise) { return } - const generation = loadGeneration + const generation = get().loadGeneration const request = (async () => { try { const state = await T.RPCGen.gregorGetStateRpcPromise() - if (generation === loadGeneration) { + if (generation === get().loadGeneration) { setFromGregorItems(state.items) } } catch (error) { logger.warn('Failed to load block button state', error) } })() - loadPromise = request + activeLoadPromise = request ignorePromise( request.finally(() => { - if (loadPromise === request) { - loadPromise = undefined + if (activeLoadPromise === request) { + activeLoadPromise = undefined } }) ) }, resetState: () => { - loadGeneration++ - loadPromise = undefined + activeLoadPromise = undefined set(s => ({ ...makeInitialStore(), dispatch: s.dispatch, + loadGeneration: s.loadGeneration + 1, })) }, updateFromGregorItems: items => { - loadGeneration++ + set(s => { + s.loadGeneration++ + }) setFromGregorItems(items) }, } diff --git a/shared/chat/conversation/messages/wrapper/shared-timers.tsx b/shared/chat/conversation/messages/wrapper/shared-timers.tsx index 139e72e099c0..60fbd6aad00e 100644 --- a/shared/chat/conversation/messages/wrapper/shared-timers.tsx +++ b/shared/chat/conversation/messages/wrapper/shared-timers.tsx @@ -6,6 +6,11 @@ import logger from '@/logger' * be kept in sync. Timers are given a key that can be * subscribed to. When all observers of a timer are * removed the timeout is cancelled and the key deleted + * + * No explicit logout reset: every observer here is added from a message row's + * effect and removed in that effect's cleanup (see exploding-meta.tsx), and + * logout unmounts every message row. So _refs/_timers always drain back to + * empty on logout without this module needing to know about it. */ export type SharedTimerID = number diff --git a/shared/chat/inbox/header-portal-state.tsx b/shared/chat/inbox/header-portal-state.tsx index 0d9a2e2e5e5b..b7fcb10c146d 100644 --- a/shared/chat/inbox/header-portal-state.tsx +++ b/shared/chat/inbox/header-portal-state.tsx @@ -1,5 +1,10 @@ import * as React from 'react' +// These never get an explicit logout reset: the ref callback that sets +// portalNode fires with null on unmount, and the effect that sets +// portalContent clears it on unmount too. Both owning components live only +// inside the logged-in chat tree, so logout unmounts them and clears this +// module state as a side effect - no extra reset wiring needed. let portalNode: HTMLElement | null = null let portalContent: React.ReactElement | null = null const listeners = new Set<() => void>() From 312b5dd4fa2450a70c8eab4110630e3836414835 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 3 Jul 2026 12:43:23 -0400 Subject: [PATCH 15/18] docs(chat): update chat readme to current data architecture --- shared/chat/readme.md | 44 ++++++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/shared/chat/readme.md b/shared/chat/readme.md index 21640bbe9398..c77311b5c35e 100644 --- a/shared/chat/readme.md +++ b/shared/chat/readme.md @@ -1,19 +1,37 @@ How chat works: -## Inbox: +## Data ownership -Conversations are of 2 basic types. - Small: adhoc conversations or teams with only the #general channel - Big: teams with multiple channels +Chat data is split across several focused stores instead of one global redux tree. Roughly: + +- **Inbox metadata** (`chat/inbox/metadata.tsx`, `useInboxMetadataState`) is the single owner of conversation `meta` (trustedState, snippet, participants pointer, draft, timestamp, etc.) and `participants`. All meta writes go through `metasReceived`, which version-gates each incoming meta against the currently stored one (`Meta.updateMeta`) so a stale/out-of-order update can't clobber newer data. Callers that already merged from the current meta (e.g. `updateInboxConversationMeta`, error metas, incremental inbox sync) pass `{force: true}` to bypass gating. Converters live in `constants/chat/meta.tsx` (`baseMetaFromUIItem` is the shared base used by the various `*ToConversationMeta` functions). +- **Per-conversation thread store** (`chat/conversation/thread-context.tsx`) is a vanilla zustand store created fresh per mounted `ConversationThreadProvider` and destroyed when the provider unmounts. It holds `messageMap`/`messageOrdinals`/`messageIDToOrdinal`/`messageTypeMap`/`pendingOutboxToOrdinal`, live `typing` (a `Set`), exploding mode, and payment/request/flip/unfurl maps. It reads conversation meta from the inbox metadata store rather than owning its own copy (`useThreadMeta`, `getMeta`). The module is split: `thread-engine.tsx` holds engine-notification handlers (`applyMessagesUpdatedToThread`, `applyIncomingMutationToThread`, etc.) and `thread-load.tsx` holds thread-load logic (RPC calls, exploding-mode-from-gregor, pagination sizing). +- **Inbox rows are computed, not cached.** `chat/inbox/rows-state.tsx` exposes `useInboxRowSmall`/`useInboxRowBig`, which `useMemo` a display row from: inbox metadata (meta + participants), `chat/inbox/layout-state.tsx` (a memoized index built from the service's `UIInboxLayout`, used as a fallback for rows whose meta isn't trusted yet), `chat/inbox/badge-state.tsx` (badge/unread counts, fully replaced from each `BadgeState` RPC payload), and `chat/inbox/typing-state.tsx` (per-conversation typing username sets, merged in from `ChatTypingUpdate`). Merge precedence is one rule: meta wins whenever it's `trusted` or `error`; otherwise the layout row fills the gaps (snippet, draft, time, mute, name-split participants). +- **Message conversion** lives in `constants/chat/message.tsx` (`uiMessageToMessage` converts a single RPC `UIMessage` to the internal `Message` type; `parseUIMessagesJSON` does the same for a JSON-stringified array, used for bulk thread-load ingestion). +- **Orange line** (the "new messages" divider) is a small standalone store, `chat/conversation/orange-line-context.tsx` (`useExplicitOrangeLineState`), keyed by conversationIDKey -> `{ordinal, version}`. + +## How data flows in + +Engine notifications land in `shared/constants/init/shared.tsx`'s `_onEngineIncoming`, which calls `handleConvoEngineIncoming` (`chat/inbox/engine.tsx`) directly for chat-relevant action types. That function is the inbox-side router: it turns RPC notifications (`ChatConvUpdate`, `NewChatActivity`, `ChatTypingUpdate`, `ChatParticipantsInfo`, `ChatThreadsStale`, etc.) into calls against the metadata store (`metasReceived`, `metaReceivedError`, `updateInboxConversationMeta`), the typing store (`updateInboxTyping`), or an unbox request (`unboxRows`/`forceUnboxRowsForService`). Thread-specific engine events (message updates/mutations, reactions, attachments) are instead handled by `thread-engine.tsx`'s listeners, wired up per-conversation inside `thread-context.tsx` (`useThreadEngineListeners`) so they only run while that conversation's provider is mounted. Thread loads (initial, scrollback, centered, jump-to-recent) go through `loadMoreMessages` -> `loadConversationThreadMessages` in `thread-load.tsx`, which issues the RPC and calls back into the thread store's `applyThreadLoad`. + +## Lifecycle + +The thread store and its sibling `ShownUsernameCacheContext` are created in `ConversationThreadProviderInner` and torn down by unmounting; the screen mounts a fresh provider (via a React `key` on the conversationIDKey) when you switch conversations, so there's no manual "clear old thread" step — the old store and its listeners just go away. `ConversationThreadProvider` special-cases the case where the requested id matches the currently-provided one, reusing the existing store/actions instead of remounting (e.g. nested same-thread wrappers). On logout, `Z.resetAllStores()` (`util/zustand.tsx`) resets every store created via `Z.createZustand` — inbox metadata, badge, typing, layout, orange-line, etc. — back to its initial state; it's invoked from `stores/config.tsx` when `loggedIn` flips to false. -We get a list of untrusted conversations from the server. Untrusted (unboxed) means we don't have any snippets and can't verify the participants / channel name. If we've previously loaded them the daemon can give us a trusted payload with the untrusted payload -We request untrusted conversations to be unboxed (converted to trusted). This is driven by the inbox scrolling rows into view. -The primary ID of a conversation is a ConversationIDKey. Our data structures are mostly maps driven off of this key +## Intentional dualities + +A few pieces of state are deliberately duplicated rather than unified, because they represent different things: + +- **Typing**: the thread store's `typing` is a live `Set` of who's typing right now in the open conversation; the inbox's `typingSnippet` (computed in `rows-state.tsx`) is a display string ("X is typing...") for inbox rows, sourced from the separate `typing-state.tsx` map so an unopened conversation's row can still show it. +- **Draft**: the composer's unsent text (`unsentText` in `chat/conversation/input-area/input-state.tsx`) is local, per-conversation UI state scoped to the input's own React context/reducer, cleared by unmount. `meta.draft` (in the inbox metadata store) is the last draft synced to the service, used to render the draft snippet on inbox rows for conversations you aren't currently looking at. They're independent by design — the input doesn't read from or write to `meta.draft` on every keystroke. + +## Inbox + +Conversations are of 2 basic types. - Small: adhoc conversations or teams with only the #general channel - Big: teams with multiple channels -badgeMap: id to the badge number -messageMap: id to message id to message -messageOrdinals: id to list of ordinals -metaMap: id to metadata -unreadMap: id to unread count -etc +We get a list of untrusted conversations from the server. Untrusted (unboxed) means we don't have any snippets and can't verify the participants / channel name. If we've previously loaded them the daemon can give us a trusted payload with the untrusted payload. +We request untrusted conversations to be unboxed (converted to trusted). This is driven by the inbox scrolling rows into view, via a queue (`queueMetaToRequest`) that unboxes in small batches rather than all at once. +The primary ID of a conversation is a ConversationIDKey. Data structures are mostly maps driven off of this key, split by store as described above (meta/participants, thread messages, badges, layout, typing). The inbox operates in 2 modes: 'normal' and 'filtered'. Filtered is driven by a filter string. Each item calculates a score and is sorted by this score (exact match > prefix match > substring match). We show small items, then big items. No dividers or hierarchy of channel/team. @@ -24,7 +42,7 @@ The normal mode is split into 2 sections. If you have a mix of small/big teams we can show a divider between them and truncate the small list. -The inbox is entirely derived from the metaMap +Row display data is derived at render time from inbox metadata plus the layout/badge/typing stores (see above), not read out of a single cached map. Edge cases: @@ -45,4 +63,4 @@ We keep the original ordinal if we can so the ordering of the thread from our pe ## Pending -When we build a search for users we want to preview the conversation. We have a special conversationIDKey for this Constants.pendingConversationIDKey. This always exists in the metaMap. The users go into the participants property. Usually the convesationIDKey inside the meta is the same as the key in the metaMap but in this special instance the key of the preview conversation goes in there depending on the participants +When we build a search for users we want to preview the conversation. We have a special conversationIDKey for this Constants.pendingConversationIDKey. This always exists in the inbox metadata store. The users go into the participants property. Usually the convesationIDKey inside the meta is the same as the key in the metadata store but in this special instance the key of the preview conversation goes in there depending on the participants From 772874b313fa6a0378b20b9d48e30a2c1eabcafa Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 3 Jul 2026 12:59:48 -0400 Subject: [PATCH 16/18] perf(chat): reload-free participants read in message rows useMessageData ran per mounted message row and pulled participants via useConversationParticipants, which registers ~6 engine action listeners and fires an unboxRows mount effect per row. Read directly from useInboxMetadataState instead; singleton call sites (special-top-message, bottom-banner, reset-user, popups) keep the reload-driving hook. --- shared/chat/conversation/data-hooks.tsx | 2 +- shared/chat/conversation/messages/wrapper/wrapper.tsx | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/shared/chat/conversation/data-hooks.tsx b/shared/chat/conversation/data-hooks.tsx index 3126aead5495..e9ca175aefbc 100644 --- a/shared/chat/conversation/data-hooks.tsx +++ b/shared/chat/conversation/data-hooks.tsx @@ -15,7 +15,7 @@ import {loadThreadNonblock, markConversationRead} from './thread-rpc' import {setConversationOrangeLine} from './orange-line-context' const emptyConversationMeta = Meta.makeConversationMeta() -const emptyParticipantInfo: T.Chat.ParticipantInfo = { +export const emptyParticipantInfo: T.Chat.ParticipantInfo = { all: [], contactName: new Map(), name: [], diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index 118763fcb6f1..d53766d24da6 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -29,7 +29,8 @@ import { useConversationThreadSelector, useThreadMeta, } from '../../thread-context' -import {useConversationParticipants} from '../../data-hooks' +import {emptyParticipantInfo} from '../../data-hooks' +import {useInboxMetadataState} from '@/chat/inbox/metadata' import type {ConversationInputState} from '../../input-area/input-state' import {useChatTeamMemberRole} from '../../team-hooks' @@ -376,7 +377,9 @@ export const useMessageData = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: bo const messageActions = useConversationThreadMessageActions() const shownCache = React.useContext(ShownUsernameCacheContext) const conversationIDKey = useConversationThreadID() - const participantInfo = useConversationParticipants(conversationIDKey) + // Reload-free read: avoid useConversationParticipants' per-mount unboxRows + engine + // listener registration, which is too expensive to pay per message row. + const participantInfo = useInboxMetadataState(s => s.participants.get(conversationIDKey)) ?? emptyParticipantInfo const authorMeta = useThreadMeta( C.useShallow(m => ({ botAliases: m.botAliases, From 6bcd651c7cebdb391bd00ceb0cd7059fbf9c8191 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 3 Jul 2026 15:41:43 -0400 Subject: [PATCH 17/18] chore(chat): post-simplify cleanup (badge passthrough, exploding dup, shared thread helpers, typing map growth) --- shared/chat/conversation/data-hooks.tsx | 25 +-------------- shared/chat/conversation/thread-context.tsx | 8 ++--- shared/chat/conversation/thread-engine.tsx | 13 ++++---- shared/chat/conversation/thread-load.tsx | 6 ++-- shared/chat/inbox/engine.test.tsx | 35 +-------------------- shared/chat/inbox/metadata.tsx | 5 --- shared/chat/inbox/typing-state.tsx | 9 +++++- shared/constants/init/shared.tsx | 4 +-- 8 files changed, 23 insertions(+), 82 deletions(-) diff --git a/shared/chat/conversation/data-hooks.tsx b/shared/chat/conversation/data-hooks.tsx index e9ca175aefbc..6f99f34061cc 100644 --- a/shared/chat/conversation/data-hooks.tsx +++ b/shared/chat/conversation/data-hooks.tsx @@ -1,5 +1,4 @@ import * as C from '@/constants' -import * as Common from '@/constants/chat/common' import * as Message from '@/constants/chat/message' import * as Meta from '@/constants/chat/meta' import * as React from 'react' @@ -9,10 +8,10 @@ import {useEngineActionListener} from '@/engine/action-listener' import {ignorePromise} from '@/constants/utils' import {useCurrentUserState} from '@/stores/current-user' import {useConfigState} from '@/stores/config' -import {uint8ArrayToString} from '@/util/uint8array' import logger from '@/logger' import {loadThreadNonblock, markConversationRead} from './thread-rpc' import {setConversationOrangeLine} from './orange-line-context' +import {getExplodingModeFromGregorItems} from './thread-load' const emptyConversationMeta = Meta.makeConversationMeta() export const emptyParticipantInfo: T.Chat.ParticipantInfo = { @@ -129,28 +128,6 @@ export const useConversationMeta = (conversationIDKey: T.Chat.ConversationIDKey) export const useConversationParticipants = (conversationIDKey: T.Chat.ConversationIDKey) => useConversationMetadata(conversationIDKey).participants -const getExplodingModeFromGregorItems = ( - conversationIDKey: T.Chat.ConversationIDKey, - items: ReadonlyArray<{item: T.RPCGen.Gregor1.Item}> -) => { - const explodingItems = items.filter(i => i.item.category.startsWith(Common.explodingModeGregorKeyPrefix)) - if (!explodingItems.length) { - return 0 - } - const category = `${Common.explodingModeGregorKeyPrefix}${conversationIDKey}` - const item = explodingItems.find(i => i.item.category === category) - if (!item) { - return undefined - } - const secondsString = uint8ArrayToString(item.item.body) - const seconds = parseInt(secondsString, 10) - if (isNaN(seconds)) { - logger.warn(`Got dirty exploding mode ${secondsString} for category ${category}`) - return undefined - } - return seconds -} - export const useConversationExplodingMode = (conversationIDKey: T.Chat.ConversationIDKey) => useConfigState(state => getExplodingModeFromGregorItems(conversationIDKey, state.gregorPushState) ?? 0) diff --git a/shared/chat/conversation/thread-context.tsx b/shared/chat/conversation/thread-context.tsx index d4bfb3c74472..ab58d3d94909 100644 --- a/shared/chat/conversation/thread-context.tsx +++ b/shared/chat/conversation/thread-context.tsx @@ -56,8 +56,10 @@ import { } from './message-rpc' import {cancelActiveThreadSearchRPC} from '../search-rpc' import { + emptyConversationMeta, getClientPrevFromSnapshot, getExplodingModeFromConfig, + getMeta, loadConversationThreadMessages, numMessagesOnInitialLoad, numMessagesOnScrollback, @@ -89,12 +91,6 @@ const formatTextForQuoting = (text: string) => .map(line => `> ${line}\n`) .join('') -// The inbox metadata store is the single owner of conversation meta; fall back to -// an empty meta for reads that predate an unbox. -const emptyConversationMeta = Meta.makeConversationMeta() -const getMeta = (id: T.Chat.ConversationIDKey) => - getInboxConversationMeta(id) ?? emptyConversationMeta - const ConversationThreadIDContext = React.createContext(undefined) ConversationThreadIDContext.displayName = 'ConversationThreadIDContext' diff --git a/shared/chat/conversation/thread-engine.tsx b/shared/chat/conversation/thread-engine.tsx index 508136364ae9..d8fed1eaf88f 100644 --- a/shared/chat/conversation/thread-engine.tsx +++ b/shared/chat/conversation/thread-engine.tsx @@ -3,16 +3,15 @@ import * as Message from '@/constants/chat/message' import * as T from '@/constants/types' import logger from '@/logger' import {useConfigState} from '@/stores/config' -import {useCurrentUserState} from '@/stores/current-user' import {useEngineActionListener} from '@/engine/action-listener' -import {getExplodingModeFromGregorItems, getLastOrdinalFromSnapshot, getOrdinalForMessageIDInSnapshot} from './thread-load' +import { + getCurrentUser, + getExplodingModeFromGregorItems, + getLastOrdinalFromSnapshot, + getOrdinalForMessageIDInSnapshot, +} from './thread-load' import type {ConversationThreadActions} from './thread-context' -const getCurrentUser = () => { - const s = useCurrentUserState.getState() - return {devicename: s.deviceName, username: s.username} -} - export const applyMessagesUpdatedToThread = ( conversationIDKey: T.Chat.ConversationIDKey, messagesUpdated: T.RPCChat.MessagesUpdated, diff --git a/shared/chat/conversation/thread-load.tsx b/shared/chat/conversation/thread-load.tsx index 6a7299a12b59..de4121253cad 100644 --- a/shared/chat/conversation/thread-load.tsx +++ b/shared/chat/conversation/thread-load.tsx @@ -33,10 +33,10 @@ const ignoreErrors = [ // The inbox metadata store is the single owner of conversation meta; fall back to // an empty meta for reads that predate an unbox. -const emptyConversationMeta = Meta.makeConversationMeta() -const getMeta = (id: T.Chat.ConversationIDKey) => getInboxConversationMeta(id) ?? emptyConversationMeta +export const emptyConversationMeta = Meta.makeConversationMeta() +export const getMeta = (id: T.Chat.ConversationIDKey) => getInboxConversationMeta(id) ?? emptyConversationMeta -const getCurrentUser = () => { +export const getCurrentUser = () => { const s = useCurrentUserState.getState() return {devicename: s.deviceName, username: s.username} } diff --git a/shared/chat/inbox/engine.test.tsx b/shared/chat/inbox/engine.test.tsx index aa83360138ce..60ed618248fc 100644 --- a/shared/chat/inbox/engine.test.tsx +++ b/shared/chat/inbox/engine.test.tsx @@ -2,9 +2,8 @@ import * as T from '@/constants/types' import {resetAllStores} from '@/util/zustand' import {handleConvoEngineIncoming} from './engine' -import {getInboxConversationMeta, getInboxConversationParticipants, syncBadgeState} from './metadata' +import {getInboxConversationMeta, getInboxConversationParticipants} from './metadata' import {useConfigState} from '@/stores/config' -import {syncInboxBadgeState} from '@/chat/inbox/badge-state' import {updateInboxTyping} from '@/chat/inbox/typing-state' jest.mock('@/chat/inbox/badge-state', () => ({ @@ -410,35 +409,3 @@ test('global inbox failure routing stores error metadata and rekey participants' expect([...(meta?.rekeyers ?? [])]).toEqual(['bob']) expect(getInboxConversationParticipants(convID)?.name).toEqual(['alice', 'bob', 'charlie']) }) - -test('syncBadgeState delegates badge ownership to inbox rows', () => { - const badgeState = { - bigTeamBadgeCount: 0, - conversations: [ - { - badgeCount: 1, - convID: T.Chat.keyToConversationID(convID), - 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 T.RPCGen.BadgeState - - syncBadgeState(badgeState) - - expect(syncInboxBadgeState).toHaveBeenCalledWith(badgeState) -}) diff --git a/shared/chat/inbox/metadata.tsx b/shared/chat/inbox/metadata.tsx index 271ae717f6b6..95b1385fa249 100644 --- a/shared/chat/inbox/metadata.tsx +++ b/shared/chat/inbox/metadata.tsx @@ -16,7 +16,6 @@ import * as Z from '@/util/zustand' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useUsersState} from '@/stores/users' -import {syncInboxBadgeState} from '@/chat/inbox/badge-state' type InboxMetadataState = T.Immutable<{ metas: Map @@ -601,7 +600,3 @@ export const onChatInboxSynced = async ( await refreshInbox('inboxSyncedUnknown') } } - -export const syncBadgeState = (badgeState?: T.RPCGen.BadgeState) => { - syncInboxBadgeState(badgeState) -} diff --git a/shared/chat/inbox/typing-state.tsx b/shared/chat/inbox/typing-state.tsx index 1a7db1398be9..1d9b2cfb2c2e 100644 --- a/shared/chat/inbox/typing-state.tsx +++ b/shared/chat/inbox/typing-state.tsx @@ -22,7 +22,14 @@ export const updateInboxTyping = (updates?: ReadonlyArray { updates.forEach(update => { const id = T.Chat.conversationIDToKey(update.convID) - s.typing.set(id, new Set(update.typers?.map(typer => typer.username))) + const typers = new Set(update.typers?.map(typer => typer.username)) + // Absent key already means nobody typing; delete rather than store an + // empty Set so the map doesn't grow unbounded as conversations go quiet. + if (typers.size) { + s.typing.set(id, typers) + } else { + s.typing.delete(id) + } }) }) } diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index c946979249da..74ffd817df41 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -44,8 +44,8 @@ import { onGetInboxUnverifiedConvs, onInboxLayoutChanged, onIncomingInboxUIItem, - syncBadgeState, } from '@/chat/inbox/metadata' +import {syncInboxBadgeState} from '@/chat/inbox/badge-state' import {clearSignupEmail} from '@/people/signup-email' import {clearSignupDeviceNameDraft} from '@/signup/device-name-draft' import {clearNavBadges} from '@/teams/actions' @@ -346,7 +346,7 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { case 'keybase.1.NotifyBadges.badgeState': { const {badgeState} = action.payload.params - syncBadgeState(badgeState) + syncInboxBadgeState(badgeState) const {useNotifState} = require('@/stores/notifications') as typeof UseNotificationsStateType useNotifState.getState().dispatch.onEngineIncomingImpl(action) } From 38edaa33382e0e02c10279d21471381001e85f6c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 3 Jul 2026 15:44:17 -0400 Subject: [PATCH 18/18] chore: untrack plan doc --- plans/2026-07-03-chat-data-simplify.md | 372 ------------------------- 1 file changed, 372 deletions(-) delete mode 100644 plans/2026-07-03-chat-data-simplify.md diff --git a/plans/2026-07-03-chat-data-simplify.md b/plans/2026-07-03-chat-data-simplify.md deleted file mode 100644 index f0139386791a..000000000000 --- a/plans/2026-07-03-chat-data-simplify.md +++ /dev/null @@ -1,372 +0,0 @@ -# Chat Data Layer Simplification Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Collapse duplicated chat data ownership on the JS side — one owner for conversation meta, no hand-maintained inbox row cache, a decomposed thread-context — plus a set of small state cleanups. - -**Architecture:** Conversation meta/participants get a single owner (`useInboxMetadataState`); the per-conversation thread store stops holding copies and stops writing back. The `rows-state` materialized view is replaced by selector hooks computed from meta + a new tiny badge store + layout + typing. `thread-context.tsx` is split into store/actions, engine listeners, and load logic. Converters and parse paths are deduplicated. Each task is one commit on a fresh branch. - -**Tech Stack:** TypeScript, React, zustand (global via `Z.createZustand`, per-conversation via vanilla `createStore`), immer, jest. - -## Global Constraints - -- Repo root is `client/`; TS source in `shared/`. All Bash runs `cd /Users/chrisnojima/go/src/github.com/keybase/client/shared` first. File ops use absolute paths. -- After TS changes: `yarn lint` then `yarn tsc` (from `shared/`). Both must be clean before each commit. tsc errors are never "pre-existing" (exception: known `react-native-kb` `install` error in client2 — not in this tree's tsc run). -- Never use `npm`. Never touch Electron app or iOS simulator. User verifies visuals. -- No `Co-Authored-By` in commits. -- No DOM elements in plain `.tsx`; use `Kb.*`. -- Remove unused code (imports, vars, params, dead helpers) in every file touched. -- Comments only for non-obvious constraints; no refactoring-history comments. -- In tests use `testuser` / `testuser-mac` usernames. -- Do NOT push. Branch stays local until the user verifies the app. -- Behavior-preserving refactor: no feature/behavior drops. If a task forces a behavior question, STOP and surface options instead of deciding silently. -- `git diff` output is rtk-filtered to stats in this environment; to inspect content use `git show` / read files directly. -- Existing tests that must keep passing: `yarn jest stores chat` (covers `shared/stores/tests/*` and `shared/chat/**/*.test.*`). Run per task; full `yarn jest` before final handoff. - ---- - -### Task 0: Branch setup - -**Files:** none (git only) - -- [ ] **Step 1: Create branch** - -```bash -cd /Users/chrisnojima/go/src/github.com/keybase/client -git checkout -b nojima/chat-data-simplify -``` - -- [ ] **Step 2: Baseline validation** — record a clean starting point. - -```bash -cd shared && yarn lint && yarn tsc && yarn jest stores chat -``` - -Expected: all pass. If anything fails at baseline, STOP and report (do not fix pre-existing failures inside this plan). - ---- - -### Task 1: Unify InboxUIItem→ConversationMeta converters - -**Files:** -- Modify: `shared/constants/chat/meta.tsx` -- Test: `shared/stores/tests/chat.test.ts` (extend) or new `shared/constants/chat/meta.test.tsx` if converters aren't covered there. - -**Interfaces:** -- Produces: internal helper `baseMetaFromUIItem` in `meta.tsx`. Public exports (`inboxUIItemToConversationMeta`, `unverifiedInboxUIItemToConversationMeta`, `inboxUIItemErrorToConversationMetaAndParticipants`, `makeConversationMeta`, `updateMeta`, `parseNotificationSettings`, `getEffectiveRetentionPolicy`, `getRowParticipants`, `getTeams`, `getTeamType` if exported) unchanged in name and signature. - -`inboxUIItemToConversationMeta` (meta.tsx:276) and `unverifiedInboxUIItemToConversationMeta` (meta.tsx:27) share ~20 field mappings. Extract the shared subset. `InboxUIItem` and `UnverifiedInboxUIItem` share: `convID`, `name`, `status`, `membersType`, `memberStatus`, `visibility`, `time`, `version`, `localVersion`, `maxMsgID`, `maxVisibleMsgID`, `readMsgID`, `convRetention`, `teamRetention`, `notifications`, `supersededBy`, `supersedes`, `finalizeInfo`, `draft`, `commands`, `teamType`, `tlfID`. Verify the exact shared shape against `T.RPCChat` types before writing the helper — if a listed field is missing on one type, move it back to the specific converter. - -- [ ] **Step 1: Write characterization tests first** (before touching converters). Build a representative `InboxUIItem` and `UnverifiedInboxUIItem` fixture (team + adhoc + muted + retention-set variants), snapshot/assert the produced metas field-by-field for the important fields (`trustedState`, `snippet`, `channelname`, `teamname`, `resetParticipants`, `retentionPolicy`, `notificationsDesktop`, `supersededBy`). Run: `yarn jest ` — expect PASS against current code. - -```ts -// shape of the test (fill fixtures from T.RPCChat types): -describe('meta converters', () => { - it('trusted item maps fields', () => { - const meta = inboxUIItemToConversationMeta(trustedFixture) - expect(meta?.trustedState).toBe('trusted') - // ... field asserts - }) - it('unverified item maps fields', () => { - const meta = unverifiedInboxUIItemToConversationMeta(unverifiedFixture) - expect(meta?.trustedState).toBe('untrusted') - // ... field asserts; assert fields the unverified path must NOT set (botAliases etc. stay defaults) - }) -}) -``` - -- [ ] **Step 2: Extract `baseMetaFromUIItem`** covering the shared mappings (visibility guard, resetParticipants impteam logic, supersede decode, retention, notifications, membershipType, ids/versions/timestamps, draft, status, tlfname, wasFinalizedBy, teamType via `getTeamType`). Each public converter becomes: guard clauses specific to it + `{...makeConversationMeta(), ...baseMetaFromUIItem(i, isTeam), ...specific fields}`. -- [ ] **Step 3: Run tests** — `yarn jest ` PASS unchanged. -- [ ] **Step 4: Validate + commit** - -```bash -yarn lint && yarn tsc && yarn jest stores chat -git add -A && git commit -m "refactor(chat): dedupe InboxUIItem meta converters via shared base helper" -``` - ---- - -### Task 2: Shared UIMessages parse helper - -**Files:** -- Modify: `shared/constants/chat/message.tsx` (add `parseUIMessagesJSON`), `shared/chat/conversation/data-hooks.tsx:160-189`, `shared/chat/conversation/thread-context.tsx:781-796` - -**Interfaces:** -- Produces: `export const parseUIMessagesJSON = (conversationIDKey: T.Chat.ConversationIDKey, threadJSON: string, username: string, devicename: string, getLastOrdinal: () => T.Chat.Ordinal) => Array` in `constants/chat/message.tsx`. JSON.parse + per-message `uiMessageToMessage`, dropping nulls. Errors: catch, `logger.warn`, return `[]` (matching data-hooks behavior; thread-context currently doesn't catch — keep thread-context's call NOT swallowing? No: unify on catch+warn+[] and verify thread-context call sites tolerate empty array — they do, `applyThreadLoad` with 0 messages is a no-op add). -- Consumers keep their own `getLastOrdinal` semantics: thread-context passes its snapshot-based lambda; data-hooks passes its running-max lambda. - -- [ ] **Step 1: Add helper to `message.tsx`** (near `uiMessageToMessage`). -- [ ] **Step 2: Replace `parseThreadMessages` body in data-hooks** with a call to the helper (keep the running-max `getLastOrdinal` wrapper local). -- [ ] **Step 3: Replace the inline parse block in `loadConversationThreadMessages`** (`thread-context.tsx:782-796`) with the helper. -- [ ] **Step 4: Validate + commit** (same commands). Commit: `refactor(chat): shared UIMessages JSON parse helper` - ---- - -### Task 3: Meta single-ownership — remove participants copy from thread store - -Smallest slice of the ownership change first: participants. - -**Files:** -- Modify: `shared/chat/conversation/thread-context.tsx` -- Modify: any component reading `participants` from the thread store (grep below) - -**Interfaces:** -- Consumes: `useInboxMetadataState`, `getInboxConversationParticipants`, `participantInfoReceived` from `@/chat/inbox/metadata`; `useConversationParticipants` from `./data-hooks`. -- Produces: `ConversationThreadState` no longer has `participants`; `ConversationThreadActions` no longer has `setParticipants`. - -Current wiring to remove: store field (`thread-context.tsx:206,241,258-260`), mirror effect (`:1629-1637`), `setParticipants` action (`:1164-1169`), `ChatParticipantsInfo` listener (`:1813-1818` — global coverage already exists: `chat/inbox/engine.tsx` routes `ChatParticipantsInfo` → `syncInboxParticipantsFromParticipantMap`, which writes the inbox store the mirror effect was reading from), `chatInboxFailed` participants branch (`:1762-1764` — replace with direct `participantInfoReceived(id, participants, meta)`). - -- [ ] **Step 1: Find all readers** - -```bash -rg -n "s\.participants|snapshot\.participants|state\.participants" chat/ --glob '*.tsx' | rg -v inbox/ -rg -n "useConversationThreadSelector\(" chat/ -A2 | rg -i participants -``` - -- [ ] **Step 2: Repoint readers** to `useConversationParticipants(conversationIDKey)` (data-hooks) or, in non-hook code, `getInboxConversationParticipants(id) ?? emptyParticipantInfo`. `useConversationThreadSelectedConversation` (`thread-context.tsx:2057-2083`) reads `s.participants` — switch it to `getInboxConversationParticipants(conversationIDKey)` with an empty-info fallback. -- [ ] **Step 3: Delete** store field, `makeEmptyParticipantInfo` usage in state (keep the helper if still used elsewhere), initial seeding, mirror effect, `setParticipants` action + type, `ChatParticipantsInfo` listener. In the `chatInboxFailed` listener, replace `threadActions.setParticipants(participants)` with `participantInfoReceived(id, participants, meta)`. -- [ ] **Step 4: Verify no global-coverage regression.** Confirm `chat/inbox/engine.tsx` handles `ChatParticipantsInfo` unconditionally (not only for inbox-visible convs) — read `handleConvoEngineIncoming` and the wiring in `constants/init/shared.tsx:334-460`. If coverage is conditional, keep an equivalent write via `participantInfoReceived` where the listener was. Document finding in the commit message. -- [ ] **Step 5: Validate + commit.** `refactor(chat): thread store reads participants from inbox metadata store` - ---- - -### Task 4: Meta single-ownership — remove meta copy from thread store - -The core ownership change. The thread store's `meta` field, its bi-directional sync (`setMeta`/`updateMeta` → `metasReceived`), and every thread-context listener that exists only to refresh the local meta copy all go away. Reads come from `useInboxMetadataState`; writes flow one way: RPC/engine → `metasReceived` → inbox store → (subscribers). - -**Files:** -- Modify: `shared/chat/conversation/thread-context.tsx` (major) -- Modify: components reading `s.meta` from the thread store (grep) -- Test: existing `chat/conversation/*.test.tsx` must keep passing. - -**Interfaces:** -- Produces: `ConversationThreadState` without `meta`; `ConversationThreadActions` without `setMeta`/`updateMeta`. New module-local helper in thread-context: `const getMeta = (id: T.Chat.ConversationIDKey) => getInboxConversationMeta(id) ?? Meta.makeConversationMeta()`. -- Writes that used `setMeta`/`updateMeta` now call `metasReceived([...])` / `updateInboxConversationMeta(id, partial)` from `@/chat/inbox/metadata` directly. - -Internal read sites to repoint (all become `getMeta(id)`): -- `loadConversationThreadMessages` membershipType/rekeyers bail (`:745-749`), offline write (`:862-864` → `updateInboxConversationMeta(conversationIDKey, {offline: results.offline})`) -- `markThreadAsRead` readMsgID noop check (`:996-998`) -- `applyThreadLoad` `maxVisibleMsgID` containsLatest calc (`:1069`) -- `setExplodingMode` retention lookup (`:1144`) -- `messageDelete` tlfname + meta-presence check (`:1237-1257`) -- `toggleMessageReaction` tlfname (`:1393`) -- `unfurlRemove` tlfname + presence check (`:1406-1415`) -- `setMarkAsUnread` maxVisibleMsgID (`:1180`) -- Note: presence checks like `snapshot.meta.conversationIDKey === id` become `getInboxConversationMeta(id) !== undefined` (or drop where vacuous). - -Listeners to DELETE from thread-context (each only refreshed the local meta copy; global path already writes the inbox store): -- `NewChatActivity` sub-branches `setStatus`, `readMessage`, `newConversation` (`:1650-1679`) and the `applyInboxUIItemToThread` call inside `incomingMessage` (`:1645`) and `failedMessage` (`:1697`) — global: `inbox/engine.tsx` `onNewChatActivity` returns the `inboxUIItem`, `constants/init/shared.tsx` pushes it through `onIncomingInboxUIItem` → `hydrateInboxConversations` → `metasReceived`. -- `ChatConvUpdate` (`:1742-1748`) — global: engine routes to `metasReceived`. -- `ChatSetConvRetention` (`:1782-1797`), `ChatSetTeamRetention` (`:1798-1812`), `ChatSetConvSettings` (`:1766-1781`), `setAppNotificationSettings` branch (`:1680-1686`) — global: engine routes retention/settings to `metasReceived`/`updateInboxConversationMeta`; VERIFY each case exists in `handleConvoEngineIncoming` (`chat/inbox/engine.tsx:189+`) before deleting. `setAppNotificationSettings` in particular: engine's `onNewChatActivity` must handle it or return its conv; if not covered, replace the thread-context listener with a direct `updateInboxConversationMeta(id, Meta.parseNotificationSettings(...))` call instead of deleting. -- `chatInboxFailed` (`:1749-1765`) — global: engine routes `chatInboxFailed` → `metaReceivedError` which builds the same error meta + participants. Delete the thread-context copy entirely after confirming `metaReceivedError` covers rekey participants (it calls `participantInfoReceived`). - -Helper functions that die with this: `applyConversationMetaToThread`, `applyInboxUIItemToThread` (`:688-710`). `Meta.updateMeta` version-gating remains used by the inbox store path (`metasReceived` consumers) — verify `metasReceived` applies version gating; TODAY it does NOT (it overwrites). The thread store previously gated via `applyConversationMetaToThread`. To preserve behavior (no stale-version overwrite thrash), add gating into `metasReceived`: - -```ts -// metadata.tsx metasReceived body change: -metas.forEach(m => { - const old = s.metas.get(m.conversationIDKey) - const next = old ? Meta.updateMeta(old, m) : m - s.metas.set(m.conversationIDKey, T.castDraft(next)) -}) -``` - -CAREFUL: some callers intentionally overwrite (error metas from `metaReceivedError` have same version but `trustedState:'error'`). `updateMeta` keeps old on equal version unless untrusted→trusted or localVersion bump — that would SWALLOW error metas and the `unverifiedInboxUIItemToConversationMeta`-based incremental sync. Resolution: add an options param `metasReceived(metas, removals?, {force?: boolean})`; pass `force: true` from `metaReceivedError`, `onChatInboxSynced` incremental, `clearConversationsForInboxSync` path, and `updateInboxConversationMeta` (which merges from current already); default (unbox results, NewChatActivity hydration, thread-store-removal call sites) goes through gating. Write a unit test for both behaviors in `chat/inbox/metadata.test.tsx`. - -Components reading thread-store meta: - -- [ ] **Step 1: Enumerate readers** - -```bash -rg -n "useConversationThreadSelector\(s => s\.meta|snapshot\.meta|\.getState\(\)\.meta" chat/ --glob '*.tsx' -rg -n "s\.meta\b" chat/conversation --glob '*.tsx' -``` - -Repoint component readers to `useConversationMeta(id)` (data-hooks; id from `useConversationThreadID()`). For selectors that picked single fields (e.g. `s.meta.teamname`), use `useInboxMetadataState(s => s.metas.get(id)?.teamname ?? '')` or `useConversationMeta` + field access — prefer the narrow selector where the component is render-hot (message rows). - -- [ ] **Step 2: Add version gating + force flag to `metasReceived`** with tests (as specified above). Commit separately if it stands alone: `fix(chat): version-gate metasReceived like thread-store path did`. -- [ ] **Step 3: Remove `meta` from `ConversationThreadState`**, seeding in `makeInitialThreadState`, `setMeta`/`updateMeta` actions, repoint all internal reads per the list above, delete dead helpers/listeners per the list above (with the per-listener global-coverage verification described — record each verification in the commit message body). -- [ ] **Step 4: Validate + commit.** `refactor(chat): single-owner conversation meta in inbox metadata store` - -Run `yarn jest chat` and fix fallout in `chat/conversation/normal/container.test.tsx`, `chat/inbox/metadata.test.tsx`, `chat/inbox/engine.test.tsx` by updating them to the new ownership (tests asserting thread-store meta mirroring get deleted; tests asserting inbox-store writes stay). - ---- - -### Task 5: Badge store + retire rows-state (staged) - -Replace the hand-maintained `rows-state` materialized view with selector hooks over: metadata store + new badge store + layout store + a typing map. Five sync entry points and two divergent projections disappear. - -**Files:** -- Create: `shared/chat/inbox/badge-state.tsx` -- Modify: `shared/chat/inbox/layout-state.tsx` (add per-conv row index selectors) -- Modify: `shared/chat/inbox/rows-state.tsx` → shrinks to row view hooks, then everything else deleted -- Modify: `shared/chat/inbox/metadata.tsx` (drop `syncInboxRows*` fan-out calls; trusted-state handling) -- Modify: `shared/chat/inbox/engine.tsx` (typing + badge routing), `shared/constants/init/shared.tsx` (badge routing) -- Modify consumers: `chat/inbox/row/small-team/index.tsx`, `chat/inbox/row/big-team-channel.tsx`, `chat/selectable-small-team.tsx`, `chat/selectable-big-team-channel.tsx`, `chat/inbox/use-inbox-state.tsx:22-58`, `chat/inbox/row/teams-divider-container.tsx:25` -- Test: `shared/chat/inbox/rows-state.test.ts` → replaced by `badge-state.test.ts` + row-hook tests; `metadata.test.tsx` updated. - -**Interfaces:** -- Produces `badge-state.tsx`: - -```ts -type BadgeCounts = {badgeCount: number; unreadCount: number} -export const useInboxBadgeState: Z store {counts: Map} -export const syncInboxBadgeState = (badgeState?: T.RPCGen.BadgeState) => void // full-replace semantics: convs absent from payload get no entry (map rebuilt each sync) -export const getInboxBadge = (id: T.Chat.ConversationIDKey): BadgeCounts // {0,0} default -``` - -- Produces typing map (goes into badge-state.tsx or its own 30-line `typing-state.tsx`): `useInboxTypingState {typing: Map>}` + `updateInboxTyping(updates)`. `buildTypingSnippet` moves next to its consumer hook. -- Produces in `rows-state.tsx` (file renamed responsibility, keep path to limit churn): `useInboxRowSmall(id): InboxRowSmall` and `useInboxRowBig(id): InboxRowBig` with the SAME return shapes as today (so row components change minimally), but computed via `useShallow` selectors: - -```ts -export const useInboxRowSmall = (id: string): InboxRowSmall => { - const you = useCurrentUserState(s => s.username) - const meta = useInboxMetadataState(s => s.metas.get(id)) - const participantInfo = useInboxMetadataState(s => s.participants.get(id)) - const layoutRow = useInboxLayoutState(s => getSmallLayoutRow(s, id)) // memoized index - const counts = useInboxBadgeState(s => s.counts.get(id)) - const typing = useInboxTypingState(s => s.typing.get(id)) - return React.useMemo(() => computeSmallRow(id, you, meta, participantInfo, layoutRow, counts, typing), - [id, you, meta, participantInfo, layoutRow, counts, typing]) -} -``` - -`computeSmallRow` merges with ONE precedence rule (fixes today's divergent projections): meta wins when `trustedState === 'trusted' || 'error'`; layout row fills gaps otherwise (snippet, draft, time, isMuted, name-split participants). ONE definition of `isDecryptingSnippet = !!id && !snippet && !metaTrusted`. `hasBadge`/`hasUnread` computed from counts — the stale-boolean class disappears. - -- Trusted-state: rows-state's `trustedState`/`setInboxRowTrustedState` copy is replaced by: meta's own `trustedState` + the existing `inFlightUnboxRows` set. In `metadata.tsx`: `trustedStateForConversation(id) = metas.get(id)?.trustedState ?? (inFlightUnboxRows.has(id) ? 'requesting' : 'untrusted')`. `setInboxRowTrustedState` call sites: the 'requesting' marker (`metadata.tsx:444`) is covered by `inFlightUnboxRows`; the untrusted resets (`:79`, `:458`) are covered by removal from `inFlightUnboxRows` (finally block already deletes). The error case (`rows-state.tsx:315`) is covered by the error meta from `metaReceivedError`. `getInboxRowTrustedState` and `hasKnownMeta` fallback (`metadata.tsx:492-497`) become meta-store checks. - -Stages (each its own commit): - -- [ ] **Step 1: badge store.** Create `badge-state.tsx` + tests (badge applied, absent conv zeroed on next sync). Route `keybase.1.NotifyBadges.badgeState` (`constants/init/shared.tsx:346-352` via `syncBadgeState` in metadata.tsx) to it. Repoint aggregate consumers `use-inbox-state.tsx:22-58` and `teams-divider-container.tsx:25` to badge store. Keep rows-state badge sync temporarily (double-write) so rows stay correct. Commit: `feat(chat): dedicated inbox badge store`. -- [ ] **Step 2: typing map + layout index.** Add typing store; route `ChatTypingUpdate` in `engine.tsx` to it (keep rows-state write temporarily). Add memoized per-conv layout row index selectors to `layout-state.tsx` (`getSmallLayoutRow`, `getBigLayoutChannelRow`) — build Maps keyed by convID once per layout change (module-level WeakMap on the layout object or a zustand computed). Commit: `feat(chat): typing store + layout row index`. -- [ ] **Step 3: selector-based row hooks.** Rewrite `useInboxRowSmall`/`useInboxRowBig` as computed selectors (shapes unchanged). Port `rows-state.test.ts` assertions to drive the new hooks via store writes (use `@testing-library/react` renderHook if present in repo tests — check existing patterns in `chat/inbox/metadata.test.tsx` first and mirror them). Delete `applyMetaToRows`, `syncInboxRowsFromLayout`, `syncInboxRowsFrom*`, `syncInboxRowBadgeState`, `updateInboxRowTyping`, `setInboxRowTrustedState`, `getInboxRowTrustedState`, the `rowsBig/rowsSmall` store, and every `syncInboxRows*` call in `metadata.tsx` (`:108,125,129,155,269` etc.). Swap trusted-state logic in `metadata.tsx` per the interface above. Update `hydrateInboxLayout` — it keeps only the missing-snippet queueing. Commit: `refactor(chat): inbox rows computed from stores, delete rows-state cache`. -- [ ] **Step 4: sweep.** `rg -n "rows-state|InboxRow(Big|Small)|syncInboxRow" chat/ constants/` — no stale imports. `yarn jest chat stores`, lint, tsc. Fix `engine.test.tsx`/`metadata.test.tsx` fallout. Commit with step 3 if small. - -Perf note for executor: row hooks run per store update per mounted row; all selector bodies must be cheap map lookups; the merge lives in `useMemo`. Do not create new arrays/objects inside the zustand selector itself (breaks referential equality) — only inside `useMemo`. - ---- - -### Task 6: Split thread-context.tsx - -Pure file reorganization after Tasks 3–4 shrink it. No behavior change, no export renames. - -**Files:** -- Create: `shared/chat/conversation/thread-engine.tsx` — the `apply*ToThread` free functions (`applyMessagesUpdatedToThread`, `applyIncomingMutationToThread`, `applyIncomingMessageToThread`, `applyFailedMessageToThread`, `applyReactionUpdateToThread`, `applyExpungeToThread`, `applyEphemeralPurgeToThread`) + a `useThreadEngineListeners(id: T.Chat.ConversationIDKey, threadActions: ConversationThreadActions): void` hook containing every `useEngineActionListener` currently in `ConversationThreadProviderInner` (thread-context.tsx:1638-1892 minus ones deleted in Task 4). -- Create: `shared/chat/conversation/thread-load.tsx` — `loadConversationThreadMessages`, `scrollDirectionToPagination`, `numMessagesOnInitialLoad`/`numMessagesOnScrollback`, `getClientPrevFromSnapshot`, snapshot helpers (`getLastOrdinalFromSnapshot`, `getOrdinalForMessageIDInSnapshot`), exploding-mode gregor helpers (`getExplodingModeFromGregorItems`, `getExplodingModeFromConfig`, `persistExplodingMode`). -- Modify: `shared/chat/conversation/thread-context.tsx` — keeps: state type, contexts, provider, actions, hooks, `toggleConversationThreadSearch`, `showConversationInfoPanel`. Imports from the two new files. Type exports needed by new files (`ConversationThreadState`, `ConversationThreadActions`, `LoadMoreMessagesParams`, `ThreadLoadStatusOptions`, `ScrollDirection`) get exported from thread-context (some already are). - -- [ ] **Step 1: Move code** (cut/paste, adjust imports, export the types the new modules need). Watch the circular import: thread-load/thread-engine import types from thread-context; thread-context imports functions from them — type-only imports one way (`import type`) keep the cycle harmless, but if lint's import-cycle rule fires, move the shared types into `thread-context-types.ts` instead. -- [ ] **Step 2: Also remove the `threadActionsHolder` indirection** (`:1552-1623`): now that `loadConversationThreadMessages` lives in thread-load.tsx and takes `actions` as a param, build the actions object first with a plain `loadMoreMessages` stub assignment: - -```ts -const [threadActions] = React.useState(() => { - const impl = (p: LoadMoreMessagesParams) => loadConversationThreadMessages(id, p, threadActions) - const throttled = throttle(impl, 500) - const loadMoreMessages: LoadMoreMessages = Object.assign((p: LoadMoreMessagesParams) => { - if (p.centeredMessageID || p.messageIDControl || p.reason === 'jump to recent') { - throttled.cancel() - impl(p) - } else throttled(p) - }, {cancel: () => throttled.cancel()}) - const threadActions: ConversationThreadActions = { /* ...same object... */ } - return threadActions -}) -``` - -(A `const` referenced from a closure created before its initialization is fine at call time — the holder object was equivalent; keep the existing comment about throttle drop semantics.) -- [ ] **Step 3: Validate + commit.** `refactor(chat): split thread-context into engine/load modules` - ---- - -### Task 7: Orange line — keyed store - -**Files:** -- Modify: `shared/chat/conversation/orange-line-context.tsx`, `shared/chat/conversation/normal/container.tsx` (NormalOrangeLineProvider consumption), `shared/chat/conversation/data-hooks.tsx:323` (caller unchanged in signature) - -**Interfaces:** -- `useExplicitOrangeLineState` becomes `{updates: Map}`; `setOrangeLine` writes into the map; module-level `explicitOrangeLineVersion` counter moves inside the store creator closure (still monotonic). `setConversationOrangeLine` signature unchanged. Consumers that did `s.update?.conversationIDKey === id ? s.update : undefined` become `s.updates.get(id)`. - -- [ ] **Step 1: Find consumers** — `rg -n "useExplicitOrangeLineState|OrangeLineContext" chat/` (expect `NormalOrangeLineProvider` in `normal/container.tsx` or nearby). Rewrite store + consumers. -- [ ] **Step 2: Validate + commit.** `refactor(chat): per-conversation orange-line updates map` - ---- - -### Task 8: Merge Focus + Scroll contexts - -**Files:** -- Modify: `shared/chat/conversation/normal/context.tsx`, `shared/chat/conversation/normal/container.tsx` (provider tower), all `FocusContext`/`ScrollContext` consumers. - -**Interfaces:** -- Produces single `ThreadRefsContext` + `ThreadRefsProvider` exposing `{focusInput, setInputRef, scrollUp, scrollDown, scrollToBottom, setScrollRef}` — one context, one `useState`-stable value, two internal refs. Keep file at `normal/context.tsx`. - -- [ ] **Step 1:** `rg -ln "FocusContext|ScrollContext" chat/` → rewrite context file, update provider nesting (two providers become one), mechanical consumer updates (`React.useContext(ThreadRefsContext)`). -- [ ] **Step 2: Validate + commit.** `refactor(chat): merge focus/scroll contexts into ThreadRefsContext` - -Leave `MaxInputAreaContext` and `thread-search-overlay-context` alone: different value types with different update cadences (measured number / reanimated SharedValue); merging couples unrelated re-render paths. - ---- - -### Task 9: use-inbox-state username-guard removal - -**Files:** -- Modify: `shared/chat/inbox/use-inbox-state.tsx`, plus the inbox screen component that calls it (find via `rg -ln "useInboxState|inboxControls" chat/inbox`). - -The `state.username === username ? state.X : default` guards (~lines 82-208) reimplement reset-on-user-switch. Replace with a `key={username}` remount at the inbox screen boundary (component that owns this state), then store plain values in `useState` without embedded username. - -- [ ] **Step 1: Read the file + owner component.** Confirm state is component-local (agent report says yes). Apply `key={username}` where the stateful component is rendered; strip username from the state shape and all guards. -- [ ] **Step 2:** If remount at that boundary would drop other wanted state (scroll position across user switch is fine to lose — user switched accounts), proceed; otherwise keep guards and note why in the commit. -- [ ] **Step 3: Validate + commit.** `refactor(chat): inbox controls reset via username key remount` - ---- - -### Task 10: Emoji picker handoff (investigate-first) - -**Files:** -- Read first: `shared/chat/emoji-picker/use-picker.tsx` consumers (`rg -ln "usePickerState|updatePickerMap|PickKey"`). - -- [ ] **Step 1: Trace the three flows** (`addAlias`, `chatInput`, `reaction`): who opens the picker (route? overlay?), who writes the pick, who consumes + when it's cleared. -- [ ] **Step 2: Decide by precedent.** If the codebase already passes callbacks through navigation params or the picker renders in-tree (overlay/popup, not a separate route), replace the global mailbox with a direct `onPick` callback prop. If the picker is a routed screen and params are serializable-only here, keep the store but make handoff self-cleaning: consumer clears its key on read (`updatePickerMap(key, undefined)` after consumption) and document the mailbox pattern in the file header. -- [ ] **Step 3: Validate + commit.** `refactor(chat): emoji picker pick handoff via callback` (or the self-cleaning variant) - ---- - -### Task 11: Never-reset module state audit - -**Files:** -- Modify: `shared/chat/inbox/header-portal-state.tsx`, `shared/chat/blocking/block-buttons-state.tsx`; read `shared/chat/conversation/messages/wrapper/shared-timers.tsx`. - -- [ ] **Step 1: header-portal.** Verify the components that call `setInboxHeaderPortalNode/Content` clear on unmount (grep call sites). If they do, module `let`s are effectively lifecycle-managed — leave, add a header comment stating the invariant. If content can survive logout, add an explicit `resetInboxHeaderPortal()` and call it from the same place `Z.resetAllStores` is triggered (`stores/config.tsx:560` area) — read that site first and follow its pattern. -- [ ] **Step 2: block-buttons.** Move `loadGeneration` into the store (plain number field); `loadPromise` stays module-level (promises don't belong in immutable state) but rename to make the pairing with `resetState` obvious and keep the existing manual clear. Confirm `resetState` clears both. -- [ ] **Step 3: shared-timers.** Observers detach on unmount; logout unmounts all rows. No change unless a live timer outlasting logout is observable — read and confirm; comment the invariant. -- [ ] **Step 4: Validate + commit.** `chore(chat): lifecycle-audit module-level chat state` - ---- - -### Task 12: readme rewrite + final validation - -**Files:** -- Modify: `shared/chat/readme.md` - -- [ ] **Step 1: Rewrite** to describe the post-refactor architecture: per-conversation thread store (messages/ordinals, lifecycle via key remount), inbox metadata store as single meta/participants owner, layout store, badge store, computed row hooks, engine routing, converter layer, ordinal/pending model (keep the existing ordinal section — it's accurate). Note the intentional dualities: live typing (thread store) vs inbox typing snippet; composer draft (input-state) vs service draft (meta). -- [ ] **Step 2: Full validation** - -```bash -cd shared && yarn lint && yarn tsc && yarn jest -``` - -- [ ] **Step 3: Commit.** `docs(chat): update chat readme to current data architecture` -- [ ] **Step 4: STOP. Do not push.** Hand back to user for app verification (desktop + mobile chat smoke: open inbox, unread badges, open conversation, send/edit/delete/react, switch conversations, thread search jump, logout/login). - ---- - -## Explicitly deferred (from the evaluation, with reasons) - -- `mergeMessage` hand-rolled deep merge + 4-index consolidation + `messageTypeMap` partial denormalization: highest regression risk per unit of benefit; needs its own test-first plan against `thread-message-state.tsx` once the above has settled. -- typing/draft dual representations: intentional after analysis (live view vs snippet view; local composer vs service draft) — documented in readme instead. -- `ShownUsernameCacheContext` render-time mutation: works, isolated, and a fix requires rethinking sticky-header calculation; separate task if it ever misbehaves. - -## Self-review notes - -- Task 4 depends on Task 3 (participants first shrinks the diff). Task 5 depends on Task 4 (trusted-state move assumes meta gating in place). Task 6 depends on Tasks 3–4 (file contents). Tasks 1–2 and 7–11 are independent. -- Type consistency: `ConversationThreadState`/`ConversationThreadActions` field removals in Tasks 3–4 are referenced again in Task 6's file split — Task 6 lists only surviving members. -- Riskiest step: `metasReceived` gating change (Task 4 Step 2) — has dedicated tests + force flag for the overwrite paths.