From c3e665fbe7a3d1b38146b2745e71db8622b5ff45 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Tue, 7 Apr 2026 11:52:09 -0400 Subject: [PATCH 01/55] pull non legends list changes from other branch --- .../conversation/messages/attachment/file.tsx | 4 +- .../messages/attachment/wrapper.tsx | 34 +-- .../conversation/messages/reactions-rows.tsx | 9 +- .../chat/conversation/messages/separator.tsx | 225 +----------------- .../conversation/messages/text/wrapper.tsx | 10 +- .../conversation/messages/wrapper/wrapper.tsx | 213 +++++++++++++++-- shared/common-adapters/avatar/avatar-line.tsx | 16 +- 7 files changed, 249 insertions(+), 262 deletions(-) diff --git a/shared/chat/conversation/messages/attachment/file.tsx b/shared/chat/conversation/messages/attachment/file.tsx index 03de575ab726..6c464026750d 100644 --- a/shared/chat/conversation/messages/attachment/file.tsx +++ b/shared/chat/conversation/messages/attachment/file.tsx @@ -123,6 +123,7 @@ function FileContainer(p: OwnProps) { @@ -182,7 +183,7 @@ function FileContainer(p: OwnProps) { )} {!!progressLabel && ( - + {progressLabel} @@ -241,6 +242,7 @@ const styles = Kb.Styles.styleSheetCreate( }, }), linkStyle: {color: Kb.Styles.globalColors.black_50}, + progressOverlay: {bottom: 0, left: 0, position: 'absolute', right: 0}, progressLabelStyle: { color: Kb.Styles.globalColors.black_50, marginRight: Kb.Styles.globalMargins.tiny, diff --git a/shared/chat/conversation/messages/attachment/wrapper.tsx b/shared/chat/conversation/messages/attachment/wrapper.tsx index 142185e41154..7e884892444d 100644 --- a/shared/chat/conversation/messages/attachment/wrapper.tsx +++ b/shared/chat/conversation/messages/attachment/wrapper.tsx @@ -2,56 +2,56 @@ import type AudioAttachmentType from './audio' import type FileAttachmentType from './file' import type ImageAttachmentType from './image' import type VideoAttachmentType from './video' -import {WrapperMessage, useCommonWithData, useMessageData, type Props} from '../wrapper/wrapper' +import {WrapperMessageView, useCommonWithData, useMessageData, type Props} from '../wrapper/wrapper' export function WrapperAttachmentAudio(p: Props) { - const {ordinal} = p - const messageData = useMessageData(ordinal) + const {ordinal, isCenteredHighlight = false, isLastMessage = false} = p + const messageData = useMessageData(ordinal, isLastMessage, isCenteredHighlight) const common = useCommonWithData(ordinal, messageData) const {default: AudioAttachment} = require('./audio') as {default: typeof AudioAttachmentType} return ( - + - + ) } export function WrapperAttachmentFile(p: Props) { - const {ordinal} = p - const messageData = useMessageData(ordinal) + const {ordinal, isCenteredHighlight = false, isLastMessage = false} = p + const messageData = useMessageData(ordinal, isLastMessage, isCenteredHighlight) const common = useCommonWithData(ordinal, messageData) const {showPopup} = common const {default: FileAttachment} = require('./file') as {default: typeof FileAttachmentType} return ( - + - + ) } export function WrapperAttachmentVideo(p: Props) { - const {ordinal} = p - const messageData = useMessageData(ordinal) + const {ordinal, isCenteredHighlight = false, isLastMessage = false} = p + const messageData = useMessageData(ordinal, isLastMessage, isCenteredHighlight) const common = useCommonWithData(ordinal, messageData) const {showPopup} = common const {default: VideoAttachment} = require('./video') as {default: typeof VideoAttachmentType} return ( - + - + ) } export function WrapperAttachmentImage(p: Props) { - const {ordinal} = p - const messageData = useMessageData(ordinal) + const {ordinal, isCenteredHighlight = false, isLastMessage = false} = p + const messageData = useMessageData(ordinal, isLastMessage, isCenteredHighlight) const common = useCommonWithData(ordinal, messageData) const {showPopup} = common const {default: ImageAttachment} = require('./image') as {default: typeof ImageAttachmentType} return ( - + - + ) } diff --git a/shared/chat/conversation/messages/reactions-rows.tsx b/shared/chat/conversation/messages/reactions-rows.tsx index 86eb38bec95d..cb254e856d04 100644 --- a/shared/chat/conversation/messages/reactions-rows.tsx +++ b/shared/chat/conversation/messages/reactions-rows.tsx @@ -13,7 +13,14 @@ const emptyEmojis: ReadonlyArray = [] function ReactionsRowContainer() { const ordinal = useOrdinal() - const emojis = Chat.useChatContext(C.useShallow(s => s.reactionOrderMap.get(ordinal) ?? emptyEmojis)) + const emojis = Chat.useChatContext( + C.useShallow(s => { + const fromMap = s.reactionOrderMap.get(ordinal) + if (fromMap?.length) return fromMap + const reactions = s.messageMap.get(ordinal)?.reactions + return reactions?.size ? [...reactions.keys()] : emptyEmojis + }) + ) return emojis.length === 0 ? null : ( diff --git a/shared/chat/conversation/messages/separator.tsx b/shared/chat/conversation/messages/separator.tsx index c41ee092e677..ba7704df6e40 100644 --- a/shared/chat/conversation/messages/separator.tsx +++ b/shared/chat/conversation/messages/separator.tsx @@ -1,13 +1,10 @@ import * as C from '@/constants' import * as Chat from '@/stores/chat' -import {useTeamsState} from '@/stores/teams' import * as Kb from '@/common-adapters' import * as React from 'react' import * as T from '@/constants/types' -import {formatTimeForConversationList, formatTimeForChat} from '@/util/timestamp' +import {formatTimeForConversationList} from '@/util/timestamp' import {OrangeLineContext} from '../orange-line-context' -import {useTrackerState} from '@/stores/tracker' -import {navToProfile} from '@/constants/router' const missingMessage = Chat.makeMessageDeleted({}) @@ -34,147 +31,11 @@ const useSeparatorData = (trailingItem: T.Chat.Ordinal, leadingItem: T.Chat.Ordi ? formatTimeForConversationList(m.timestamp) : '' - if (!showUsername) { - return { - author: '', - botAlias: '', - isAdhocBot: false, - orangeLineAbove, - orangeTime, - ordinal, - showUsername, - teamID: '' as T.Teams.TeamID, - teamType: 'adhoc' as T.Chat.TeamType, - teamname: '', - timestamp: 0, - } - } - - const {author, timestamp} = m - const {teamID, botAliases, teamType, teamname} = s.meta - const participantInfoNames = s.participants.name - const isAdhocBot = - teamType === 'adhoc' && participantInfoNames.length > 0 - ? !participantInfoNames.includes(author) - : false - - return { - author, - botAlias: botAliases[author] ?? '', - isAdhocBot, - orangeLineAbove, - orangeTime, - ordinal, - showUsername, - teamID, - teamType, - teamname, - timestamp, - } + return {orangeLineAbove, orangeTime, ordinal} }) ) } -type AuthorProps = { - author: string - botAlias: string - isAdhocBot: boolean - teamID: T.Teams.TeamID - teamType: T.Chat.TeamType - teamname: string - timestamp: number - showUsername: string -} - -// Separate component so useTeamsState/useTrackerState only -// subscribe when there's actually an author to show. -function AuthorSection(p: AuthorProps) { - const {author, botAlias, isAdhocBot, teamID, teamType, teamname, timestamp, showUsername} = p - - const authorRoleInTeam = useTeamsState(s => s.teamIDToMembers.get(teamID)?.get(author)?.type) - const showUser = useTrackerState(s => s.dispatch.showUser) - - const onAuthorClick = () => { - if (C.isMobile) { - navToProfile(showUsername) - } else { - showUser(showUsername, true) - } - } - - const authorIsOwner = authorRoleInTeam === 'owner' - const authorIsAdmin = authorRoleInTeam === 'admin' - const authorIsBot = teamname - ? authorRoleInTeam === 'restrictedbot' || authorRoleInTeam === 'bot' - : isAdhocBot - const allowCrown = teamType !== 'adhoc' && (authorIsOwner || authorIsAdmin) - - const usernameNode = ( - - ) - - const ownerAdminTooltipIcon = allowCrown ? ( - - - - ) : null - - const botIcon = authorIsBot ? ( - - - - ) : null - - const botAliasOrUsername = botAlias ? ( - - {botAlias} {' [' + showUsername + ']'} - - ) : ( - usernameNode - ) - - return ( - <> - - - - {botAliasOrUsername} - {ownerAdminTooltipIcon} - {botIcon} - - {formatTimeForChat(timestamp)} - - - - - ) -} - type Props = { leadingItem?: T.Chat.Ordinal trailingItem: T.Chat.Ordinal @@ -183,39 +44,25 @@ type Props = { function SeparatorConnector(p: Props) { const {leadingItem, trailingItem} = p const data = useSeparatorData(trailingItem, leadingItem ?? T.Chat.numberToOrdinal(0)) - const {ordinal, showUsername, orangeLineAbove, orangeTime} = data + const {ordinal, orangeLineAbove, orangeTime} = data - if (!ordinal || (!showUsername && !orangeLineAbove)) return null + if (!ordinal || !orangeLineAbove) return null return ( - {showUsername ? ( - - ) : null} - {orangeLineAbove ? ( - - {orangeTime ? ( - - {orangeTime} - - ) : null} - - ) : null} + + {orangeTime ? ( + + {orangeTime} + + ) : null} + ) } @@ -223,46 +70,7 @@ function SeparatorConnector(p: Props) { const styles = Kb.Styles.styleSheetCreate( () => ({ - authorContainer: Kb.Styles.platformStyles({ - common: { - alignItems: 'flex-start', - alignSelf: 'flex-start', - marginLeft: Kb.Styles.isMobile ? 48 : 56, - }, - isElectron: { - marginBottom: 0, - marginTop: 0, - }, - isMobile: {marginTop: 8}, - }), - avatar: Kb.Styles.platformStyles({ - common: {position: 'absolute', top: 4}, - isElectron: { - left: Kb.Styles.globalMargins.small, - top: 4, - zIndex: 2, - }, - isMobile: {left: Kb.Styles.globalMargins.tiny}, - }), - botAlias: Kb.Styles.platformStyles({ - common: {color: Kb.Styles.globalColors.black}, - isElectron: { - maxWidth: 240, - wordBreak: 'break-all', - }, - isMobile: {maxWidth: 120}, - }), container: Kb.Styles.platformStyles({ - common: { - position: 'relative', - }, - isElectron: { - height: 21, - marginBottom: 0, - paddingTop: 5, - }, - }), - containerNoName: Kb.Styles.platformStyles({ common: { position: 'relative', }, @@ -297,15 +105,6 @@ const styles = Kb.Styles.styleSheetCreate( right: -16, }, }), - usernameCrown: Kb.Styles.platformStyles({ - isElectron: { - alignItems: 'baseline', - marginRight: 48, - position: 'relative', - top: -2, - }, - isMobile: {alignItems: 'center'}, - }), }) as const ) diff --git a/shared/chat/conversation/messages/text/wrapper.tsx b/shared/chat/conversation/messages/text/wrapper.tsx index 4c99c716becd..cc1586dc7eba 100644 --- a/shared/chat/conversation/messages/text/wrapper.tsx +++ b/shared/chat/conversation/messages/text/wrapper.tsx @@ -4,7 +4,7 @@ import {useReply} from './reply' import {useBottom} from './bottom' import {useOrdinal} from '../ids-context' import {SetRecycleTypeContext} from '../../recycle-type-context' -import {WrapperMessage, useCommonWithData, useMessageData, type Props} from '../wrapper/wrapper' +import {WrapperMessageView, useCommonWithData, useMessageData, type Props} from '../wrapper/wrapper' import type {StyleOverride} from '@/common-adapters/markdown' import {sharedStyles} from '../shared-styles' @@ -46,8 +46,8 @@ function MessageMarkdown({style, text}: {style: Kb.Styles.StylesCrossPlatform; t } function WrapperText(p: Props) { - const {ordinal} = p - const messageData = useMessageData(ordinal) + const {ordinal, isCenteredHighlight = false, isLastMessage = false} = p + const messageData = useMessageData(ordinal, isLastMessage, isCenteredHighlight) const common = useCommonWithData(ordinal, messageData) const {type, showCenteredHighlight} = common const {isEditing, hasReactions} = messageData @@ -87,9 +87,9 @@ function WrapperText(p: Props) { } return ( - + {children} - + ) } diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index 15607be023b8..e53ef6fbf390 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -14,8 +14,14 @@ import * as T from '@/constants/types' import capitalize from 'lodash/capitalize' import {useEdited} from './edited' import {useCurrentUserState} from '@/stores/current-user' +import {useTeamsState} from '@/stores/teams' +import {useTrackerState} from '@/stores/tracker' +import {navToProfile} from '@/constants/router' +import {formatTimeForChat} from '@/util/timestamp' export type Props = { + isCenteredHighlight?: boolean + isLastMessage?: boolean ordinal: T.Chat.Ordinal } @@ -45,10 +51,141 @@ const messageShowsPopup = (type?: T.Chat.Message['type']) => // If there is no matching message treat it like a deleted const missingMessage = Chat.makeMessageDeleted({}) +type AuthorProps = { + author: string + botAlias: string + isAdhocBot: boolean + teamID: T.Teams.TeamID + teamType: T.Chat.TeamType + teamname: string + timestamp: number + showUsername: string +} + +function AuthorSection(p: AuthorProps) { + const {author, botAlias, isAdhocBot, teamID, teamType, teamname, timestamp, showUsername} = p + + const authorRoleInTeam = useTeamsState(s => s.teamIDToMembers.get(teamID)?.get(author)?.type) + const showUser = useTrackerState(s => s.dispatch.showUser) + + const onAuthorClick = () => { + if (C.isMobile) { + navToProfile(showUsername) + } else { + showUser(showUsername, true) + } + } + + const authorIsOwner = authorRoleInTeam === 'owner' + const authorIsAdmin = authorRoleInTeam === 'admin' + const authorIsBot = teamname + ? authorRoleInTeam === 'restrictedbot' || authorRoleInTeam === 'bot' + : isAdhocBot + const allowCrown = teamType !== 'adhoc' && (authorIsOwner || authorIsAdmin) + + const usernameNode = ( + + ) + + const ownerAdminTooltipIcon = allowCrown ? ( + + + + ) : null + + const botIcon = authorIsBot ? ( + + + + ) : null + + const botAliasOrUsername = botAlias ? ( + + {botAlias} {' [' + showUsername + ']'} + + ) : ( + usernameNode + ) + + return ( + <> + + + + {botAliasOrUsername} + {ownerAdminTooltipIcon} + {botIcon} + + {formatTimeForChat(timestamp)} + + + + + ) +} + +const useAuthorData = (ordinal: T.Chat.Ordinal) => + Chat.useChatContext( + C.useShallow(s => { + const showUsername = s.showUsernameMap.get(ordinal) ?? '' + if (!showUsername) { + return { + author: '', + botAlias: '', + isAdhocBot: false, + showUsername, + teamID: '' as T.Teams.TeamID, + teamType: 'adhoc' as T.Chat.TeamType, + teamname: '', + timestamp: 0, + } + } + const m = s.messageMap.get(ordinal) ?? missingMessage + const {author, timestamp} = m + const {teamID, botAliases, teamType, teamname} = s.meta + const participantInfoNames = s.participants.name + const isAdhocBot = + teamType === 'adhoc' && participantInfoNames.length > 0 + ? !participantInfoNames.includes(author) + : false + return {author, botAlias: botAliases[author] ?? '', isAdhocBot, showUsername, teamID, teamType, teamname, timestamp} + }) + ) + +function AuthorHeader({ordinal}: {ordinal: T.Chat.Ordinal}) { + const data = useAuthorData(ordinal) + if (!data.showUsername) return null + return +} + // Pure helper functions - moved outside hooks to avoid recreating them per message const getReactionsPopupPosition = ( - ordinal: T.Chat.Ordinal, - ordinals: ReadonlyArray, + isLastMessage: boolean, hasReactions: boolean, message: T.Chat.Message ) => { @@ -56,7 +193,7 @@ const getReactionsPopupPosition = ( if (hasReactions) return 'none' as const const validMessage = Chat.isMessageWithReactions(message) if (!validMessage) return 'none' as const - return ordinals.at(-1) === ordinal ? ('last' as const) : ('middle' as const) + return isLastMessage ? ('last' as const) : ('middle' as const) } const getEcrType = (message: T.Chat.Message, you: string) => { @@ -81,7 +218,11 @@ const getEcrType = (message: T.Chat.Message, you: string) => { } // Combined selector hook that fetches all message data in a single subscription -export const useMessageData = (ordinal: T.Chat.Ordinal) => { +export const useMessageData = ( + ordinal: T.Chat.Ordinal, + isLastMessage = false, + isCenteredHighlight = false +) => { const you = useCurrentUserState(s => s.username) return Chat.useChatContext( @@ -89,7 +230,6 @@ export const useMessageData = (ordinal: T.Chat.Ordinal) => { const accountsInfoMap = s.accountsInfoMap const m = s.messageMap.get(ordinal) ?? missingMessage const isEditing = s.editing === ordinal - const ordinals = s.messageOrdinals const {exploded, submitState, author, id, botUsername} = m const type = m.type const idMatchesOrdinal = T.Chat.ordinalToNumber(m.ordinal) === T.Chat.messageIDToNumber(id) @@ -105,13 +245,9 @@ export const useMessageData = (ordinal: T.Chat.Ordinal) => { const showCoinsIcon = hasSuccessfulInlinePayments(paymentStatusMap, m) const hasReactions = (m.reactions?.size ?? 0) > 0 const botname = botUsername === author ? '' : (botUsername ?? '') - const reactionsPopupPosition = getReactionsPopupPosition(ordinal, ordinals ?? [], hasReactions, m) + const reactionsPopupPosition = getReactionsPopupPosition(isLastMessage, hasReactions, m) const ecrType = getEcrType(m, you) const shouldShowPopup = Chat.shouldShowPopup(accountsInfoMap, m) - // Inline highlight mode check to avoid separate selector - const centeredOrdinalType = s.messageCenterOrdinal - const showCenteredHighlight = - centeredOrdinalType?.ordinal === ordinal && centeredOrdinalType.highlightMode !== 'none' // Fields lifted from child components to consolidate subscriptions const hasBeenEdited = m.hasBeenEdited ?? false const hasCoinFlip = m.type === 'text' && !!m.flipGameID @@ -134,7 +270,7 @@ export const useMessageData = (ordinal: T.Chat.Ordinal) => { isEditing, reactionsPopupPosition, shouldShowPopup, - showCenteredHighlight, + showCenteredHighlight: isCenteredHighlight, showCoinsIcon, showExplodingCountdown, showReplyTo, @@ -526,14 +662,16 @@ function RightSide(p: RProps) { } export function WrapperMessage(p: WMProps) { - const {ordinal, bottomChildren, children, messageData: mdataProp} = p + const {ordinal, isCenteredHighlight = false, isLastMessage = false} = p + const messageData = useMessageData(ordinal, isLastMessage, isCenteredHighlight) + return +} + +export function WrapperMessageView(p: WMProps & {messageData: ReturnType}) { + const {ordinal, bottomChildren, children, messageData: mdata} = p const {showCenteredHighlight, showPopup, showingPopup, popup, popupAnchor} = p const [showingPicker, setShowingPicker] = React.useState(false) - // Use provided messageData if available, otherwise fetch it - const mdataFetched = useMessageData(ordinal) - const mdata = mdataProp ?? mdataFetched - const {decorate, type, hasReactions, isEditing, shouldShowPopup} = mdata const {ecrType, showSendIndicator, showRevoked, showExplodingCountdown, exploding} = mdata const {reactionsPopupPosition, showCoinsIcon, botname, you, hasBeenEdited, hasUnfurlList} = mdata @@ -569,7 +707,10 @@ export function WrapperMessage(p: WMProps) { return ( - + + + + {popup} ) @@ -578,10 +719,39 @@ export function WrapperMessage(p: WMProps) { const styles = Kb.Styles.styleSheetCreate( () => ({ + authorContainer: Kb.Styles.platformStyles({ + common: { + alignItems: 'flex-start', + alignSelf: 'flex-start', + marginLeft: Kb.Styles.isMobile ? 48 : 56, + }, + isElectron: { + marginBottom: 0, + marginTop: 0, + }, + isMobile: {marginTop: 8}, + }), + avatar: Kb.Styles.platformStyles({ + common: {position: 'absolute', top: 4}, + isElectron: { + left: Kb.Styles.globalMargins.small, + top: 4, + zIndex: 2, + }, + isMobile: {left: Kb.Styles.globalMargins.tiny}, + }), background: { alignSelf: 'stretch', flexShrink: 1, }, + botAlias: Kb.Styles.platformStyles({ + common: {color: Kb.Styles.globalColors.black}, + isElectron: { + maxWidth: 240, + wordBreak: 'break-all', + }, + isMobile: {maxWidth: 120}, + }), ellipsis: Kb.Styles.platformStyles({ isElectron: {paddingTop: 2}, isMobile: {paddingTop: 4}, @@ -642,5 +812,14 @@ const styles = Kb.Styles.styleSheetCreate( }, isElectron: {minHeight: 14}, }), + usernameCrown: Kb.Styles.platformStyles({ + isElectron: { + alignItems: 'baseline', + marginRight: 48, + position: 'relative', + top: -2, + }, + isMobile: {alignItems: 'center'}, + }), }) as const ) diff --git a/shared/common-adapters/avatar/avatar-line.tsx b/shared/common-adapters/avatar/avatar-line.tsx index 046e34dd2c34..6820fafcd440 100644 --- a/shared/common-adapters/avatar/avatar-line.tsx +++ b/shared/common-adapters/avatar/avatar-line.tsx @@ -57,42 +57,42 @@ const getTextSize = (size: AvatarSize) => (size >= 48 ? 'BodySmallBold' : 'BodyT const getSizeStyle = (size: AvatarSize) => ({ horizontal: Kb.Styles.styleSheetCreate(() => ({ avatar: { - marginRight: -size / 3, + marginRight: -Math.round(size / 3), }, container: { marginLeft: 2, - marginRight: size / 3 + 2, + marginRight: Math.round(size / 3) + 2, }, overflowBox: { backgroundColor: Kb.Styles.globalColors.grey, borderBottomRightRadius: size, borderTopRightRadius: size, height: size, - paddingLeft: size / 2, + paddingLeft: Math.round(size / 2), }, text: { color: Kb.Styles.globalColors.black_50, - paddingRight: size / 5, + paddingRight: Math.round(size / 5), }, })), vertical: Kb.Styles.styleSheetCreate(() => ({ avatar: { - marginBottom: -size / 3, + marginBottom: -Math.round(size / 3), }, container: { - marginBottom: size / 3 + 2, + marginBottom: Math.round(size / 3) + 2, marginTop: 2, }, overflowBox: { backgroundColor: Kb.Styles.globalColors.grey, borderBottomLeftRadius: size, borderBottomRightRadius: size, - paddingTop: size / 2, + paddingTop: Math.round(size / 2), width: size, }, text: { color: Kb.Styles.globalColors.black_50, - paddingBottom: size / 5, + paddingBottom: Math.round(size / 5), }, })), }) From 87649dab040508ed2f6430d02ab852c500e117d8 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Tue, 7 Apr 2026 13:23:12 -0400 Subject: [PATCH 02/55] WIP --- .../conversation/list-area/index.desktop.tsx | 26 ++++++++++++++----- .../conversation/list-area/index.native.tsx | 19 +++++++++++--- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/shared/chat/conversation/list-area/index.desktop.tsx b/shared/chat/conversation/list-area/index.desktop.tsx index 25692d01bc6c..f401a101b715 100644 --- a/shared/chat/conversation/list-area/index.desktop.tsx +++ b/shared/chat/conversation/list-area/index.desktop.tsx @@ -366,13 +366,15 @@ const useScrolling = (p: { } const useItems = (p: { + centeredHighlightOrdinal: T.Chat.Ordinal | undefined messageOrdinals: ReadonlyArray centeredOrdinal: T.Chat.Ordinal | undefined editingOrdinal: T.Chat.Ordinal | undefined messageTypeMap: ReadonlyMap | undefined }) => { - const {messageTypeMap, messageOrdinals, centeredOrdinal, editingOrdinal} = p + const {messageTypeMap, messageOrdinals, centeredHighlightOrdinal, centeredOrdinal, editingOrdinal} = p const ordinalsInAWaypoint = 10 + const lastOrdinal = messageOrdinals.at(-1) const rowRenderer = (ordinal: T.Chat.Ordinal) => { const type = messageTypeMap?.get(ordinal) ?? 'text' const Clazz = getMessageRender(type) @@ -393,11 +395,15 @@ const useItems = (p: { 'WrapperMessage-hoverBox', 'WrapperMessage-decorated', 'WrapperMessage-hoverColor', - {highlighted: centeredOrdinal === ordinal || editingOrdinal === ordinal} + {highlighted: centeredHighlightOrdinal === ordinal || editingOrdinal === ordinal} )} > - + ) } @@ -487,9 +493,11 @@ const ThreadWrapper = function ThreadWrapper() { C.useShallow(s => { const {messageTypeMap, editing: editingOrdinal, id: conversationIDKey} = s const {messageCenterOrdinal: mco, messageOrdinals = noOrdinals, loaded} = s - const centeredOrdinal = mco && mco.highlightMode !== 'none' ? mco.ordinal : undefined + const centeredHighlightOrdinal = mco && mco.highlightMode !== 'none' ? mco.ordinal : undefined + const centeredOrdinal = mco?.ordinal const containsLatestMessage = s.isCaughtUp() return { + centeredHighlightOrdinal, centeredOrdinal, containsLatestMessage, conversationIDKey, @@ -500,7 +508,7 @@ const ThreadWrapper = function ThreadWrapper() { } }) ) - const {conversationIDKey, editingOrdinal, centeredOrdinal} = data + const {conversationIDKey, editingOrdinal, centeredHighlightOrdinal, centeredOrdinal} = data const {containsLatestMessage, messageOrdinals, loaded, messageTypeMap} = data const copyToClipboard = useConfigState(s => s.dispatch.defer.copyToClipboard) const listRef = React.useRef(null) @@ -561,7 +569,13 @@ const ThreadWrapper = function ThreadWrapper() { } } - const items = useItems({centeredOrdinal, editingOrdinal, messageOrdinals, messageTypeMap}) + const items = useItems({ + centeredHighlightOrdinal, + centeredOrdinal, + editingOrdinal, + messageOrdinals, + messageTypeMap, + }) const setListContents = useHandleListResize({ centeredOrdinal, isLockedToBottom, diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index d0b14b4df6a1..1c5f8ad78d5d 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -104,12 +104,17 @@ const ConversationList = function ConversationList() { const [lastED, setLastED] = React.useState(extraData) const loaded = Chat.useChatContext(s => s.loaded) - const centeredOrdinal = - Chat.useChatContext(s => s.messageCenterOrdinal)?.ordinal ?? T.Chat.numberToOrdinal(-1) + const messageCenterOrdinal = Chat.useChatContext(s => s.messageCenterOrdinal) + const centeredHighlightOrdinal = + messageCenterOrdinal && messageCenterOrdinal.highlightMode !== 'none' + ? messageCenterOrdinal.ordinal + : T.Chat.numberToOrdinal(-1) + const centeredOrdinal = messageCenterOrdinal?.ordinal ?? T.Chat.numberToOrdinal(-1) const messageTypeMap = Chat.useChatContext(s => s.messageTypeMap) const _messageOrdinals = Chat.useChatContext(s => s.messageOrdinals) const messageOrdinals = [...(_messageOrdinals ?? [])].reverse() + const lastOrdinal = messageOrdinals.at(-1) const listRef = React.useRef |*/ FlatList | null>(null) const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) @@ -126,7 +131,15 @@ const ConversationList = function ConversationList() { const type = messageTypeMap.get(ordinal) ?? 'text' const Clazz = getMessageRender(type) if (!Clazz) return null - return + return ( + + + + ) } const recycleTypeRef = React.useRef(new Map()) From 0a0f058c7c0f7693588645735b541ca232b846b3 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Tue, 7 Apr 2026 15:08:14 -0400 Subject: [PATCH 03/55] WIP --- .../conversation/messages/attachment/file.tsx | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/shared/chat/conversation/messages/attachment/file.tsx b/shared/chat/conversation/messages/attachment/file.tsx index 6c464026750d..5118a0f2c434 100644 --- a/shared/chat/conversation/messages/attachment/file.tsx +++ b/shared/chat/conversation/messages/attachment/file.tsx @@ -48,16 +48,22 @@ function FileContainer(p: OwnProps) { const switchTab = C.Router2.switchTab const navigateAppend = C.Router2.navigateAppend - const onSaltpackFileOpen = (path: string, name: typeof CryptoRoutes.decryptTab | typeof CryptoRoutes.verifyTab) => { + const onSaltpackFileOpen = ( + path: string, + name: typeof CryptoRoutes.decryptTab | typeof CryptoRoutes.verifyTab + ) => { switchTab(C.Tabs.cryptoTab) - navigateAppend({ - name, - params: { - entryNonce: makeUUID(), - seedInputPath: path, - seedInputType: 'file', + navigateAppend( + { + name, + params: { + entryNonce: makeUUID(), + seedInputPath: path, + seedInputType: 'file', + }, }, - }, true) + true + ) } const openLocalPathInSystemFileManagerDesktop = useFSState( s => s.dispatch.defer.openLocalPathInSystemFileManagerDesktop @@ -242,11 +248,11 @@ const styles = Kb.Styles.styleSheetCreate( }, }), linkStyle: {color: Kb.Styles.globalColors.black_50}, - progressOverlay: {bottom: 0, left: 0, position: 'absolute', right: 0}, progressLabelStyle: { color: Kb.Styles.globalColors.black_50, marginRight: Kb.Styles.globalMargins.tiny, }, + progressOverlay: {bottom: 0, left: 0, position: 'absolute', right: 0}, retry: { color: Kb.Styles.globalColors.redDark, textDecorationLine: 'underline', From 9ad69ff499e0b72c6cd8aa6f11d318621daad740 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Tue, 7 Apr 2026 17:52:37 -0400 Subject: [PATCH 04/55] WIP --- .../conversation/list-area/index.desktop.tsx | 2 - .../conversation/list-area/index.native.tsx | 2 - .../messages/attachment/wrapper.tsx | 16 +-- .../conversation/messages/text/wrapper.tsx | 4 +- .../wrapper/long-pressable/index.d.ts | 1 + .../conversation/messages/wrapper/wrapper.tsx | 135 +++++++++++------- 6 files changed, 97 insertions(+), 63 deletions(-) diff --git a/shared/chat/conversation/list-area/index.desktop.tsx b/shared/chat/conversation/list-area/index.desktop.tsx index f401a101b715..6eba39f88e94 100644 --- a/shared/chat/conversation/list-area/index.desktop.tsx +++ b/shared/chat/conversation/list-area/index.desktop.tsx @@ -374,7 +374,6 @@ const useItems = (p: { }) => { const {messageTypeMap, messageOrdinals, centeredHighlightOrdinal, centeredOrdinal, editingOrdinal} = p const ordinalsInAWaypoint = 10 - const lastOrdinal = messageOrdinals.at(-1) const rowRenderer = (ordinal: T.Chat.Ordinal) => { const type = messageTypeMap?.get(ordinal) ?? 'text' const Clazz = getMessageRender(type) @@ -401,7 +400,6 @@ const useItems = (p: { diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 1c5f8ad78d5d..3cdadfd4a6cb 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -114,7 +114,6 @@ const ConversationList = function ConversationList() { const _messageOrdinals = Chat.useChatContext(s => s.messageOrdinals) const messageOrdinals = [...(_messageOrdinals ?? [])].reverse() - const lastOrdinal = messageOrdinals.at(-1) const listRef = React.useRef |*/ FlatList | null>(null) const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) @@ -135,7 +134,6 @@ const ConversationList = function ConversationList() { diff --git a/shared/chat/conversation/messages/attachment/wrapper.tsx b/shared/chat/conversation/messages/attachment/wrapper.tsx index 7e884892444d..4dddf606aee1 100644 --- a/shared/chat/conversation/messages/attachment/wrapper.tsx +++ b/shared/chat/conversation/messages/attachment/wrapper.tsx @@ -5,8 +5,8 @@ import type VideoAttachmentType from './video' import {WrapperMessageView, useCommonWithData, useMessageData, type Props} from '../wrapper/wrapper' export function WrapperAttachmentAudio(p: Props) { - const {ordinal, isCenteredHighlight = false, isLastMessage = false} = p - const messageData = useMessageData(ordinal, isLastMessage, isCenteredHighlight) + const {ordinal, isCenteredHighlight = false} = p + const messageData = useMessageData(ordinal, isCenteredHighlight) const common = useCommonWithData(ordinal, messageData) const {default: AudioAttachment} = require('./audio') as {default: typeof AudioAttachmentType} return ( @@ -16,8 +16,8 @@ export function WrapperAttachmentAudio(p: Props) { ) } export function WrapperAttachmentFile(p: Props) { - const {ordinal, isCenteredHighlight = false, isLastMessage = false} = p - const messageData = useMessageData(ordinal, isLastMessage, isCenteredHighlight) + const {ordinal, isCenteredHighlight = false} = p + const messageData = useMessageData(ordinal, isCenteredHighlight) const common = useCommonWithData(ordinal, messageData) const {showPopup} = common @@ -30,8 +30,8 @@ export function WrapperAttachmentFile(p: Props) { ) } export function WrapperAttachmentVideo(p: Props) { - const {ordinal, isCenteredHighlight = false, isLastMessage = false} = p - const messageData = useMessageData(ordinal, isLastMessage, isCenteredHighlight) + const {ordinal, isCenteredHighlight = false} = p + const messageData = useMessageData(ordinal, isCenteredHighlight) const common = useCommonWithData(ordinal, messageData) const {showPopup} = common const {default: VideoAttachment} = require('./video') as {default: typeof VideoAttachmentType} @@ -43,8 +43,8 @@ export function WrapperAttachmentVideo(p: Props) { ) } export function WrapperAttachmentImage(p: Props) { - const {ordinal, isCenteredHighlight = false, isLastMessage = false} = p - const messageData = useMessageData(ordinal, isLastMessage, isCenteredHighlight) + const {ordinal, isCenteredHighlight = false} = p + const messageData = useMessageData(ordinal, isCenteredHighlight) const common = useCommonWithData(ordinal, messageData) const {showPopup} = common const {default: ImageAttachment} = require('./image') as {default: typeof ImageAttachmentType} diff --git a/shared/chat/conversation/messages/text/wrapper.tsx b/shared/chat/conversation/messages/text/wrapper.tsx index cc1586dc7eba..2996cb752cb0 100644 --- a/shared/chat/conversation/messages/text/wrapper.tsx +++ b/shared/chat/conversation/messages/text/wrapper.tsx @@ -46,8 +46,8 @@ function MessageMarkdown({style, text}: {style: Kb.Styles.StylesCrossPlatform; t } function WrapperText(p: Props) { - const {ordinal, isCenteredHighlight = false, isLastMessage = false} = p - const messageData = useMessageData(ordinal, isLastMessage, isCenteredHighlight) + const {ordinal, isCenteredHighlight = false} = p + const messageData = useMessageData(ordinal, isCenteredHighlight) const common = useCommonWithData(ordinal, messageData) const {type, showCenteredHighlight} = common const {isEditing, hasReactions} = messageData diff --git a/shared/chat/conversation/messages/wrapper/long-pressable/index.d.ts b/shared/chat/conversation/messages/wrapper/long-pressable/index.d.ts index 18fc12940d48..2640c86ec213 100644 --- a/shared/chat/conversation/messages/wrapper/long-pressable/index.d.ts +++ b/shared/chat/conversation/messages/wrapper/long-pressable/index.d.ts @@ -11,6 +11,7 @@ export type Props = { // desktop className?: string onContextMenu?: () => void + onMouseLeave?: () => void onMouseOver?: () => void } declare function LongPressable(props: Props & {ref?: React.Ref}): React.ReactNode diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index e53ef6fbf390..fa101f7bc742 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -21,7 +21,6 @@ import {formatTimeForChat} from '@/util/timestamp' export type Props = { isCenteredHighlight?: boolean - isLastMessage?: boolean ordinal: T.Chat.Ordinal } @@ -183,19 +182,6 @@ function AuthorHeader({ordinal}: {ordinal: T.Chat.Ordinal}) { return } -// Pure helper functions - moved outside hooks to avoid recreating them per message -const getReactionsPopupPosition = ( - isLastMessage: boolean, - hasReactions: boolean, - message: T.Chat.Message -) => { - if (C.isMobile) return 'none' as const - if (hasReactions) return 'none' as const - const validMessage = Chat.isMessageWithReactions(message) - if (!validMessage) return 'none' as const - return isLastMessage ? ('last' as const) : ('middle' as const) -} - const getEcrType = (message: T.Chat.Message, you: string) => { const {errorReason, type, submitState} = message if (!errorReason) return EditCancelRetryType.NONE @@ -220,7 +206,6 @@ const getEcrType = (message: T.Chat.Message, you: string) => { // Combined selector hook that fetches all message data in a single subscription export const useMessageData = ( ordinal: T.Chat.Ordinal, - isLastMessage = false, isCenteredHighlight = false ) => { const you = useCurrentUserState(s => s.username) @@ -233,7 +218,6 @@ export const useMessageData = ( const {exploded, submitState, author, id, botUsername} = m const type = m.type const idMatchesOrdinal = T.Chat.ordinalToNumber(m.ordinal) === T.Chat.messageIDToNumber(id) - const youSent = m.author === you && !idMatchesOrdinal const exploding = !!m.exploding const decorate = !exploded && !m.errorReason const isShowingUploadProgressBar = you === author && m.type === 'attachment' && m.inlineVideoPlayable @@ -245,7 +229,7 @@ export const useMessageData = ( const showCoinsIcon = hasSuccessfulInlinePayments(paymentStatusMap, m) const hasReactions = (m.reactions?.size ?? 0) > 0 const botname = botUsername === author ? '' : (botUsername ?? '') - const reactionsPopupPosition = getReactionsPopupPosition(isLastMessage, hasReactions, m) + const canShowReactionsPopup = Chat.isMessageWithReactions(m) const ecrType = getEcrType(m, you) const shouldShowPopup = Chat.shouldShowPopup(accountsInfoMap, m) // Fields lifted from child components to consolidate subscriptions @@ -259,6 +243,7 @@ export const useMessageData = ( return { botname, + canShowReactionsPopup, decorate, ecrType, exploding, @@ -268,7 +253,6 @@ export const useMessageData = ( hasUnfurlList, hasUnfurlPrompts, isEditing, - reactionsPopupPosition, shouldShowPopup, showCenteredHighlight: isCenteredHighlight, showCoinsIcon, @@ -279,8 +263,6 @@ export const useMessageData = ( text, textType, type, - you, - youSent, } }) ) @@ -349,6 +331,7 @@ const hasSuccessfulInlinePayments = ( type TSProps = { botname: string bottomChildren: React.ReactNode + canShowReactionsPopup: boolean children: React.ReactNode decorate: boolean ecrType: EditCancelRetryType @@ -358,7 +341,6 @@ type TSProps = { hasUnfurlList: boolean isHighlighted: boolean popupAnchor: React.RefObject - reactionsPopupPosition: 'none' | 'last' | 'middle' setShowingPicker: (s: boolean) => void shouldShowPopup: boolean showCoinsIcon: boolean @@ -369,7 +351,6 @@ type TSProps = { showingPopup: boolean showPopup: () => void type: T.Chat.MessageType - you: string } const NormalWrapper = ({ @@ -387,10 +368,12 @@ const NormalWrapper = ({ } function TextAndSiblings(p: TSProps) { - const {botname, bottomChildren, children, decorate, hasBeenEdited, hasUnfurlList, isHighlighted} = p + const {botname, bottomChildren, canShowReactionsPopup, children, decorate, hasBeenEdited, hasUnfurlList, isHighlighted} = p const {showingPopup, ecrType, exploding, hasReactions, popupAnchor} = p - const {type, reactionsPopupPosition, setShowingPicker, showCoinsIcon, shouldShowPopup} = p + const {type, setShowingPicker, showCoinsIcon, shouldShowPopup} = p const {showPopup, showExplodingCountdown, showRevoked, showSendIndicator, showingPicker} = p + const [hovering, setHovering] = React.useState(false) + const showDesktopReactionsPopup = hovering || showingPicker const pressableProps = Kb.Styles.isMobile ? { onLongPress: decorate ? showPopup : undefined, @@ -404,6 +387,8 @@ function TextAndSiblings(p: TSProps) { active: showingPopup || showingPicker, }), onContextMenu: showPopup, + onMouseLeave: () => setHovering(false), + onMouseOver: () => setHovering(true), } const content = exploding ? ( @@ -424,10 +409,10 @@ function TextAndSiblings(p: TSProps) { hasBeenEdited={hasBeenEdited} hasUnfurlList={hasUnfurlList} messageType={type} - reactionsPopupPosition={reactionsPopupPosition} hasReactions={hasReactions} bottomChildren={bottomChildren} - showPopup={showPopup} + canShowReactionsPopup={canShowReactionsPopup} + showDesktopReactionsPopup={showDesktopReactionsPopup} setShowingPicker={setShowingPicker} showingPopup={showingPopup} /> @@ -533,33 +518,80 @@ function EditCancelRetry(p: {ecrType: EditCancelRetryType}) { } type BProps = { - showPopup: () => void showingPopup: boolean + showDesktopReactionsPopup: boolean setShowingPicker: (s: boolean) => void bottomChildren?: React.ReactNode + canShowReactionsPopup: boolean hasBeenEdited: boolean hasReactions: boolean hasUnfurlList: boolean messageType: T.Chat.MessageType - reactionsPopupPosition: 'none' | 'last' | 'middle' ecrType: EditCancelRetryType } + +function DesktopEmojiRowPopup(p: { + hasUnfurlList: boolean + messageType: T.Chat.MessageType + setShowingPicker: (s: boolean) => void +}) { + const {hasUnfurlList, messageType, setShowingPicker} = p + const popupRef = React.useRef(null) + const [position, setPosition] = React.useState<'bottom' | 'top'>('bottom') + const [measured, setMeasured] = React.useState(false) + + React.useLayoutEffect(() => { + const popupNode = popupRef.current + if (!popupNode) { + return + } + const {bottom, top} = popupNode.getBoundingClientRect() + const {clientHeight} = document.documentElement + + if (position === 'bottom' && bottom > clientHeight && top >= 0) { + setPosition('top') + return + } + if (position === 'top' && top < 0 && bottom <= clientHeight) { + setPosition('bottom') + return + } + setMeasured(true) + }, [position]) + + return ( + + + + ) +} // reactions function BottomSide(p: BProps) { - const {showingPopup, setShowingPicker, bottomChildren, ecrType, hasBeenEdited} = p - const {hasReactions, hasUnfurlList, messageType, reactionsPopupPosition} = p + const {showingPopup, showDesktopReactionsPopup, setShowingPicker, bottomChildren, canShowReactionsPopup, ecrType, hasBeenEdited} = p + const {hasReactions, hasUnfurlList, messageType} = p const reactionsRow = hasReactions ? : null - // this exists and is shown using css to avoid thrashing + const canShowDesktopReactionsPopup = !C.isMobile && !hasReactions && canShowReactionsPopup const desktopReactionsPopup = - !C.isMobile && reactionsPopupPosition !== 'none' && !showingPopup ? ( - ) : null @@ -662,8 +694,8 @@ function RightSide(p: RProps) { } export function WrapperMessage(p: WMProps) { - const {ordinal, isCenteredHighlight = false, isLastMessage = false} = p - const messageData = useMessageData(ordinal, isLastMessage, isCenteredHighlight) + const {ordinal, isCenteredHighlight = false} = p + const messageData = useMessageData(ordinal, isCenteredHighlight) return } @@ -673,13 +705,14 @@ export function WrapperMessageView(p: WMProps & {messageData: ReturnType Date: Tue, 7 Apr 2026 19:08:06 -0400 Subject: [PATCH 05/55] WIP --- shared/chat/conversation/messages/attachment/file.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/shared/chat/conversation/messages/attachment/file.tsx b/shared/chat/conversation/messages/attachment/file.tsx index 5118a0f2c434..e49ff89e6fad 100644 --- a/shared/chat/conversation/messages/attachment/file.tsx +++ b/shared/chat/conversation/messages/attachment/file.tsx @@ -252,7 +252,14 @@ const styles = Kb.Styles.styleSheetCreate( color: Kb.Styles.globalColors.black_50, marginRight: Kb.Styles.globalMargins.tiny, }, - progressOverlay: {bottom: 0, left: 0, position: 'absolute', right: 0}, + progressOverlay: { + backgroundColor: Kb.Styles.globalColors.greyLight, + bottom: 0, + left: 0, + opacity: 0.9, + position: 'absolute', + width: 'auto', + }, retry: { color: Kb.Styles.globalColors.redDark, textDecorationLine: 'underline', From 4b6bc14b4ba4276bb7e40c5ef95c842ba381c5d8 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Tue, 7 Apr 2026 19:13:14 -0400 Subject: [PATCH 06/55] WIP --- shared/chat/inbox/row/small-team/index.tsx | 106 +++++++++--------- .../swipe-conv-actions/index.native.tsx | 15 ++- 2 files changed, 65 insertions(+), 56 deletions(-) diff --git a/shared/chat/inbox/row/small-team/index.tsx b/shared/chat/inbox/row/small-team/index.tsx index d1f75a34875c..763b5e28264e 100644 --- a/shared/chat/inbox/row/small-team/index.tsx +++ b/shared/chat/inbox/row/small-team/index.tsx @@ -45,61 +45,65 @@ const SmallTeam = (p: Props) => { const participantOne = teamDisplayName ? '' : participants[0] ?? '' const participantTwo = teamDisplayName ? '' : participants[1] ?? '' + const className = Kb.Styles.classNames('small-row', {selected: isSelected}) + const containerStyle = Kb.Styles.isTablet + ? Kb.Styles.collapseStyles([styles.container, {backgroundColor}]) + : styles.container + const rowContents = ( + + {teamDisplayName ? ( + + ) : ( + + )} + + + + + + + + ) return ( - - - {teamDisplayName ? ( - - ) : ( - - )} - - - - - - + {Kb.Styles.isMobile ? ( + + {rowContents} - + ) : ( + + {rowContents} + + )} ) } diff --git a/shared/chat/inbox/row/small-team/swipe-conv-actions/index.native.tsx b/shared/chat/inbox/row/small-team/swipe-conv-actions/index.native.tsx index 7c884263249c..c9668af271ca 100644 --- a/shared/chat/inbox/row/small-team/swipe-conv-actions/index.native.tsx +++ b/shared/chat/inbox/row/small-team/swipe-conv-actions/index.native.tsx @@ -4,7 +4,7 @@ import * as React from 'react' import * as Reanimated from 'react-native-reanimated' import * as RowSizes from '../../sizes' import type {Props} from '.' -import {Pressable, View} from 'react-native' +import {View} from 'react-native' import {RectButton} from 'react-native-gesture-handler' import Swipeable, {type SwipeableMethods} from 'react-native-gesture-handler/ReanimatedSwipeable' import {useOpenedRowState} from '../../opened-row-state' @@ -126,11 +126,15 @@ function SwipeConvActions(p: Props) { } const inner = onPress ? ( - - {children} - + + + {children} + + ) : ( - children + + {children} + ) return ( @@ -175,6 +179,7 @@ const styles = Kb.Styles.styleSheetCreate( }, touchable: { height: RowSizes.smallRowHeight, + width: '100%', }, }) as const ) From 3bba9a24dd6924b93940c8d193d4525c6246e319 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Tue, 7 Apr 2026 19:25:53 -0400 Subject: [PATCH 07/55] WIP --- .../messages/account-payment/wrapper.tsx | 4 +- .../conversation/messages/pin/wrapper.tsx | 4 +- .../conversation/messages/reactions-rows.tsx | 3 +- .../messages/set-channelname/wrapper.tsx | 4 +- .../messages/set-description/wrapper.tsx | 4 +- .../messages/system-added-to-team/wrapper.tsx | 4 +- .../messages/system-change-avatar/wrapper.tsx | 4 +- .../system-change-retention/wrapper.tsx | 4 +- .../messages/system-create-team/wrapper.tsx | 4 +- .../messages/system-git-push/wrapper.tsx | 4 +- .../system-invite-accepted/wrapper.tsx | 4 +- .../messages/system-joined/wrapper.tsx | 4 +- .../messages/system-left/wrapper.tsx | 4 +- .../messages/system-new-channel/wrapper.tsx | 4 +- .../messages/system-sbs-resolve/wrapper.tsx | 4 +- .../system-simple-to-complex/wrapper.tsx | 4 +- .../messages/system-text/wrapper.tsx | 4 +- .../system-users-added-to-conv/wrapper.tsx | 4 +- .../wrapper/long-pressable/index.d.ts | 1 - .../conversation/messages/wrapper/wrapper.tsx | 107 ++++-------------- shared/constants/chat/message.tsx | 12 ++ shared/stores/convostate.tsx | 17 +-- shared/stores/tests/convostate.test.ts | 20 ++++ 23 files changed, 93 insertions(+), 135 deletions(-) diff --git a/shared/chat/conversation/messages/account-payment/wrapper.tsx b/shared/chat/conversation/messages/account-payment/wrapper.tsx index c3e018a5e990..68df4ece0f16 100644 --- a/shared/chat/conversation/messages/account-payment/wrapper.tsx +++ b/shared/chat/conversation/messages/account-payment/wrapper.tsx @@ -3,8 +3,8 @@ import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' import type PaymentMessageType from './container' function WrapperPayment(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) + const {ordinal, isCenteredHighlight} = p + const common = useCommon(ordinal, isCenteredHighlight) const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) if (message?.type !== 'requestPayment' && message?.type !== 'sendPayment') return null diff --git a/shared/chat/conversation/messages/pin/wrapper.tsx b/shared/chat/conversation/messages/pin/wrapper.tsx index 3d427c3e093d..0b666fa10436 100644 --- a/shared/chat/conversation/messages/pin/wrapper.tsx +++ b/shared/chat/conversation/messages/pin/wrapper.tsx @@ -3,8 +3,8 @@ import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' import type PinType from '.' function WrapperPin(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) + const {ordinal, isCenteredHighlight} = p + const common = useCommon(ordinal, isCenteredHighlight) const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) if (message?.type !== 'pin') return null diff --git a/shared/chat/conversation/messages/reactions-rows.tsx b/shared/chat/conversation/messages/reactions-rows.tsx index cb254e856d04..16cc85a3c417 100644 --- a/shared/chat/conversation/messages/reactions-rows.tsx +++ b/shared/chat/conversation/messages/reactions-rows.tsx @@ -1,4 +1,5 @@ import * as C from '@/constants' +import * as Message from '@/constants/chat/message' import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' import * as React from 'react' @@ -18,7 +19,7 @@ function ReactionsRowContainer() { const fromMap = s.reactionOrderMap.get(ordinal) if (fromMap?.length) return fromMap const reactions = s.messageMap.get(ordinal)?.reactions - return reactions?.size ? [...reactions.keys()] : emptyEmojis + return reactions?.size ? Message.getReactionOrder(reactions) : emptyEmojis }) ) diff --git a/shared/chat/conversation/messages/set-channelname/wrapper.tsx b/shared/chat/conversation/messages/set-channelname/wrapper.tsx index 3d6520603613..66b30b55a7fa 100644 --- a/shared/chat/conversation/messages/set-channelname/wrapper.tsx +++ b/shared/chat/conversation/messages/set-channelname/wrapper.tsx @@ -3,8 +3,8 @@ import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' import type SetChannelnameType from './container' function WrapperSetChannelname(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) + const {ordinal, isCenteredHighlight} = p + const common = useCommon(ordinal, isCenteredHighlight) const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) if (message?.type !== 'setChannelname') return null diff --git a/shared/chat/conversation/messages/set-description/wrapper.tsx b/shared/chat/conversation/messages/set-description/wrapper.tsx index 79799cca91c9..2a3aaf2f9eb0 100644 --- a/shared/chat/conversation/messages/set-description/wrapper.tsx +++ b/shared/chat/conversation/messages/set-description/wrapper.tsx @@ -3,8 +3,8 @@ import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' import type SetDescriptionType from './container' function WrapperSetDescription(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) + const {ordinal, isCenteredHighlight} = p + const common = useCommon(ordinal, isCenteredHighlight) const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) if (message?.type !== 'setDescription') return null 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 ea9d67704a2c..3c1fe3673499 100644 --- a/shared/chat/conversation/messages/system-added-to-team/wrapper.tsx +++ b/shared/chat/conversation/messages/system-added-to-team/wrapper.tsx @@ -3,8 +3,8 @@ import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' import type SystemAddedToTeamType from './container' function SystemAddedToTeam(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) + const {ordinal, isCenteredHighlight} = p + const common = useCommon(ordinal, isCenteredHighlight) const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) if (message?.type !== 'systemAddedToTeam') return null diff --git a/shared/chat/conversation/messages/system-change-avatar/wrapper.tsx b/shared/chat/conversation/messages/system-change-avatar/wrapper.tsx index e03b5f46eded..710e89068407 100644 --- a/shared/chat/conversation/messages/system-change-avatar/wrapper.tsx +++ b/shared/chat/conversation/messages/system-change-avatar/wrapper.tsx @@ -3,8 +3,8 @@ import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' import type SystemChangeAvatarType from '.' function SystemChangeAvatar(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) + const {ordinal, isCenteredHighlight} = p + const common = useCommon(ordinal, isCenteredHighlight) const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) if (message?.type !== 'systemChangeAvatar') return null diff --git a/shared/chat/conversation/messages/system-change-retention/wrapper.tsx b/shared/chat/conversation/messages/system-change-retention/wrapper.tsx index c8729b0fda04..608caa342a96 100644 --- a/shared/chat/conversation/messages/system-change-retention/wrapper.tsx +++ b/shared/chat/conversation/messages/system-change-retention/wrapper.tsx @@ -3,8 +3,8 @@ import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' import type SystemChangeRetentionType from './container' function SystemChangeRetention(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) + const {ordinal, isCenteredHighlight} = p + const common = useCommon(ordinal, isCenteredHighlight) const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) if (message?.type !== 'systemChangeRetention') return null diff --git a/shared/chat/conversation/messages/system-create-team/wrapper.tsx b/shared/chat/conversation/messages/system-create-team/wrapper.tsx index c3d224845552..b033fb219591 100644 --- a/shared/chat/conversation/messages/system-create-team/wrapper.tsx +++ b/shared/chat/conversation/messages/system-create-team/wrapper.tsx @@ -3,8 +3,8 @@ import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' import type SystemCreateTeamType from './container' function SystemCreateTeam(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) + const {ordinal, isCenteredHighlight} = p + const common = useCommon(ordinal, isCenteredHighlight) const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) if (message?.type !== 'systemCreateTeam') return null diff --git a/shared/chat/conversation/messages/system-git-push/wrapper.tsx b/shared/chat/conversation/messages/system-git-push/wrapper.tsx index b524726d4a32..d36d4e0fd6b8 100644 --- a/shared/chat/conversation/messages/system-git-push/wrapper.tsx +++ b/shared/chat/conversation/messages/system-git-push/wrapper.tsx @@ -3,8 +3,8 @@ import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' import type SystemGitPushType from './container' function SystemGitPush(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) + const {ordinal, isCenteredHighlight} = p + const common = useCommon(ordinal, isCenteredHighlight) const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) if (message?.type !== 'systemGitPush') return null diff --git a/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx b/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx index 4f9c3c107825..877256feeae7 100644 --- a/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx +++ b/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx @@ -3,8 +3,8 @@ import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' import type SystemInviteAcceptedType from './container' function WrapperSystemInvite(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) + const {ordinal, isCenteredHighlight} = p + const common = useCommon(ordinal, isCenteredHighlight) const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) if (message?.type !== 'systemInviteAccepted') return null diff --git a/shared/chat/conversation/messages/system-joined/wrapper.tsx b/shared/chat/conversation/messages/system-joined/wrapper.tsx index a876ce759dc1..b8704533a2a0 100644 --- a/shared/chat/conversation/messages/system-joined/wrapper.tsx +++ b/shared/chat/conversation/messages/system-joined/wrapper.tsx @@ -3,8 +3,8 @@ import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' import type SystemJoinedType from './container' function SystemJoined(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) + const {ordinal, isCenteredHighlight} = p + const common = useCommon(ordinal, isCenteredHighlight) const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) if (message?.type !== 'systemJoined') return null diff --git a/shared/chat/conversation/messages/system-left/wrapper.tsx b/shared/chat/conversation/messages/system-left/wrapper.tsx index 3af2f7a2b166..3769d78fe35e 100644 --- a/shared/chat/conversation/messages/system-left/wrapper.tsx +++ b/shared/chat/conversation/messages/system-left/wrapper.tsx @@ -3,8 +3,8 @@ import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' import type SystemLeftType from './container' function SystemLeft(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) + const {ordinal, isCenteredHighlight} = p + const common = useCommon(ordinal, isCenteredHighlight) const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) if (message?.type !== 'systemLeft') return null diff --git a/shared/chat/conversation/messages/system-new-channel/wrapper.tsx b/shared/chat/conversation/messages/system-new-channel/wrapper.tsx index 4065346fb8db..725a193d4283 100644 --- a/shared/chat/conversation/messages/system-new-channel/wrapper.tsx +++ b/shared/chat/conversation/messages/system-new-channel/wrapper.tsx @@ -3,8 +3,8 @@ import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' import type SystemNewChannelType from './container' function SystemNewChannel(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) + const {ordinal, isCenteredHighlight} = p + const common = useCommon(ordinal, isCenteredHighlight) const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) if (message?.type !== 'systemNewChannel') return null diff --git a/shared/chat/conversation/messages/system-sbs-resolve/wrapper.tsx b/shared/chat/conversation/messages/system-sbs-resolve/wrapper.tsx index 725795b526ce..4d4b3e5169c5 100644 --- a/shared/chat/conversation/messages/system-sbs-resolve/wrapper.tsx +++ b/shared/chat/conversation/messages/system-sbs-resolve/wrapper.tsx @@ -5,8 +5,8 @@ import type SystemJoinedType from '../system-joined/container' import {useCurrentUserState} from '@/stores/current-user' function WrapperSystemInvite(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) + const {ordinal, isCenteredHighlight} = p + const common = useCommon(ordinal, isCenteredHighlight) const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) const you = useCurrentUserState(s => s.username) 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 a4a0eec4f6b6..5dbeb2ec47e2 100644 --- a/shared/chat/conversation/messages/system-simple-to-complex/wrapper.tsx +++ b/shared/chat/conversation/messages/system-simple-to-complex/wrapper.tsx @@ -3,8 +3,8 @@ import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' import type SystemSimpleToComplexType from './container' function WrapperSystemSimpleToComplex(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) + const {ordinal, isCenteredHighlight} = p + const common = useCommon(ordinal, isCenteredHighlight) const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) if (message?.type !== 'systemSimpleToComplex') return null diff --git a/shared/chat/conversation/messages/system-text/wrapper.tsx b/shared/chat/conversation/messages/system-text/wrapper.tsx index f2b2afd8d106..553e74196abe 100644 --- a/shared/chat/conversation/messages/system-text/wrapper.tsx +++ b/shared/chat/conversation/messages/system-text/wrapper.tsx @@ -3,8 +3,8 @@ import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' import type SystemTextType from './container' function SystemText(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) + const {ordinal, isCenteredHighlight} = p + const common = useCommon(ordinal, isCenteredHighlight) const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) if (message?.type !== 'systemText') return null diff --git a/shared/chat/conversation/messages/system-users-added-to-conv/wrapper.tsx b/shared/chat/conversation/messages/system-users-added-to-conv/wrapper.tsx index b956af223673..e31c29b5f406 100644 --- a/shared/chat/conversation/messages/system-users-added-to-conv/wrapper.tsx +++ b/shared/chat/conversation/messages/system-users-added-to-conv/wrapper.tsx @@ -3,8 +3,8 @@ import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' import type SystemUsersAddedToConvType from './container' function SystemUsersAddedToConv(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) + const {ordinal, isCenteredHighlight} = p + const common = useCommon(ordinal, isCenteredHighlight) const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) if (message?.type !== 'systemUsersAddedToConversation') return null diff --git a/shared/chat/conversation/messages/wrapper/long-pressable/index.d.ts b/shared/chat/conversation/messages/wrapper/long-pressable/index.d.ts index 2640c86ec213..18fc12940d48 100644 --- a/shared/chat/conversation/messages/wrapper/long-pressable/index.d.ts +++ b/shared/chat/conversation/messages/wrapper/long-pressable/index.d.ts @@ -11,7 +11,6 @@ export type Props = { // desktop className?: string onContextMenu?: () => void - onMouseLeave?: () => void onMouseOver?: () => void } declare function LongPressable(props: Props & {ref?: React.Ref}): React.ReactNode diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index fa101f7bc742..28db20eafbd7 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -206,7 +206,7 @@ const getEcrType = (message: T.Chat.Message, you: string) => { // Combined selector hook that fetches all message data in a single subscription export const useMessageData = ( ordinal: T.Chat.Ordinal, - isCenteredHighlight = false + isCenteredHighlight?: boolean ) => { const you = useCurrentUserState(s => s.username) @@ -240,6 +240,14 @@ export const useMessageData = ( const textType: 'error' | 'sent' | 'pending' = m.errorReason ? 'error' : !submitState ? 'sent' : 'pending' const showReplyTo = m.type === 'text' ? !!m.replyTo : false const text = m.type === 'text' ? (m.decoratedText?.stringValue() ?? m.text.stringValue()) : '' + const {messageCenterOrdinal} = s + const showCenteredHighlight = + isCenteredHighlight ?? + !!( + messageCenterOrdinal && + messageCenterOrdinal.highlightMode !== 'none' && + messageCenterOrdinal.ordinal === ordinal + ) return { botname, @@ -254,7 +262,7 @@ export const useMessageData = ( hasUnfurlPrompts, isEditing, shouldShowPopup, - showCenteredHighlight: isCenteredHighlight, + showCenteredHighlight, showCoinsIcon, showExplodingCountdown, showReplyTo, @@ -284,8 +292,8 @@ export const useCommonWithData = (ordinal: T.Chat.Ordinal, data: ReturnType { - const data = useMessageData(ordinal) +export const useCommon = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: boolean) => { + const data = useMessageData(ordinal, isCenteredHighlight) const {type, shouldShowPopup, showCenteredHighlight} = data const shouldShow = () => { @@ -372,8 +380,6 @@ function TextAndSiblings(p: TSProps) { const {showingPopup, ecrType, exploding, hasReactions, popupAnchor} = p const {type, setShowingPicker, showCoinsIcon, shouldShowPopup} = p const {showPopup, showExplodingCountdown, showRevoked, showSendIndicator, showingPicker} = p - const [hovering, setHovering] = React.useState(false) - const showDesktopReactionsPopup = hovering || showingPicker const pressableProps = Kb.Styles.isMobile ? { onLongPress: decorate ? showPopup : undefined, @@ -387,8 +393,6 @@ function TextAndSiblings(p: TSProps) { active: showingPopup || showingPicker, }), onContextMenu: showPopup, - onMouseLeave: () => setHovering(false), - onMouseOver: () => setHovering(true), } const content = exploding ? ( @@ -412,7 +416,6 @@ function TextAndSiblings(p: TSProps) { hasReactions={hasReactions} bottomChildren={bottomChildren} canShowReactionsPopup={canShowReactionsPopup} - showDesktopReactionsPopup={showDesktopReactionsPopup} setShowingPicker={setShowingPicker} showingPopup={showingPopup} /> @@ -519,7 +522,6 @@ function EditCancelRetry(p: {ecrType: EditCancelRetryType}) { type BProps = { showingPopup: boolean - showDesktopReactionsPopup: boolean setShowingPicker: (s: boolean) => void bottomChildren?: React.ReactNode canShowReactionsPopup: boolean @@ -529,69 +531,22 @@ type BProps = { messageType: T.Chat.MessageType ecrType: EditCancelRetryType } - -function DesktopEmojiRowPopup(p: { - hasUnfurlList: boolean - messageType: T.Chat.MessageType - setShowingPicker: (s: boolean) => void -}) { - const {hasUnfurlList, messageType, setShowingPicker} = p - const popupRef = React.useRef(null) - const [position, setPosition] = React.useState<'bottom' | 'top'>('bottom') - const [measured, setMeasured] = React.useState(false) - - React.useLayoutEffect(() => { - const popupNode = popupRef.current - if (!popupNode) { - return - } - const {bottom, top} = popupNode.getBoundingClientRect() - const {clientHeight} = document.documentElement - - if (position === 'bottom' && bottom > clientHeight && top >= 0) { - setPosition('top') - return - } - if (position === 'top' && top < 0 && bottom <= clientHeight) { - setPosition('bottom') - return - } - setMeasured(true) - }, [position]) - - return ( - - - - ) -} // reactions function BottomSide(p: BProps) { - const {showingPopup, showDesktopReactionsPopup, setShowingPicker, bottomChildren, canShowReactionsPopup, ecrType, hasBeenEdited} = p + const {showingPopup, setShowingPicker, bottomChildren, canShowReactionsPopup, ecrType, hasBeenEdited} = p const {hasReactions, hasUnfurlList, messageType} = p const reactionsRow = hasReactions ? : null const canShowDesktopReactionsPopup = !C.isMobile && !hasReactions && canShowReactionsPopup const desktopReactionsPopup = - canShowDesktopReactionsPopup && showDesktopReactionsPopup && !showingPopup ? ( - ) : null @@ -694,9 +649,9 @@ function RightSide(p: RProps) { } export function WrapperMessage(p: WMProps) { - const {ordinal, isCenteredHighlight = false} = p + const {ordinal, isCenteredHighlight} = p const messageData = useMessageData(ordinal, isCenteredHighlight) - return + return } export function WrapperMessageView(p: WMProps & {messageData: ReturnType}) { @@ -792,31 +747,13 @@ const styles = Kb.Styles.styleSheetCreate( backgroundColor: Kb.Styles.globalColors.white, border: `1px solid ${Kb.Styles.globalColors.black_10}`, borderRadius: Kb.Styles.borderRadius, - paddingRight: Kb.Styles.globalMargins.xtiny, - }, - }), - emojiRowMeasuring: Kb.Styles.platformStyles({ - isElectron: { - opacity: 0, - pointerEvents: 'none', - }, - }), - emojiRowPositionBottom: Kb.Styles.platformStyles({ - isElectron: { bottom: -Kb.Styles.globalMargins.medium + 3, + paddingRight: Kb.Styles.globalMargins.xtiny, position: 'absolute', right: 96, zIndex: 2, }, }), - emojiRowPositionTop: Kb.Styles.platformStyles({ - isElectron: { - position: 'absolute', - right: 96, - top: -Kb.Styles.globalMargins.medium + 5, - zIndex: 2, - }, - }), fail: {color: Kb.Styles.globalColors.redDark}, failExploding: {color: Kb.Styles.globalColors.black_50}, failUnderline: {color: Kb.Styles.globalColors.redDark, textDecorationLine: 'underline'}, diff --git a/shared/constants/chat/message.tsx b/shared/constants/chat/message.tsx index 1ee7d91a253d..34e0284f9c22 100644 --- a/shared/constants/chat/message.tsx +++ b/shared/constants/chat/message.tsx @@ -63,6 +63,18 @@ export const isMessageWithReactions = (message: T.Chat.Message) => { !message.errorReason ) } + +export const getReactionOrder = (reactions: ReadonlyMap): Array => { + const keys = [...reactions.keys()] + const scoreMap = new Map( + keys.map(emoji => [ + emoji, + reactions.get(emoji)!.users.reduce((min, r) => Math.min(min, r.timestamp), Infinity), + ]) + ) + return keys.sort((a, b) => scoreMap.get(a)! - scoreMap.get(b)!) +} + export const getMessageID = (m: T.RPCChat.UIMessage) => { switch (m.state) { case T.RPCChat.MessageUnboxedState.valid: diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 64b2a4ab4c5f..62e2735c5e1b 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -585,17 +585,6 @@ const createSlice = return clientPrev || T.Chat.numberToMessageID(0) } - const getReactionOrder = (reactions: ReadonlyMap): Array => { - const keys = [...reactions.keys()] - const scoreMap = new Map( - keys.map(emoji => [ - emoji, - reactions.get(emoji)!.users.reduce((min, r) => Math.min(min, r.timestamp), Infinity), - ]) - ) - return keys.sort((a, b) => scoreMap.get(a)! - scoreMap.get(b)!) - } - const clearMessageIDIndexForOrdinal = ( state: Z.WritableDraft, ordinal: T.Chat.Ordinal, @@ -635,7 +624,7 @@ const createSlice = sm.set(o, p) const m = s.messageMap.get(o) if (m) um.set(o, getUsernameToShow(m, pMessage, you)) - if (m?.reactions?.size) rm.set(o, getReactionOrder(m.reactions)) + if (m?.reactions?.size) rm.set(o, Message.getReactionOrder(m.reactions)) pMessage = m as T.Chat.Message | undefined p = o } @@ -984,7 +973,7 @@ const createSlice = users: [{timestamp: Date.now(), username}], }) } - s.reactionOrderMap.set(targetOrdinal, getReactionOrder(m.reactions)) + s.reactionOrderMap.set(targetOrdinal, Message.getReactionOrder(m.reactions)) } }) } @@ -3511,7 +3500,7 @@ const createSlice = } m.reactions = T.castDraft(newReactions) } - s.reactionOrderMap.set(targetOrdinal, m.reactions ? getReactionOrder(m.reactions) : []) + s.reactionOrderMap.set(targetOrdinal, m.reactions ? Message.getReactionOrder(m.reactions) : []) } }) } diff --git a/shared/stores/tests/convostate.test.ts b/shared/stores/tests/convostate.test.ts index 36c621d1aa9a..c091220dc9a5 100644 --- a/shared/stores/tests/convostate.test.ts +++ b/shared/stores/tests/convostate.test.ts @@ -134,6 +134,26 @@ const makeMeta = (override?: Partial) => ({ ...override, }) +test('getReactionOrder sorts emojis by earliest reaction timestamp', () => { + const reactions = new Map([ + [':fire:', makeReaction('carol', 70)], + [ + ':+1:', + { + decorated: ':+1:', + users: [ + {timestamp: 50, username: 'alice'}, + {timestamp: 30, username: 'bob'}, + ], + }, + ], + [':wave:', makeReaction('bob', 60)], + [':eyes:', makeReaction('dave', 40)], + ]) + + expect(Message.getReactionOrder(reactions)).toEqual([':+1:', ':eyes:', ':wave:', ':fire:']) +}) + const applyState = ( store: {getState: () => any; setState: (state: any) => void}, partial: Partial & {messageIDToOrdinal?: ReadonlyMap} From 08e096b457964faa5b18cea722bc6bbf020ea014 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Tue, 7 Apr 2026 21:53:07 -0400 Subject: [PATCH 08/55] WIP --- .../chat/conversation/messages/wrapper/wrapper.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index 28db20eafbd7..5c40a66db41d 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -307,7 +307,7 @@ export const useCommon = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: boolean return {popup, popupAnchor, showCenteredHighlight, showPopup, showingPopup, type} } -type WMProps = { +type WrapperMessageProps = { children: React.ReactNode bottomChildren?: React.ReactNode showCenteredHighlight: boolean @@ -315,10 +315,12 @@ type WMProps = { showingPopup: boolean popup: React.ReactNode popupAnchor: React.RefObject - // Optional: if provided, avoids calling useMessageData again - messageData?: ReturnType } & Props +type WrapperMessageViewProps = WrapperMessageProps & { + messageData: ReturnType +} + const successfulInlinePaymentStatuses = ['completed', 'claimable'] const hasSuccessfulInlinePayments = ( paymentStatusMap: Chat.State['paymentStatusMap'], @@ -648,13 +650,13 @@ function RightSide(p: RProps) { ) } -export function WrapperMessage(p: WMProps) { +export function WrapperMessage(p: WrapperMessageProps) { const {ordinal, isCenteredHighlight} = p const messageData = useMessageData(ordinal, isCenteredHighlight) return } -export function WrapperMessageView(p: WMProps & {messageData: ReturnType}) { +export function WrapperMessageView(p: WrapperMessageViewProps) { const {ordinal, bottomChildren, children, messageData: mdata} = p const {showCenteredHighlight, showPopup, showingPopup, popup, popupAnchor} = p const [showingPicker, setShowingPicker] = React.useState(false) From e9f2d84bfee853adaa23b430db3b693cd3c5f321 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 8 Apr 2026 10:04:42 -0400 Subject: [PATCH 09/55] WIP --- .../messages/account-payment/wrapper.tsx | 9 +- .../messages/attachment/wrapper.tsx | 36 ++- .../conversation/messages/pin/wrapper.tsx | 9 +- .../messages/placeholder/wrapper.tsx | 20 +- .../messages/set-channelname/wrapper.tsx | 9 +- .../messages/set-description/wrapper.tsx | 9 +- .../messages/system-added-to-team/wrapper.tsx | 9 +- .../messages/system-change-avatar/wrapper.tsx | 9 +- .../system-change-retention/wrapper.tsx | 9 +- .../messages/system-create-team/wrapper.tsx | 9 +- .../messages/system-git-push/wrapper.tsx | 9 +- .../system-invite-accepted/wrapper.tsx | 9 +- .../messages/system-joined/wrapper.tsx | 9 +- .../messages/system-left/wrapper.tsx | 9 +- .../messages/system-new-channel/wrapper.tsx | 9 +- .../messages/system-sbs-resolve/wrapper.tsx | 9 +- .../system-simple-to-complex/wrapper.tsx | 9 +- .../messages/system-text/wrapper.tsx | 9 +- .../system-users-added-to-conv/wrapper.tsx | 9 +- .../conversation/messages/text/wrapper.tsx | 14 +- .../conversation/messages/wrapper/wrapper.tsx | 220 ++++++++++-------- 21 files changed, 225 insertions(+), 218 deletions(-) diff --git a/shared/chat/conversation/messages/account-payment/wrapper.tsx b/shared/chat/conversation/messages/account-payment/wrapper.tsx index 68df4ece0f16..252c8171e75f 100644 --- a/shared/chat/conversation/messages/account-payment/wrapper.tsx +++ b/shared/chat/conversation/messages/account-payment/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type PaymentMessageType from './container' function WrapperPayment(p: Props) { const {ordinal, isCenteredHighlight} = p - const common = useCommon(ordinal, isCenteredHighlight) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData if (message?.type !== 'requestPayment' && message?.type !== 'sendPayment') return null const {default: PaymentMessage} = require('./container') as {default: typeof PaymentMessageType} return ( - + ) diff --git a/shared/chat/conversation/messages/attachment/wrapper.tsx b/shared/chat/conversation/messages/attachment/wrapper.tsx index 4dddf606aee1..a605c122d5bd 100644 --- a/shared/chat/conversation/messages/attachment/wrapper.tsx +++ b/shared/chat/conversation/messages/attachment/wrapper.tsx @@ -2,56 +2,52 @@ import type AudioAttachmentType from './audio' import type FileAttachmentType from './file' import type ImageAttachmentType from './image' import type VideoAttachmentType from './video' -import {WrapperMessageView, useCommonWithData, useMessageData, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessage, type Props} from '../wrapper/wrapper' export function WrapperAttachmentAudio(p: Props) { const {ordinal, isCenteredHighlight = false} = p - const messageData = useMessageData(ordinal, isCenteredHighlight) - const common = useCommonWithData(ordinal, messageData) + const wrapper = useWrapperMessage(ordinal, isCenteredHighlight) const {default: AudioAttachment} = require('./audio') as {default: typeof AudioAttachmentType} return ( - + - + ) } export function WrapperAttachmentFile(p: Props) { const {ordinal, isCenteredHighlight = false} = p - const messageData = useMessageData(ordinal, isCenteredHighlight) - const common = useCommonWithData(ordinal, messageData) - const {showPopup} = common + const wrapper = useWrapperMessage(ordinal, isCenteredHighlight) + const {showPopup} = wrapper const {default: FileAttachment} = require('./file') as {default: typeof FileAttachmentType} return ( - + - + ) } export function WrapperAttachmentVideo(p: Props) { const {ordinal, isCenteredHighlight = false} = p - const messageData = useMessageData(ordinal, isCenteredHighlight) - const common = useCommonWithData(ordinal, messageData) - const {showPopup} = common + const wrapper = useWrapperMessage(ordinal, isCenteredHighlight) + const {showPopup} = wrapper const {default: VideoAttachment} = require('./video') as {default: typeof VideoAttachmentType} return ( - + - + ) } export function WrapperAttachmentImage(p: Props) { const {ordinal, isCenteredHighlight = false} = p - const messageData = useMessageData(ordinal, isCenteredHighlight) - const common = useCommonWithData(ordinal, messageData) - const {showPopup} = common + const wrapper = useWrapperMessage(ordinal, isCenteredHighlight) + const {showPopup} = wrapper const {default: ImageAttachment} = require('./image') as {default: typeof ImageAttachmentType} return ( - + - + ) } diff --git a/shared/chat/conversation/messages/pin/wrapper.tsx b/shared/chat/conversation/messages/pin/wrapper.tsx index 0b666fa10436..fede63d17775 100644 --- a/shared/chat/conversation/messages/pin/wrapper.tsx +++ b/shared/chat/conversation/messages/pin/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type PinType from '.' function WrapperPin(p: Props) { const {ordinal, isCenteredHighlight} = p - const common = useCommon(ordinal, isCenteredHighlight) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData if (message?.type !== 'pin') return null const {default: PinComponent} = require('.') as {default: typeof PinType} return ( - + ) diff --git a/shared/chat/conversation/messages/placeholder/wrapper.tsx b/shared/chat/conversation/messages/placeholder/wrapper.tsx index 6a8032355001..6dc026dbc805 100644 --- a/shared/chat/conversation/messages/placeholder/wrapper.tsx +++ b/shared/chat/conversation/messages/placeholder/wrapper.tsx @@ -1,25 +1,22 @@ -import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' import * as React from 'react' import * as T from '@/constants/types' -import {WrapperMessage, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessage, type Props} from '../wrapper/wrapper' import {ForceListRedrawContext} from '../../force-list-redraw-context' -const noop = () => {} - const baseWidth = Kb.Styles.isMobile ? 100 : 150 const mult = Kb.Styles.isMobile ? 5 : 10 function WrapperPlaceholder(p: Props) { - const {ordinal} = p + const {ordinal, isCenteredHighlight} = p const o = T.Chat.ordinalToNumber(ordinal) const code = o * 16807 const width = baseWidth + (code % 20) * mult // pseudo randomize the length - const noAnchor = React.useRef(null) + const wrapper = useWrapperMessage(ordinal, isCenteredHighlight) const forceListRedraw = React.useContext(ForceListRedrawContext) - const type = Chat.useChatContext(s => s.messageMap.get(ordinal)?.type) + const {type} = wrapper.messageData const [lastType, setLastType] = React.useState(type) if (lastType !== type) { @@ -30,14 +27,7 @@ function WrapperPlaceholder(p: Props) { } return ( - + diff --git a/shared/chat/conversation/messages/set-channelname/wrapper.tsx b/shared/chat/conversation/messages/set-channelname/wrapper.tsx index 66b30b55a7fa..2ce89a160d5a 100644 --- a/shared/chat/conversation/messages/set-channelname/wrapper.tsx +++ b/shared/chat/conversation/messages/set-channelname/wrapper.tsx @@ -1,18 +1,17 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SetChannelnameType from './container' function WrapperSetChannelname(p: Props) { const {ordinal, isCenteredHighlight} = p - const common = useCommon(ordinal, isCenteredHighlight) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData if (message?.type !== 'setChannelname') return null if (message.newChannelname === 'general') return null const {default: SetChannelnameComponent} = require('./container') as {default: typeof SetChannelnameType} return ( - + ) diff --git a/shared/chat/conversation/messages/set-description/wrapper.tsx b/shared/chat/conversation/messages/set-description/wrapper.tsx index 2a3aaf2f9eb0..3bca2c439b3e 100644 --- a/shared/chat/conversation/messages/set-description/wrapper.tsx +++ b/shared/chat/conversation/messages/set-description/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SetDescriptionType from './container' function WrapperSetDescription(p: Props) { const {ordinal, isCenteredHighlight} = p - const common = useCommon(ordinal, isCenteredHighlight) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData if (message?.type !== 'setDescription') return null const {default: SetDescriptionComponent} = require('./container') as {default: typeof SetDescriptionType} return ( - + ) 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 3c1fe3673499..a1d0ad05baf9 100644 --- a/shared/chat/conversation/messages/system-added-to-team/wrapper.tsx +++ b/shared/chat/conversation/messages/system-added-to-team/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemAddedToTeamType from './container' function SystemAddedToTeam(p: Props) { const {ordinal, isCenteredHighlight} = p - const common = useCommon(ordinal, isCenteredHighlight) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData if (message?.type !== 'systemAddedToTeam') return null const {default: SystemAddedToTeam} = require('./container') as {default: typeof SystemAddedToTeamType} return ( - + ) diff --git a/shared/chat/conversation/messages/system-change-avatar/wrapper.tsx b/shared/chat/conversation/messages/system-change-avatar/wrapper.tsx index 710e89068407..8e275cbff073 100644 --- a/shared/chat/conversation/messages/system-change-avatar/wrapper.tsx +++ b/shared/chat/conversation/messages/system-change-avatar/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemChangeAvatarType from '.' function SystemChangeAvatar(p: Props) { const {ordinal, isCenteredHighlight} = p - const common = useCommon(ordinal, isCenteredHighlight) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData if (message?.type !== 'systemChangeAvatar') return null const {default: SystemChangeAvatar} = require('.') as {default: typeof SystemChangeAvatarType} return ( - + ) diff --git a/shared/chat/conversation/messages/system-change-retention/wrapper.tsx b/shared/chat/conversation/messages/system-change-retention/wrapper.tsx index 608caa342a96..b2ccbd2ac6c8 100644 --- a/shared/chat/conversation/messages/system-change-retention/wrapper.tsx +++ b/shared/chat/conversation/messages/system-change-retention/wrapper.tsx @@ -1,11 +1,10 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemChangeRetentionType from './container' function SystemChangeRetention(p: Props) { const {ordinal, isCenteredHighlight} = p - const common = useCommon(ordinal, isCenteredHighlight) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData if (message?.type !== 'systemChangeRetention') return null @@ -13,7 +12,7 @@ function SystemChangeRetention(p: Props) { default: typeof SystemChangeRetentionType } return ( - + ) diff --git a/shared/chat/conversation/messages/system-create-team/wrapper.tsx b/shared/chat/conversation/messages/system-create-team/wrapper.tsx index b033fb219591..370f12563f14 100644 --- a/shared/chat/conversation/messages/system-create-team/wrapper.tsx +++ b/shared/chat/conversation/messages/system-create-team/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemCreateTeamType from './container' function SystemCreateTeam(p: Props) { const {ordinal, isCenteredHighlight} = p - const common = useCommon(ordinal, isCenteredHighlight) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData if (message?.type !== 'systemCreateTeam') return null const {default: SystemCreateTeam} = require('./container') as {default: typeof SystemCreateTeamType} return ( - + ) diff --git a/shared/chat/conversation/messages/system-git-push/wrapper.tsx b/shared/chat/conversation/messages/system-git-push/wrapper.tsx index d36d4e0fd6b8..a5c2440ae4c4 100644 --- a/shared/chat/conversation/messages/system-git-push/wrapper.tsx +++ b/shared/chat/conversation/messages/system-git-push/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemGitPushType from './container' function SystemGitPush(p: Props) { const {ordinal, isCenteredHighlight} = p - const common = useCommon(ordinal, isCenteredHighlight) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData if (message?.type !== 'systemGitPush') return null const {default: SystemGitPush} = require('./container') as {default: typeof SystemGitPushType} return ( - + ) diff --git a/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx b/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx index 877256feeae7..ede7c9f8dd0d 100644 --- a/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx +++ b/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemInviteAcceptedType from './container' function WrapperSystemInvite(p: Props) { const {ordinal, isCenteredHighlight} = p - const common = useCommon(ordinal, isCenteredHighlight) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData if (message?.type !== 'systemInviteAccepted') return null const {default: SystemInviteAccepted} = require('./container') as {default: typeof SystemInviteAcceptedType} return ( - + ) diff --git a/shared/chat/conversation/messages/system-joined/wrapper.tsx b/shared/chat/conversation/messages/system-joined/wrapper.tsx index b8704533a2a0..2cab24d38187 100644 --- a/shared/chat/conversation/messages/system-joined/wrapper.tsx +++ b/shared/chat/conversation/messages/system-joined/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemJoinedType from './container' function SystemJoined(p: Props) { const {ordinal, isCenteredHighlight} = p - const common = useCommon(ordinal, isCenteredHighlight) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData if (message?.type !== 'systemJoined') return null const {default: SystemJoined} = require('./container') as {default: typeof SystemJoinedType} return ( - + ) diff --git a/shared/chat/conversation/messages/system-left/wrapper.tsx b/shared/chat/conversation/messages/system-left/wrapper.tsx index 3769d78fe35e..e1201a8aa9fd 100644 --- a/shared/chat/conversation/messages/system-left/wrapper.tsx +++ b/shared/chat/conversation/messages/system-left/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemLeftType from './container' function SystemLeft(p: Props) { const {ordinal, isCenteredHighlight} = p - const common = useCommon(ordinal, isCenteredHighlight) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData if (message?.type !== 'systemLeft') return null const {default: SystemLeft} = require('./container') as {default: typeof SystemLeftType} return ( - + ) diff --git a/shared/chat/conversation/messages/system-new-channel/wrapper.tsx b/shared/chat/conversation/messages/system-new-channel/wrapper.tsx index 725a193d4283..56be3db49d41 100644 --- a/shared/chat/conversation/messages/system-new-channel/wrapper.tsx +++ b/shared/chat/conversation/messages/system-new-channel/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemNewChannelType from './container' function SystemNewChannel(p: Props) { const {ordinal, isCenteredHighlight} = p - const common = useCommon(ordinal, isCenteredHighlight) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData if (message?.type !== 'systemNewChannel') return null const {default: SystemNewChannel} = require('./container') as {default: typeof SystemNewChannelType} return ( - + ) diff --git a/shared/chat/conversation/messages/system-sbs-resolve/wrapper.tsx b/shared/chat/conversation/messages/system-sbs-resolve/wrapper.tsx index 4d4b3e5169c5..21715adf7e91 100644 --- a/shared/chat/conversation/messages/system-sbs-resolve/wrapper.tsx +++ b/shared/chat/conversation/messages/system-sbs-resolve/wrapper.tsx @@ -1,13 +1,12 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemSBSResolvedType from './container' import type SystemJoinedType from '../system-joined/container' import {useCurrentUserState} from '@/stores/current-user' function WrapperSystemInvite(p: Props) { const {ordinal, isCenteredHighlight} = p - const common = useCommon(ordinal, isCenteredHighlight) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData const you = useCurrentUserState(s => s.username) if (message?.type !== 'systemSBSResolved') return null @@ -25,7 +24,7 @@ function WrapperSystemInvite(p: Props) { ) return ( - + {child} ) 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 5dbeb2ec47e2..f49e0e43f84d 100644 --- a/shared/chat/conversation/messages/system-simple-to-complex/wrapper.tsx +++ b/shared/chat/conversation/messages/system-simple-to-complex/wrapper.tsx @@ -1,11 +1,10 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemSimpleToComplexType from './container' function WrapperSystemSimpleToComplex(p: Props) { const {ordinal, isCenteredHighlight} = p - const common = useCommon(ordinal, isCenteredHighlight) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData if (message?.type !== 'systemSimpleToComplex') return null @@ -14,7 +13,7 @@ function WrapperSystemSimpleToComplex(p: Props) { } return ( - + ) diff --git a/shared/chat/conversation/messages/system-text/wrapper.tsx b/shared/chat/conversation/messages/system-text/wrapper.tsx index 553e74196abe..19eec463ae1c 100644 --- a/shared/chat/conversation/messages/system-text/wrapper.tsx +++ b/shared/chat/conversation/messages/system-text/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemTextType from './container' function SystemText(p: Props) { const {ordinal, isCenteredHighlight} = p - const common = useCommon(ordinal, isCenteredHighlight) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData if (message?.type !== 'systemText') return null const {default: SystemText} = require('./container') as {default: typeof SystemTextType} return ( - + ) diff --git a/shared/chat/conversation/messages/system-users-added-to-conv/wrapper.tsx b/shared/chat/conversation/messages/system-users-added-to-conv/wrapper.tsx index e31c29b5f406..252a19bc1f67 100644 --- a/shared/chat/conversation/messages/system-users-added-to-conv/wrapper.tsx +++ b/shared/chat/conversation/messages/system-users-added-to-conv/wrapper.tsx @@ -1,11 +1,10 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemUsersAddedToConvType from './container' function SystemUsersAddedToConv(p: Props) { const {ordinal, isCenteredHighlight} = p - const common = useCommon(ordinal, isCenteredHighlight) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData if (message?.type !== 'systemUsersAddedToConversation') return null @@ -13,7 +12,7 @@ function SystemUsersAddedToConv(p: Props) { default: typeof SystemUsersAddedToConvType } return ( - + ) diff --git a/shared/chat/conversation/messages/text/wrapper.tsx b/shared/chat/conversation/messages/text/wrapper.tsx index 2996cb752cb0..eca7f0ccffd0 100644 --- a/shared/chat/conversation/messages/text/wrapper.tsx +++ b/shared/chat/conversation/messages/text/wrapper.tsx @@ -4,7 +4,7 @@ import {useReply} from './reply' import {useBottom} from './bottom' import {useOrdinal} from '../ids-context' import {SetRecycleTypeContext} from '../../recycle-type-context' -import {WrapperMessageView, useCommonWithData, useMessageData, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessage, type Props} from '../wrapper/wrapper' import type {StyleOverride} from '@/common-adapters/markdown' import {sharedStyles} from '../shared-styles' @@ -47,12 +47,12 @@ function MessageMarkdown({style, text}: {style: Kb.Styles.StylesCrossPlatform; t function WrapperText(p: Props) { const {ordinal, isCenteredHighlight = false} = p - const messageData = useMessageData(ordinal, isCenteredHighlight) - const common = useCommonWithData(ordinal, messageData) - const {type, showCenteredHighlight} = common + const wrapper = useWrapperMessage(ordinal, isCenteredHighlight) + const {messageData} = wrapper const {isEditing, hasReactions} = messageData - const {hasCoinFlip, hasUnfurlList, hasUnfurlPrompts, textType, showReplyTo, text} = messageData + const {hasCoinFlip, hasUnfurlList, hasUnfurlPrompts, showCenteredHighlight, text, textType, showReplyTo, type} = + messageData const bottomChildren = useBottom({hasCoinFlip, hasUnfurlList, hasUnfurlPrompts}) const reply = useReply(showReplyTo) @@ -87,9 +87,9 @@ function WrapperText(p: Props) { } return ( - + {children} - + ) } diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index 5c40a66db41d..2fdc5ddfaf7f 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -203,82 +203,136 @@ const getEcrType = (message: T.Chat.Message, you: string) => { return EditCancelRetryType.RETRY_CANCEL } -// Combined selector hook that fetches all message data in a single subscription -export const useMessageData = ( - ordinal: T.Chat.Ordinal, +const getCommonMessageData = ({ + accountsInfoMap, + editing, + isCenteredHighlight, + message, + messageCenterOrdinal, + ordinal, + paymentStatusMap, + unfurlPrompt, + you, +}: { + accountsInfoMap: Chat.State['accountsInfoMap'] + editing: Chat.State['editing'] isCenteredHighlight?: boolean -) => { + message: T.Chat.Message + messageCenterOrdinal: Chat.State['messageCenterOrdinal'] + ordinal: T.Chat.Ordinal + paymentStatusMap: Chat.State['paymentStatusMap'] + unfurlPrompt: Chat.State['unfurlPrompt'] + you: string +}) => { + const {exploded, submitState, author, id, botUsername} = message + const type = message.type + const idMatchesOrdinal = T.Chat.ordinalToNumber(message.ordinal) === T.Chat.messageIDToNumber(id) + const exploding = !!message.exploding + const decorate = !exploded && !message.errorReason + const isShowingUploadProgressBar = + you === author && message.type === 'attachment' && message.inlineVideoPlayable + const showSendIndicator = + !!submitState && !exploded && you === author && !idMatchesOrdinal && !isShowingUploadProgressBar + const showRevoked = !!message.deviceRevokedAt + const showExplodingCountdown = !!exploding && !exploded && submitState !== 'failed' + const showCoinsIcon = hasSuccessfulInlinePayments(paymentStatusMap, message) + const hasReactions = (message.reactions?.size ?? 0) > 0 + const botname = botUsername === author ? '' : (botUsername ?? '') + const canShowReactionsPopup = Chat.isMessageWithReactions(message) + const ecrType = getEcrType(message, you) + const shouldShowPopup = Chat.shouldShowPopup(accountsInfoMap, message) + const hasBeenEdited = message.hasBeenEdited ?? false + const hasCoinFlip = message.type === 'text' && !!message.flipGameID + const hasUnfurlList = (message.unfurls?.size ?? 0) > 0 + const hasUnfurlPrompts = !!id && !!unfurlPrompt.get(id)?.size + const textType: 'error' | 'sent' | 'pending' = message.errorReason ? 'error' : !submitState ? 'sent' : 'pending' + const showReplyTo = message.type === 'text' ? !!message.replyTo : false + const text = + message.type === 'text' ? (message.decoratedText?.stringValue() ?? message.text.stringValue()) : '' + const showCenteredHighlight = + isCenteredHighlight ?? + !!( + messageCenterOrdinal && + messageCenterOrdinal.highlightMode !== 'none' && + messageCenterOrdinal.ordinal === ordinal + ) + + return { + botname, + canShowReactionsPopup, + decorate, + ecrType, + exploding, + hasBeenEdited, + hasCoinFlip, + hasReactions, + hasUnfurlList, + hasUnfurlPrompts, + isEditing: editing === ordinal, + shouldShowPopup, + showCenteredHighlight, + showCoinsIcon, + showExplodingCountdown, + showReplyTo, + showRevoked, + showSendIndicator, + text, + textType, + type, + } +} + +// Combined selector hook that fetches all common wrapper data in a single subscription. +export const useMessageData = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: boolean) => { const you = useCurrentUserState(s => s.username) return Chat.useChatContext( C.useShallow(s => { - const accountsInfoMap = s.accountsInfoMap - const m = s.messageMap.get(ordinal) ?? missingMessage - const isEditing = s.editing === ordinal - const {exploded, submitState, author, id, botUsername} = m - const type = m.type - const idMatchesOrdinal = T.Chat.ordinalToNumber(m.ordinal) === T.Chat.messageIDToNumber(id) - const exploding = !!m.exploding - const decorate = !exploded && !m.errorReason - const isShowingUploadProgressBar = you === author && m.type === 'attachment' && m.inlineVideoPlayable - const showSendIndicator = - !!submitState && !exploded && you === author && !idMatchesOrdinal && !isShowingUploadProgressBar - const showRevoked = !!m.deviceRevokedAt - const showExplodingCountdown = !!exploding && !exploded && submitState !== 'failed' - const paymentStatusMap = Chat.useChatState.getState().paymentStatusMap - const showCoinsIcon = hasSuccessfulInlinePayments(paymentStatusMap, m) - const hasReactions = (m.reactions?.size ?? 0) > 0 - const botname = botUsername === author ? '' : (botUsername ?? '') - const canShowReactionsPopup = Chat.isMessageWithReactions(m) - const ecrType = getEcrType(m, you) - const shouldShowPopup = Chat.shouldShowPopup(accountsInfoMap, m) - // Fields lifted from child components to consolidate subscriptions - const hasBeenEdited = m.hasBeenEdited ?? false - const hasCoinFlip = m.type === 'text' && !!m.flipGameID - const hasUnfurlList = (m.unfurls?.size ?? 0) > 0 - const hasUnfurlPrompts = !!id && !!s.unfurlPrompt.get(id)?.size - const textType: 'error' | 'sent' | 'pending' = m.errorReason ? 'error' : !submitState ? 'sent' : 'pending' - const showReplyTo = m.type === 'text' ? !!m.replyTo : false - const text = m.type === 'text' ? (m.decoratedText?.stringValue() ?? m.text.stringValue()) : '' - const {messageCenterOrdinal} = s - const showCenteredHighlight = - isCenteredHighlight ?? - !!( - messageCenterOrdinal && - messageCenterOrdinal.highlightMode !== 'none' && - messageCenterOrdinal.ordinal === ordinal - ) + const message = s.messageMap.get(ordinal) ?? missingMessage + return getCommonMessageData({ + accountsInfoMap: s.accountsInfoMap, + editing: s.editing, + isCenteredHighlight, + message, + messageCenterOrdinal: s.messageCenterOrdinal, + ordinal, + paymentStatusMap: Chat.useChatState.getState().paymentStatusMap, + unfurlPrompt: s.unfurlPrompt, + you, + }) + }) + ) +} + +const useMessageDataWithMessage = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: boolean) => { + const you = useCurrentUserState(s => s.username) + return Chat.useChatContext( + C.useShallow(s => { + const message = s.messageMap.get(ordinal) ?? missingMessage return { - botname, - canShowReactionsPopup, - decorate, - ecrType, - exploding, - hasBeenEdited, - hasCoinFlip, - hasReactions, - hasUnfurlList, - hasUnfurlPrompts, - isEditing, - shouldShowPopup, - showCenteredHighlight, - showCoinsIcon, - showExplodingCountdown, - showReplyTo, - showRevoked, - showSendIndicator, - text, - textType, - type, + ...getCommonMessageData({ + accountsInfoMap: s.accountsInfoMap, + editing: s.editing, + isCenteredHighlight, + message, + messageCenterOrdinal: s.messageCenterOrdinal, + ordinal, + paymentStatusMap: Chat.useChatState.getState().paymentStatusMap, + unfurlPrompt: s.unfurlPrompt, + you, + }), + message, } }) ) } -// Version that accepts pre-fetched data to avoid duplicate selector calls -export const useCommonWithData = (ordinal: T.Chat.Ordinal, data: ReturnType) => { - const {type, shouldShowPopup, showCenteredHighlight} = data +const useWrapperPopup = ( + ordinal: T.Chat.Ordinal, + data: Pick, 'shouldShowPopup' | 'type'> +) => { + const {type, shouldShowPopup} = data const shouldShow = () => { return messageShowsPopup(type) && shouldShowPopup @@ -288,38 +342,28 @@ export const useCommonWithData = (ordinal: T.Chat.Ordinal, data: ReturnType { - const data = useMessageData(ordinal, isCenteredHighlight) - const {type, shouldShowPopup, showCenteredHighlight} = data +export const useWrapperMessage = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: boolean) => { + const messageData = useMessageData(ordinal, isCenteredHighlight) + return {...useWrapperPopup(ordinal, messageData), messageData} +} - const shouldShow = () => { - return messageShowsPopup(type) && shouldShowPopup - } - const {showPopup, showingPopup, popup, popupAnchor} = useMessagePopup({ - ordinal, - shouldShow, - style: styles.messagePopupContainer, - }) - return {popup, popupAnchor, showCenteredHighlight, showPopup, showingPopup, type} +export const useWrapperMessageWithMessage = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: boolean) => { + const messageData = useMessageDataWithMessage(ordinal, isCenteredHighlight) + return {...useWrapperPopup(ordinal, messageData), messageData} } type WrapperMessageProps = { children: React.ReactNode bottomChildren?: React.ReactNode - showCenteredHighlight: boolean showPopup: () => void showingPopup: boolean popup: React.ReactNode popupAnchor: React.RefObject -} & Props - -type WrapperMessageViewProps = WrapperMessageProps & { messageData: ReturnType -} +} & Props const successfulInlinePaymentStatuses = ['completed', 'claimable'] const hasSuccessfulInlinePayments = ( @@ -651,19 +695,13 @@ function RightSide(p: RProps) { } export function WrapperMessage(p: WrapperMessageProps) { - const {ordinal, isCenteredHighlight} = p - const messageData = useMessageData(ordinal, isCenteredHighlight) - return -} - -export function WrapperMessageView(p: WrapperMessageViewProps) { const {ordinal, bottomChildren, children, messageData: mdata} = p - const {showCenteredHighlight, showPopup, showingPopup, popup, popupAnchor} = p + const {showPopup, showingPopup, popup, popupAnchor} = p const [showingPicker, setShowingPicker] = React.useState(false) const {decorate, type, hasReactions, isEditing, shouldShowPopup} = mdata const {canShowReactionsPopup, ecrType, showSendIndicator, showRevoked, showExplodingCountdown, exploding} = mdata - const {showCoinsIcon, botname, hasBeenEdited, hasUnfurlList} = mdata + const {showCoinsIcon, botname, hasBeenEdited, hasUnfurlList, showCenteredHighlight} = mdata const isHighlighted = showCenteredHighlight || isEditing const tsprops = { From ae335ffbf43d6f7df79c848e947779051dcef458 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 8 Apr 2026 10:16:30 -0400 Subject: [PATCH 10/55] WIP --- PLAN.md | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 000000000000..9c60ca6d5596 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,121 @@ +# Chat Message Perf Cleanup Plan + +## Goal + +Reduce chat conversation mount cost, cut per-row Zustand subscription fan-out, and remove render thrash in the message list without changing behavior. + +## Constraints + +- Preserve existing chat behavior and platform-specific handling. +- Prefer small, reviewable patches with one clear ownership boundary each. +- This machine does not have `node_modules` for this repo, so this plan assumes pure code work unless validation happens elsewhere. + +## Working Rules + +- Use one clean context per workstream below. +- Do not mix store-shape changes and row rendering changes in the same patch unless one directly unblocks the other. +- Keep desktop and native paths aligned unless there is a platform-specific reason not to. +- Treat each workstream as independently landable where possible. + +## Workstreams + +### 1. Row Renderer Boundary + +- [ ] Introduce a single row entry point that takes `ordinal` and resolves render type inside the row. +- [ ] Remove list-level render dispatch from `messageTypeMap` where possible. +- [ ] Delete the native `extraData` / `forceListRedraw` placeholder escape hatch if the new row boundary makes it unnecessary. +- [ ] Keep placeholder-to-real-message transitions stable on both native and desktop. + +Primary files: + +- `shared/chat/conversation/list-area/index.native.tsx` +- `shared/chat/conversation/list-area/index.desktop.tsx` +- `shared/chat/conversation/messages/wrapper/index.tsx` +- `shared/chat/conversation/messages/placeholder/wrapper.tsx` + +### 2. Incremental Derived Message Metadata + +- [ ] Stop rebuilding whole-thread derived maps on every `messagesAdd`. +- [ ] Update separator, username-grouping, and reaction-order metadata only for changed ordinals and any affected neighbors. +- [ ] Avoid rebuilding and resorting `messageOrdinals` unless thread membership actually changed. +- [ ] Re-evaluate whether some derived metadata should live in store state at all. + +Primary files: + +- `shared/stores/convostate.tsx` +- `shared/chat/conversation/messages/separator.tsx` +- `shared/chat/conversation/messages/reactions-rows.tsx` + +### 3. Row Subscription Consolidation + +- [ ] Move toward one main convo-store subscription per mounted row. +- [ ] Push row data down as props instead of reopening store subscriptions in reply, reactions, emoji, send-indicator, exploding-meta, and similar children. +- [ ] Audit attachment and unfurl helpers for repeated `messageMap.get(ordinal)` selectors. +- [ ] Keep selectors narrow and stable when a child still needs to subscribe directly. + +Primary files: + +- `shared/chat/conversation/messages/wrapper/wrapper.tsx` +- `shared/chat/conversation/messages/text/wrapper.tsx` +- `shared/chat/conversation/messages/text/reply.tsx` +- `shared/chat/conversation/messages/reactions-rows.tsx` +- `shared/chat/conversation/messages/emoji-row.tsx` +- `shared/chat/conversation/messages/wrapper/send-indicator.tsx` +- `shared/chat/conversation/messages/wrapper/exploding-meta.tsx` + +### 4. Split Volatile UI State From Message Data + +- [ ] Inventory convo-store fields that are transient UI state rather than message graph state. +- [ ] Move route-local or composer-local state out of the main convo message store. +- [ ] Keep dispatch call sites readable and avoid direct component store mutation. +- [ ] Minimize unrelated selector recalculation when typing/search/composer state changes. + +Primary files: + +- `shared/stores/convostate.tsx` +- `shared/chat/conversation/*` + +### 5. List Data Stability And Recycling + +- [ ] Remove avoidable array cloning / reversing in the hottest list path. +- [ ] Replace effect-driven recycle subtype reporting with data available before or during row render. +- [ ] Re-check list item type stability after workstreams 1 and 3 land. +- [ ] Keep scroll position and centered-message behavior unchanged. + +Primary files: + +- `shared/chat/conversation/list-area/index.native.tsx` +- `shared/chat/conversation/messages/text/wrapper.tsx` +- `shared/chat/conversation/recycle-type-context.tsx` + +### 6. Measurement And Regression Guardrails + +- [ ] Add or improve lightweight profiling hooks where they help compare before/after behavior. +- [ ] Define a manual verification checklist for initial thread mount, new incoming message, placeholder resolution, reactions, edits, and centered jumps. +- [ ] Capture follow-up profiling notes after each landed workstream. + +Primary files: + +- `shared/chat/conversation/list-area/index.native.tsx` +- `shared/chat/conversation/list-area/index.desktop.tsx` +- `shared/perf/*` + +## Recommended Order + +1. Workstream 1: Row Renderer Boundary +2. Workstream 2: Incremental Derived Message Metadata +3. Workstream 3: Row Subscription Consolidation +4. Workstream 4: Split Volatile UI State From Message Data +5. Workstream 5: List Data Stability And Recycling +6. Workstream 6: Measurement And Regression Guardrails + +## Clean Context Prompts + +Use these as narrow follow-up task starts: + +1. "Implement Workstream 1 from `PLAN.md`: introduce a row-level renderer boundary and remove the native placeholder redraw hack." +2. "Implement Workstream 2 from `PLAN.md`: make convo-store derived message metadata incremental instead of full-thread recompute." +3. "Implement Workstream 3 from `PLAN.md`: consolidate message row subscriptions so row children mostly receive props instead of subscribing directly." +4. "Implement Workstream 4 from `PLAN.md`: split volatile convo UI state from message graph state." +5. "Implement Workstream 5 from `PLAN.md`: stabilize list data and recycling after the earlier refactors." +6. "Implement Workstream 6 from `PLAN.md`: add measurement hooks and a regression checklist for the chat message perf cleanup." From f7006f0a195dce6b80f9ce439db10b6784dcf23b Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 8 Apr 2026 10:21:14 -0400 Subject: [PATCH 11/55] WIP --- .../force-list-redraw-context.tsx | 2 - .../conversation/list-area/index.desktop.tsx | 24 ++------- .../conversation/list-area/index.native.tsx | 51 ++++--------------- .../messages/placeholder/wrapper.tsx | 14 ----- .../conversation/messages/wrapper/index.tsx | 22 ++++++++ 5 files changed, 36 insertions(+), 77 deletions(-) delete mode 100644 shared/chat/conversation/force-list-redraw-context.tsx diff --git a/shared/chat/conversation/force-list-redraw-context.tsx b/shared/chat/conversation/force-list-redraw-context.tsx deleted file mode 100644 index 4a5c4b59848a..000000000000 --- a/shared/chat/conversation/force-list-redraw-context.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import * as React from 'react' -export const ForceListRedrawContext = React.createContext(() => {}) diff --git a/shared/chat/conversation/list-area/index.desktop.tsx b/shared/chat/conversation/list-area/index.desktop.tsx index 6eba39f88e94..0f376a7146ad 100644 --- a/shared/chat/conversation/list-area/index.desktop.tsx +++ b/shared/chat/conversation/list-area/index.desktop.tsx @@ -10,11 +10,9 @@ import SpecialBottomMessage from '../messages/special-bottom-message' import SpecialTopMessage from '../messages/special-top-message' import chunk from 'lodash/chunk' import {findLast} from '@/util/arrays' -import {getMessageRender} from '../messages/wrapper' +import {MessageRow} from '../messages/wrapper' import {globalMargins} from '@/styles/shared' import {FocusContext, ScrollContext} from '../normal/context' -import {chatDebugEnabled} from '@/constants/chat/debug' -import logger from '@/logger' import shallowEqual from '@/util/shallow-equal' import useResizeObserver from '@/util/use-resize-observer.desktop' import useIntersectionObserver from '@/util/use-intersection-observer' @@ -370,20 +368,10 @@ const useItems = (p: { messageOrdinals: ReadonlyArray centeredOrdinal: T.Chat.Ordinal | undefined editingOrdinal: T.Chat.Ordinal | undefined - messageTypeMap: ReadonlyMap | undefined }) => { - const {messageTypeMap, messageOrdinals, centeredHighlightOrdinal, centeredOrdinal, editingOrdinal} = p + const {messageOrdinals, centeredHighlightOrdinal, centeredOrdinal, editingOrdinal} = p const ordinalsInAWaypoint = 10 const rowRenderer = (ordinal: T.Chat.Ordinal) => { - const type = messageTypeMap?.get(ordinal) ?? 'text' - const Clazz = getMessageRender(type) - if (!Clazz) { - if (chatDebugEnabled) { - logger.error('[CHATDEBUG] no rendertype', {Clazz, ordinal, type}) - } - return null - } - return (
- @@ -489,7 +477,7 @@ const noOrdinals = new Array() const ThreadWrapper = function ThreadWrapper() { const data = Chat.useChatContext( C.useShallow(s => { - const {messageTypeMap, editing: editingOrdinal, id: conversationIDKey} = s + const {editing: editingOrdinal, id: conversationIDKey} = s const {messageCenterOrdinal: mco, messageOrdinals = noOrdinals, loaded} = s const centeredHighlightOrdinal = mco && mco.highlightMode !== 'none' ? mco.ordinal : undefined const centeredOrdinal = mco?.ordinal @@ -502,12 +490,11 @@ const ThreadWrapper = function ThreadWrapper() { editingOrdinal, loaded, messageOrdinals, - messageTypeMap, } }) ) const {conversationIDKey, editingOrdinal, centeredHighlightOrdinal, centeredOrdinal} = data - const {containsLatestMessage, messageOrdinals, loaded, messageTypeMap} = data + const {containsLatestMessage, messageOrdinals, loaded} = data const copyToClipboard = useConfigState(s => s.dispatch.defer.copyToClipboard) const listRef = React.useRef(null) const _setListRef = (r: HTMLDivElement | null) => { @@ -572,7 +559,6 @@ const ThreadWrapper = function ThreadWrapper() { centeredOrdinal, editingOrdinal, messageOrdinals, - messageTypeMap, }) const setListContents = useHandleListResize({ centeredOrdinal, diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 3cdadfd4a6cb..40faf05349cc 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -9,10 +9,9 @@ import SpecialTopMessage from '../messages/special-top-message' import type {ItemType} from '.' import {FlatList} from 'react-native' // import {FlashList, type ListRenderItemInfo} from '@shopify/flash-list' -import {getMessageRender} from '../messages/wrapper' +import {MessageRow} from '../messages/wrapper' import {mobileTypingContainerHeight} from '../input-area/normal/typing' import {SetRecycleTypeContext} from '../recycle-type-context' -import {ForceListRedrawContext} from '../force-list-redraw-context' // import {useChatDebugDump} from '@/constants/chat/debug' import {usingFlashList} from './flashlist-config' import {PerfProfiler} from '@/perf/react-profiler' @@ -99,10 +98,6 @@ const ConversationList = function ConversationList() { const conversationIDKey = Chat.useChatContext(s => s.id) - // used to force a rerender when a type changes, aka placeholder resolves - const [extraData, setExtraData] = React.useState(0) - const [lastED, setLastED] = React.useState(extraData) - const loaded = Chat.useChatContext(s => s.loaded) const messageCenterOrdinal = Chat.useChatContext(s => s.messageCenterOrdinal) const centeredHighlightOrdinal = @@ -127,26 +122,15 @@ const ConversationList = function ConversationList() { if (!ordinal) { return null } - const type = messageTypeMap.get(ordinal) ?? 'text' - const Clazz = getMessageRender(type) - if (!Clazz) return null return ( - - - + ) } const recycleTypeRef = React.useRef(new Map()) - React.useEffect(() => { - if (lastED !== extraData) { - recycleTypeRef.current = new Map() - setLastED(extraData) - } - }, [extraData, lastED]) const setRecycleType = (ordinal: T.Chat.Ordinal, type: string) => { recycleTypeRef.current.set(ordinal, type) } @@ -221,28 +205,15 @@ const ConversationList = function ConversationList() { } }, [loaded, centeredOrdinal, scrollToBottom, scrollToCentered, numOrdinals]) - // We use context to inject a way for items to force the list to rerender when they notice something about their - // internals have changed (aka a placeholder isn't a placeholder anymore). This can be racy as if you detect this - // and call you can get effectively memoized. In order to allow the item to re-render if they're still in this state - // we make this callback mutate, so they have a chance to rerender and recall it - // A repro is a placeholder resolving as a placeholder multiple times before resolving for real - const forceListRedraw = () => { - extraData // just to silence eslint - // wrap in timeout so we don't get max update depths sometimes - setTimeout(() => { - setExtraData(d => d + 1) - }, 100) - } - // useChatDebugDump( // 'listArea', // C.useEvent(() => { // if (!listRef.current) return '' // const {props, state} = listRef.current as { - // props: {extraData?: {}; data?: [number]} + // props: {data?: [number]} // state?: object // } - // const {extraData, data} = props + // const {data} = props // // // const layoutManager = (state?.layoutProvider?._lastLayoutManager ?? ({} as unknown)) as { // // _layouts?: [unknown] @@ -274,7 +245,6 @@ const ConversationList = function ConversationList() { // _totalHeight, // _totalWidth, // data, - // extraData, // items, // } // return JSON.stringify(details) @@ -287,13 +257,11 @@ const ConversationList = function ConversationList() { return ( - - + - - + ) diff --git a/shared/chat/conversation/messages/placeholder/wrapper.tsx b/shared/chat/conversation/messages/placeholder/wrapper.tsx index 6dc026dbc805..27a3010c2d49 100644 --- a/shared/chat/conversation/messages/placeholder/wrapper.tsx +++ b/shared/chat/conversation/messages/placeholder/wrapper.tsx @@ -1,8 +1,6 @@ import * as Kb from '@/common-adapters' -import * as React from 'react' import * as T from '@/constants/types' import {WrapperMessage, useWrapperMessage, type Props} from '../wrapper/wrapper' -import {ForceListRedrawContext} from '../../force-list-redraw-context' const baseWidth = Kb.Styles.isMobile ? 100 : 150 const mult = Kb.Styles.isMobile ? 5 : 10 @@ -14,18 +12,6 @@ function WrapperPlaceholder(p: Props) { const width = baseWidth + (code % 20) * mult // pseudo randomize the length const wrapper = useWrapperMessage(ordinal, isCenteredHighlight) - const forceListRedraw = React.useContext(ForceListRedrawContext) - - const {type} = wrapper.messageData - const [lastType, setLastType] = React.useState(type) - - if (lastType !== type) { - setLastType(type) - if (type !== 'placeholder') { - forceListRedraw() - } - } - return ( diff --git a/shared/chat/conversation/messages/wrapper/index.tsx b/shared/chat/conversation/messages/wrapper/index.tsx index 111a337d0fe2..55417d190aed 100644 --- a/shared/chat/conversation/messages/wrapper/index.tsx +++ b/shared/chat/conversation/messages/wrapper/index.tsx @@ -1,3 +1,8 @@ +import * as Chat from '@/stores/chat' +import * as React from 'react' +import {chatDebugEnabled} from '@/constants/chat/debug' +import logger from '@/logger' +import {PerfProfiler} from '@/perf/react-profiler' import Text from '../text/wrapper' import { WrapperAttachmentAudio, @@ -61,3 +66,20 @@ const typeMap = { export const getMessageRender = (type: T.Chat.RenderMessageType) => { return type === 'deleted' ? undefined : typeMap[type] } + +export const MessageRow = function MessageRow(p: Props) { + const {ordinal} = p + const type = Chat.useChatContext(s => s.messageTypeMap.get(ordinal) ?? 'text') + const Clazz = getMessageRender(type) + if (!Clazz) { + if (chatDebugEnabled) { + logger.error('[CHATDEBUG] no rendertype', {ordinal, type}) + } + return null + } + return ( + + + + ) +} From 260cc3eb06408a5b7e756697ad388dfec4331db9 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 8 Apr 2026 10:29:32 -0400 Subject: [PATCH 12/55] WIP --- .../messages/account-payment/wrapper.tsx | 2 +- .../conversation/messages/pin/wrapper.tsx | 2 +- .../messages/set-channelname/wrapper.tsx | 2 +- .../messages/set-description/wrapper.tsx | 2 +- .../messages/system-added-to-team/wrapper.tsx | 2 +- .../messages/system-change-avatar/wrapper.tsx | 2 +- .../system-change-retention/wrapper.tsx | 2 +- .../messages/system-create-team/wrapper.tsx | 2 +- .../messages/system-git-push/wrapper.tsx | 2 +- .../system-invite-accepted/wrapper.tsx | 2 +- .../messages/system-joined/wrapper.tsx | 2 +- .../messages/system-left/wrapper.tsx | 2 +- .../messages/system-new-channel/wrapper.tsx | 2 +- .../messages/system-sbs-resolve/wrapper.tsx | 2 +- .../system-simple-to-complex/wrapper.tsx | 2 +- .../messages/system-text/wrapper.tsx | 2 +- .../system-users-added-to-conv/wrapper.tsx | 2 +- .../conversation/messages/wrapper/index.tsx | 100 +++++++++++------- .../conversation/messages/wrapper/wrapper.tsx | 11 +- 19 files changed, 86 insertions(+), 59 deletions(-) diff --git a/shared/chat/conversation/messages/account-payment/wrapper.tsx b/shared/chat/conversation/messages/account-payment/wrapper.tsx index 252c8171e75f..48d0475555f7 100644 --- a/shared/chat/conversation/messages/account-payment/wrapper.tsx +++ b/shared/chat/conversation/messages/account-payment/wrapper.tsx @@ -6,7 +6,7 @@ function WrapperPayment(p: Props) { const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) const {message} = wrapper.messageData - if (message?.type !== 'requestPayment' && message?.type !== 'sendPayment') return null + if (message.type !== 'requestPayment' && message.type !== 'sendPayment') return null const {default: PaymentMessage} = require('./container') as {default: typeof PaymentMessageType} return ( diff --git a/shared/chat/conversation/messages/pin/wrapper.tsx b/shared/chat/conversation/messages/pin/wrapper.tsx index fede63d17775..3db3f5d8e752 100644 --- a/shared/chat/conversation/messages/pin/wrapper.tsx +++ b/shared/chat/conversation/messages/pin/wrapper.tsx @@ -6,7 +6,7 @@ function WrapperPin(p: Props) { const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) const {message} = wrapper.messageData - if (message?.type !== 'pin') return null + if (message.type !== 'pin') return null const {default: PinComponent} = require('.') as {default: typeof PinType} return ( diff --git a/shared/chat/conversation/messages/set-channelname/wrapper.tsx b/shared/chat/conversation/messages/set-channelname/wrapper.tsx index 2ce89a160d5a..aeeb105a307f 100644 --- a/shared/chat/conversation/messages/set-channelname/wrapper.tsx +++ b/shared/chat/conversation/messages/set-channelname/wrapper.tsx @@ -6,7 +6,7 @@ function WrapperSetChannelname(p: Props) { const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) const {message} = wrapper.messageData - if (message?.type !== 'setChannelname') return null + if (message.type !== 'setChannelname') return null if (message.newChannelname === 'general') return null const {default: SetChannelnameComponent} = require('./container') as {default: typeof SetChannelnameType} diff --git a/shared/chat/conversation/messages/set-description/wrapper.tsx b/shared/chat/conversation/messages/set-description/wrapper.tsx index 3bca2c439b3e..839da23862ba 100644 --- a/shared/chat/conversation/messages/set-description/wrapper.tsx +++ b/shared/chat/conversation/messages/set-description/wrapper.tsx @@ -6,7 +6,7 @@ function WrapperSetDescription(p: Props) { const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) const {message} = wrapper.messageData - if (message?.type !== 'setDescription') return null + if (message.type !== 'setDescription') return null const {default: SetDescriptionComponent} = require('./container') as {default: typeof SetDescriptionType} return ( 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 a1d0ad05baf9..21e45e4a38c5 100644 --- a/shared/chat/conversation/messages/system-added-to-team/wrapper.tsx +++ b/shared/chat/conversation/messages/system-added-to-team/wrapper.tsx @@ -6,7 +6,7 @@ function SystemAddedToTeam(p: Props) { const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) const {message} = wrapper.messageData - if (message?.type !== 'systemAddedToTeam') return null + if (message.type !== 'systemAddedToTeam') return null const {default: SystemAddedToTeam} = require('./container') as {default: typeof SystemAddedToTeamType} return ( diff --git a/shared/chat/conversation/messages/system-change-avatar/wrapper.tsx b/shared/chat/conversation/messages/system-change-avatar/wrapper.tsx index 8e275cbff073..a4ffc44d12d5 100644 --- a/shared/chat/conversation/messages/system-change-avatar/wrapper.tsx +++ b/shared/chat/conversation/messages/system-change-avatar/wrapper.tsx @@ -6,7 +6,7 @@ function SystemChangeAvatar(p: Props) { const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) const {message} = wrapper.messageData - if (message?.type !== 'systemChangeAvatar') return null + if (message.type !== 'systemChangeAvatar') return null const {default: SystemChangeAvatar} = require('.') as {default: typeof SystemChangeAvatarType} return ( diff --git a/shared/chat/conversation/messages/system-change-retention/wrapper.tsx b/shared/chat/conversation/messages/system-change-retention/wrapper.tsx index b2ccbd2ac6c8..119448a72710 100644 --- a/shared/chat/conversation/messages/system-change-retention/wrapper.tsx +++ b/shared/chat/conversation/messages/system-change-retention/wrapper.tsx @@ -6,7 +6,7 @@ function SystemChangeRetention(p: Props) { const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) const {message} = wrapper.messageData - if (message?.type !== 'systemChangeRetention') return null + if (message.type !== 'systemChangeRetention') return null const {default: SystemChangeRetention} = require('./container') as { default: typeof SystemChangeRetentionType diff --git a/shared/chat/conversation/messages/system-create-team/wrapper.tsx b/shared/chat/conversation/messages/system-create-team/wrapper.tsx index 370f12563f14..12f31a815751 100644 --- a/shared/chat/conversation/messages/system-create-team/wrapper.tsx +++ b/shared/chat/conversation/messages/system-create-team/wrapper.tsx @@ -6,7 +6,7 @@ function SystemCreateTeam(p: Props) { const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) const {message} = wrapper.messageData - if (message?.type !== 'systemCreateTeam') return null + if (message.type !== 'systemCreateTeam') return null const {default: SystemCreateTeam} = require('./container') as {default: typeof SystemCreateTeamType} return ( diff --git a/shared/chat/conversation/messages/system-git-push/wrapper.tsx b/shared/chat/conversation/messages/system-git-push/wrapper.tsx index a5c2440ae4c4..b44d49840b27 100644 --- a/shared/chat/conversation/messages/system-git-push/wrapper.tsx +++ b/shared/chat/conversation/messages/system-git-push/wrapper.tsx @@ -6,7 +6,7 @@ function SystemGitPush(p: Props) { const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) const {message} = wrapper.messageData - if (message?.type !== 'systemGitPush') return null + if (message.type !== 'systemGitPush') return null const {default: SystemGitPush} = require('./container') as {default: typeof SystemGitPushType} return ( diff --git a/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx b/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx index ede7c9f8dd0d..73ca0a66fd3a 100644 --- a/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx +++ b/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx @@ -6,7 +6,7 @@ function WrapperSystemInvite(p: Props) { const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) const {message} = wrapper.messageData - if (message?.type !== 'systemInviteAccepted') return null + if (message.type !== 'systemInviteAccepted') return null const {default: SystemInviteAccepted} = require('./container') as {default: typeof SystemInviteAcceptedType} return ( diff --git a/shared/chat/conversation/messages/system-joined/wrapper.tsx b/shared/chat/conversation/messages/system-joined/wrapper.tsx index 2cab24d38187..8d8d4d25e8dc 100644 --- a/shared/chat/conversation/messages/system-joined/wrapper.tsx +++ b/shared/chat/conversation/messages/system-joined/wrapper.tsx @@ -6,7 +6,7 @@ function SystemJoined(p: Props) { const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) const {message} = wrapper.messageData - if (message?.type !== 'systemJoined') return null + if (message.type !== 'systemJoined') return null const {default: SystemJoined} = require('./container') as {default: typeof SystemJoinedType} return ( diff --git a/shared/chat/conversation/messages/system-left/wrapper.tsx b/shared/chat/conversation/messages/system-left/wrapper.tsx index e1201a8aa9fd..a7aa5ab48e9d 100644 --- a/shared/chat/conversation/messages/system-left/wrapper.tsx +++ b/shared/chat/conversation/messages/system-left/wrapper.tsx @@ -6,7 +6,7 @@ function SystemLeft(p: Props) { const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) const {message} = wrapper.messageData - if (message?.type !== 'systemLeft') return null + if (message.type !== 'systemLeft') return null const {default: SystemLeft} = require('./container') as {default: typeof SystemLeftType} return ( diff --git a/shared/chat/conversation/messages/system-new-channel/wrapper.tsx b/shared/chat/conversation/messages/system-new-channel/wrapper.tsx index 56be3db49d41..18e81fa51629 100644 --- a/shared/chat/conversation/messages/system-new-channel/wrapper.tsx +++ b/shared/chat/conversation/messages/system-new-channel/wrapper.tsx @@ -6,7 +6,7 @@ function SystemNewChannel(p: Props) { const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) const {message} = wrapper.messageData - if (message?.type !== 'systemNewChannel') return null + if (message.type !== 'systemNewChannel') return null const {default: SystemNewChannel} = require('./container') as {default: typeof SystemNewChannelType} return ( diff --git a/shared/chat/conversation/messages/system-sbs-resolve/wrapper.tsx b/shared/chat/conversation/messages/system-sbs-resolve/wrapper.tsx index 21715adf7e91..ee70c18983ad 100644 --- a/shared/chat/conversation/messages/system-sbs-resolve/wrapper.tsx +++ b/shared/chat/conversation/messages/system-sbs-resolve/wrapper.tsx @@ -9,7 +9,7 @@ function WrapperSystemInvite(p: Props) { const {message} = wrapper.messageData const you = useCurrentUserState(s => s.username) - if (message?.type !== 'systemSBSResolved') return null + if (message.type !== 'systemSBSResolved') return null const youAreAuthor = you === message.author const {default: SystemSBSResolved} = require('./container') as {default: typeof SystemSBSResolvedType} 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 f49e0e43f84d..b249ba3410f2 100644 --- a/shared/chat/conversation/messages/system-simple-to-complex/wrapper.tsx +++ b/shared/chat/conversation/messages/system-simple-to-complex/wrapper.tsx @@ -6,7 +6,7 @@ function WrapperSystemSimpleToComplex(p: Props) { const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) const {message} = wrapper.messageData - if (message?.type !== 'systemSimpleToComplex') return null + if (message.type !== 'systemSimpleToComplex') return null const {default: SystemSimpleToComplex} = require('./container') as { default: typeof SystemSimpleToComplexType diff --git a/shared/chat/conversation/messages/system-text/wrapper.tsx b/shared/chat/conversation/messages/system-text/wrapper.tsx index 19eec463ae1c..b08f7d222238 100644 --- a/shared/chat/conversation/messages/system-text/wrapper.tsx +++ b/shared/chat/conversation/messages/system-text/wrapper.tsx @@ -6,7 +6,7 @@ function SystemText(p: Props) { const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) const {message} = wrapper.messageData - if (message?.type !== 'systemText') return null + if (message.type !== 'systemText') return null const {default: SystemText} = require('./container') as {default: typeof SystemTextType} return ( diff --git a/shared/chat/conversation/messages/system-users-added-to-conv/wrapper.tsx b/shared/chat/conversation/messages/system-users-added-to-conv/wrapper.tsx index 252a19bc1f67..e87786c8b0fd 100644 --- a/shared/chat/conversation/messages/system-users-added-to-conv/wrapper.tsx +++ b/shared/chat/conversation/messages/system-users-added-to-conv/wrapper.tsx @@ -6,7 +6,7 @@ function SystemUsersAddedToConv(p: Props) { const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) const {message} = wrapper.messageData - if (message?.type !== 'systemUsersAddedToConversation') return null + if (message.type !== 'systemUsersAddedToConversation') return null const {default: SystemUsersAddedToConv} = require('./container') as { default: typeof SystemUsersAddedToConvType diff --git a/shared/chat/conversation/messages/wrapper/index.tsx b/shared/chat/conversation/messages/wrapper/index.tsx index 55417d190aed..16d8e237ea13 100644 --- a/shared/chat/conversation/messages/wrapper/index.tsx +++ b/shared/chat/conversation/messages/wrapper/index.tsx @@ -1,8 +1,8 @@ import * as Chat from '@/stores/chat' -import * as React from 'react' import {chatDebugEnabled} from '@/constants/chat/debug' import logger from '@/logger' import {PerfProfiler} from '@/perf/react-profiler' +import type * as React from 'react' import Text from '../text/wrapper' import { WrapperAttachmentAudio, @@ -32,46 +32,72 @@ import SetChannelname from '../set-channelname/wrapper' import {type Props} from './wrapper' import type * as T from '@/constants/types' -const typeMap = { - 'attachment:audio': WrapperAttachmentAudio, - 'attachment:file': WrapperAttachmentFile, - 'attachment:image': WrapperAttachmentImage, - 'attachment:video': WrapperAttachmentVideo, - journeycard: JourneyCard, - pin: Pin, - placeholder: Placeholder, - requestPayment: Payment, - sendPayment: Payment, - setChannelname: SetChannelname, - setDescription: SetDescription, - systemAddedToTeam: SystemAddedToTeam, - systemChangeAvatar: SystemChangeAvatar, - systemChangeRetention: SystemChangeRetention, - systemCreateTeam: SystemCreateTeam, - systemGitPush: SystemGitPush, - systemInviteAccepted: SystemInviteAccepted, - systemJoined: SystemJoined, - systemLeft: SystemLeft, - systemNewChannel: SystemNewChannel, - systemSBSResolved: SystemSBSResolved, - systemSimpleToComplex: SystemSimpleToComplex, - systemText: SystemText, - systemUsersAddedToConversation: SystemUsersAddedToConv, - text: Text, -} satisfies Partial>> as unknown as Record< - T.Chat.RenderMessageType, - React.ComponentType | undefined -> - -export const getMessageRender = (type: T.Chat.RenderMessageType) => { - return type === 'deleted' ? undefined : typeMap[type] +const renderMessageRow = (type: T.Chat.RenderMessageType, p: Props): React.ReactNode => { + switch (type) { + case 'attachment:audio': + return + case 'attachment:file': + return + case 'attachment:image': + return + case 'attachment:video': + return + case 'journeycard': + return + case 'pin': + return + case 'placeholder': + return + case 'requestPayment': + case 'sendPayment': + return + case 'setChannelname': + return + case 'setDescription': + return + case 'systemAddedToTeam': + return + case 'systemChangeAvatar': + return + case 'systemChangeRetention': + return + case 'systemCreateTeam': + return + case 'systemGitPush': + return + case 'systemInviteAccepted': + return + case 'systemJoined': + return + case 'systemLeft': + return + case 'systemNewChannel': + return + case 'systemSBSResolved': + return + case 'systemSimpleToComplex': + return + case 'systemText': + return + case 'systemUsersAddedToConversation': + return + case 'text': + return + case 'deleted': + return null + default: + return null + } } export const MessageRow = function MessageRow(p: Props) { const {ordinal} = p const type = Chat.useChatContext(s => s.messageTypeMap.get(ordinal) ?? 'text') - const Clazz = getMessageRender(type) - if (!Clazz) { + const content = renderMessageRow(type, p) + if (!content) { + if (type === 'deleted') { + return null + } if (chatDebugEnabled) { logger.error('[CHATDEBUG] no rendertype', {ordinal, type}) } @@ -79,7 +105,7 @@ export const MessageRow = function MessageRow(p: Props) { } return ( - + {content} ) } diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index 2fdc5ddfaf7f..c9edbff378ba 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -18,6 +18,7 @@ import {useTeamsState} from '@/stores/teams' import {useTrackerState} from '@/stores/tracker' import {navToProfile} from '@/constants/router' import {formatTimeForChat} from '@/util/timestamp' +import type {ConvoState} from '@/stores/convostate' export type Props = { isCenteredHighlight?: boolean @@ -214,14 +215,14 @@ const getCommonMessageData = ({ unfurlPrompt, you, }: { - accountsInfoMap: Chat.State['accountsInfoMap'] - editing: Chat.State['editing'] + accountsInfoMap: ConvoState['accountsInfoMap'] + editing: ConvoState['editing'] isCenteredHighlight?: boolean message: T.Chat.Message - messageCenterOrdinal: Chat.State['messageCenterOrdinal'] + messageCenterOrdinal: ConvoState['messageCenterOrdinal'] ordinal: T.Chat.Ordinal - paymentStatusMap: Chat.State['paymentStatusMap'] - unfurlPrompt: Chat.State['unfurlPrompt'] + paymentStatusMap: ReturnType['paymentStatusMap'] + unfurlPrompt: ConvoState['unfurlPrompt'] you: string }) => { const {exploded, submitState, author, id, botUsername} = message From 9275d73b2dde9abad19b0a6d0722365a3c8fe534 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 8 Apr 2026 11:25:20 -0400 Subject: [PATCH 13/55] WIP --- AGENTS.md | 1 + PLAN.md | 15 ++-- shared/stores/convostate.tsx | 94 ++++++++++++++++++++------ shared/stores/tests/convostate.test.ts | 59 ++++++++++++++++ 4 files changed, 141 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eae73a9a0022..f9254958aee9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,3 +12,4 @@ - Components must not mutate Zustand stores directly with `useXState.setState`, `getState()`-based writes, or similar ad hoc store mutation. If a component needs to affect store state, route it through a store dispatch action or move the state out of the store. - During refactors, do not delete existing guards, conditionals, or platform/test-specific behavior unless you have proven they are dead and the user asked for that behavior change. Port checks like `androidIsTestDevice` forward into the new code path instead of silently dropping them. - When addressing PR or review feedback, including bot or lint-style suggestions, do not apply it mechanically. Verify that the reported issue is real in this codebase and that the proposed fix is consistent with repo rules and improves correctness, behavior, or maintainability before making changes. +- When working from a repo plan or checklist such as `PLAN.md`, update the checklist in the same change and mark implemented items done before you finish. diff --git a/PLAN.md b/PLAN.md index 9c60ca6d5596..e502260704e0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -16,15 +16,16 @@ Reduce chat conversation mount cost, cut per-row Zustand subscription fan-out, a - Do not mix store-shape changes and row rendering changes in the same patch unless one directly unblocks the other. - Keep desktop and native paths aligned unless there is a platform-specific reason not to. - Treat each workstream as independently landable where possible. +- When a checklist item is implemented, update this plan in the same change and mark that item done. ## Workstreams ### 1. Row Renderer Boundary -- [ ] Introduce a single row entry point that takes `ordinal` and resolves render type inside the row. -- [ ] Remove list-level render dispatch from `messageTypeMap` where possible. -- [ ] Delete the native `extraData` / `forceListRedraw` placeholder escape hatch if the new row boundary makes it unnecessary. -- [ ] Keep placeholder-to-real-message transitions stable on both native and desktop. +- [x] Introduce a single row entry point that takes `ordinal` and resolves render type inside the row. +- [x] Remove list-level render dispatch from `messageTypeMap` where possible. +- [x] Delete the native `extraData` / `forceListRedraw` placeholder escape hatch if the new row boundary makes it unnecessary. +- [x] Keep placeholder-to-real-message transitions stable on both native and desktop. Primary files: @@ -35,9 +36,9 @@ Primary files: ### 2. Incremental Derived Message Metadata -- [ ] Stop rebuilding whole-thread derived maps on every `messagesAdd`. -- [ ] Update separator, username-grouping, and reaction-order metadata only for changed ordinals and any affected neighbors. -- [ ] Avoid rebuilding and resorting `messageOrdinals` unless thread membership actually changed. +- [x] Stop rebuilding whole-thread derived maps on every `messagesAdd`. +- [x] Update separator, username-grouping, and reaction-order metadata only for changed ordinals and any affected neighbors. +- [x] Avoid rebuilding and resorting `messageOrdinals` unless thread membership actually changed. - [ ] Re-evaluate whether some derived metadata should live in store state at all. Primary files: diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 62e2735c5e1b..1f86c44e26ad 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -612,25 +612,68 @@ const createSlice = ) => messageIDToOrdinal(state.messageMap, state.pendingOutboxToOrdinal, messageID, state.messageIDToOrdinal) - const syncSeparatorMap = (s: Z.WritableDraft) => { + const findOrdinalIndex = (ordinals: ReadonlyArray, ordinal: T.Chat.Ordinal) => { + let low = 0 + let high = ordinals.length + while (low < high) { + const mid = Math.floor((low + high) / 2) + if (ordinals[mid]! < ordinal) { + low = mid + 1 + } else { + high = mid + } + } + return low + } + + const refreshDerivedMetadata = ( + s: Z.WritableDraft, + changedOrdinals: ReadonlySet + ) => { + if (changedOrdinals.size === 0) { + return + } + + const messageOrdinals = s.messageOrdinals ?? [] const you = useCurrentUserState.getState().username - const mo = s.messageOrdinals ?? [] - const sm = new Map() - const um = new Map() - const rm = new Map>() - let p = T.Chat.numberToOrdinal(0) - let pMessage: T.Chat.Message | undefined = undefined - for (const o of mo) { - sm.set(o, p) - const m = s.messageMap.get(o) - if (m) um.set(o, getUsernameToShow(m, pMessage, you)) - if (m?.reactions?.size) rm.set(o, Message.getReactionOrder(m.reactions)) - pMessage = m as T.Chat.Message | undefined - p = o + const ordinalsToRefresh = new Set(changedOrdinals) + + for (const ordinal of changedOrdinals) { + const idx = findOrdinalIndex(messageOrdinals, ordinal) + const maybeCurrent = messageOrdinals[idx] + const nextOrdinal = maybeCurrent === ordinal ? messageOrdinals[idx + 1] : maybeCurrent + if (nextOrdinal !== undefined) { + ordinalsToRefresh.add(nextOrdinal) + } + } + + for (const ordinal of ordinalsToRefresh) { + const idx = findOrdinalIndex(messageOrdinals, ordinal) + if (messageOrdinals[idx] !== ordinal) { + s.separatorMap.delete(ordinal) + s.showUsernameMap.delete(ordinal) + s.reactionOrderMap.delete(ordinal) + continue + } + + const previousOrdinal = idx > 0 ? messageOrdinals[idx - 1]! : T.Chat.numberToOrdinal(0) + const message = s.messageMap.get(ordinal) + if (!message) { + s.separatorMap.delete(ordinal) + s.showUsernameMap.delete(ordinal) + s.reactionOrderMap.delete(ordinal) + continue + } + + s.separatorMap.set(ordinal, previousOrdinal) + const previousMessage = idx > 0 ? s.messageMap.get(previousOrdinal) : undefined + s.showUsernameMap.set(ordinal, getUsernameToShow(message, previousMessage, you)) + if (message.reactions?.size) { + s.reactionOrderMap.set(ordinal, Message.getReactionOrder(message.reactions)) + } else { + s.reactionOrderMap.delete(ordinal) + } } - s.separatorMap = sm - s.showUsernameMap = um - s.reactionOrderMap = rm } const mergeMessage = ( @@ -720,6 +763,7 @@ const createSlice = set(s => { // Build set of incoming regular ordinals for ordinal management const incomingOrdinals = new Set() + const touchedOrdinals = new Set() for (const m of messages) { if (m.conversationMessage !== false && m.type !== 'deleted') { incomingOrdinals.add(m.ordinal) @@ -731,6 +775,7 @@ const createSlice = const regularMessage = m.conversationMessage !== false if (regularMessage && m.type === 'deleted') { + touchedOrdinals.add(m.ordinal) clearMessageIDIndexForOrdinal(s, m.ordinal) s.messageMap.delete(m.ordinal) s.messageTypeMap.delete(m.ordinal) @@ -762,6 +807,10 @@ const createSlice = m.ordinal = mapOrdinal } + if (regularMessage) { + touchedOrdinals.add(mapOrdinal) + } + const existingMsg = s.messageMap.get(mapOrdinal) if (existingMsg?.type === m.type) { if (existingMsg.id && existingMsg.id !== m.id) { @@ -818,6 +867,7 @@ const createSlice = if (validatedRange) { for (const o of existing) { if (o >= validatedRange.from && o <= validatedRange.to && !incomingOrdinals.has(o)) { + touchedOrdinals.add(o) clearMessageIDIndexForOrdinal(s, o) existing.delete(o) s.messageMap.delete(o) @@ -840,7 +890,7 @@ const createSlice = s.messageOrdinals = [...existing].sort((a, b) => a - b) } - syncSeparatorMap(s) + refreshDerivedMetadata(s, touchedOrdinals) }) if (markAsRead) { @@ -1188,7 +1238,7 @@ const createSlice = if (s.messageOrdinals) { s.messageOrdinals = s.messageOrdinals.filter(o => o !== toDelOrdinal) } - syncSeparatorMap(s) + refreshDerivedMetadata(s, new Set([toDelOrdinal])) }) } @@ -2323,8 +2373,10 @@ const createSlice = s.messageMap.clear() s.messageOrdinals = undefined s.messageTypeMap.clear() + s.reactionOrderMap.clear() + s.separatorMap.clear() + s.showUsernameMap.clear() s.validatedOrdinalRange = undefined - syncSeparatorMap(s) }) }, messagesExploded: (messageIDs, explodedBy) => { @@ -2385,7 +2437,7 @@ const createSlice = if (s.messageOrdinals) { s.messageOrdinals = s.messageOrdinals.filter(o => !allOrdinals.has(o)) } - syncSeparatorMap(s) + refreshDerivedMetadata(s, allOrdinals) }) }, mute: m => { diff --git a/shared/stores/tests/convostate.test.ts b/shared/stores/tests/convostate.test.ts index c091220dc9a5..c8062a11a20f 100644 --- a/shared/stores/tests/convostate.test.ts +++ b/shared/stores/tests/convostate.test.ts @@ -272,6 +272,37 @@ test('onMessagesUpdated adds messages and recomputes derived thread maps', () => expect(store.getState().messageTypeMap.size).toBe(0) }) +test('message updates refresh derived metadata for the following row', () => { + const firstOrdinal = T.Chat.numberToOrdinal(301) + const secondOrdinal = T.Chat.numberToOrdinal(302) + const firstMsgID = T.Chat.numberToMessageID(301) + const store = seedStore([ + makeTextMessage({ + author: 'bob', + id: firstMsgID, + ordinal: firstOrdinal, + outboxID: T.Chat.stringToOutboxID('first'), + timestamp: 100, + }), + makeTextMessage({ + author: 'bob', + id: T.Chat.numberToMessageID(302), + ordinal: secondOrdinal, + outboxID: T.Chat.stringToOutboxID('second'), + timestamp: 101, + }), + ]) + + store.getState().dispatch.onMessagesUpdated({ + convID: T.Chat.keyToConversationID(convID), + updates: [makeValidTextUIMessage(firstMsgID, 'edited first', {author: 'alice', timestamp: 100})], + }) + + expect(store.getState().showUsernameMap.get(firstOrdinal)).toBe('alice') + expect(store.getState().showUsernameMap.get(secondOrdinal)).toBe('bob') + expect(store.getState().separatorMap.get(secondOrdinal)).toBe(firstOrdinal) +}) + test('reaction updates preserve outbox-anchored row identity', () => { const store = seedStoreWithAnchoredMessage() const reactions = new Map([[':+1:', makeReaction('bob', 5)]]) @@ -350,6 +381,34 @@ test('message deletion removes the row but preserves the outbox anchor', () => { expect(store.getState().messageIDToOrdinal.has(msgID)).toBe(false) }) +test('message deletion refreshes derived metadata for the next row', () => { + const firstOrdinal = T.Chat.numberToOrdinal(401) + const secondOrdinal = T.Chat.numberToOrdinal(402) + const store = seedStore([ + makeTextMessage({ + author: 'bob', + id: T.Chat.numberToMessageID(401), + ordinal: firstOrdinal, + outboxID: T.Chat.stringToOutboxID('first-delete'), + timestamp: 100, + }), + makeTextMessage({ + author: 'bob', + id: T.Chat.numberToMessageID(402), + ordinal: secondOrdinal, + outboxID: T.Chat.stringToOutboxID('second-delete'), + timestamp: 101, + }), + ]) + + store.getState().dispatch.messagesWereDeleted({ordinals: [firstOrdinal]}) + + expect(store.getState().messageOrdinals).toEqual([secondOrdinal]) + expect(store.getState().separatorMap.has(firstOrdinal)).toBe(false) + expect(store.getState().showUsernameMap.get(secondOrdinal)).toBe('bob') + expect(store.getState().separatorMap.get(secondOrdinal)).toBe(T.Chat.numberToOrdinal(0)) +}) + test('message deletion up to a message ID honors deletable message types', () => { const earlyText = makeTextMessage({ id: T.Chat.numberToMessageID(501), From 2ba43f9c002e2e72a85e3dcab39e2a8513de1f62 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 8 Apr 2026 11:42:09 -0400 Subject: [PATCH 14/55] WIP --- PLAN.md | 2 +- .../chat/conversation/messages/emoji-row.tsx | 40 ++---- .../conversation/messages/react-button.tsx | 20 ++- .../conversation/messages/reactions-rows.tsx | 51 +++---- .../chat/conversation/messages/text/reply.tsx | 25 +--- .../conversation/messages/text/wrapper.tsx | 10 +- .../exploding-height-retainer/container.tsx | 40 ------ .../messages/wrapper/exploding-meta.tsx | 41 ++---- .../messages/wrapper/send-indicator.tsx | 30 ++-- .../conversation/messages/wrapper/wrapper.tsx | 135 ++++++++++++++++-- 10 files changed, 216 insertions(+), 178 deletions(-) delete mode 100644 shared/chat/conversation/messages/wrapper/exploding-height-retainer/container.tsx diff --git a/PLAN.md b/PLAN.md index e502260704e0..9eff46b27340 100644 --- a/PLAN.md +++ b/PLAN.md @@ -50,7 +50,7 @@ Primary files: ### 3. Row Subscription Consolidation - [ ] Move toward one main convo-store subscription per mounted row. -- [ ] Push row data down as props instead of reopening store subscriptions in reply, reactions, emoji, send-indicator, exploding-meta, and similar children. +- [x] Push row data down as props instead of reopening store subscriptions in reply, reactions, emoji, send-indicator, exploding-meta, and similar children. - [ ] Audit attachment and unfurl helpers for repeated `messageMap.get(ordinal)` selectors. - [ ] Keep selectors narrow and stable when a child still needs to subscribe directly. diff --git a/shared/chat/conversation/messages/emoji-row.tsx b/shared/chat/conversation/messages/emoji-row.tsx index 32afc9b17ae1..3073e1bcdebc 100644 --- a/shared/chat/conversation/messages/emoji-row.tsx +++ b/shared/chat/conversation/messages/emoji-row.tsx @@ -8,36 +8,19 @@ import {EmojiPickerDesktop} from '@/chat/emoji-picker/container' type OwnProps = { className?: string - hasUnfurls?: boolean - messageType?: T.Chat.MessageType + hasUnfurls: boolean + messageType: T.Chat.MessageType + onReact?: (emoji: string) => void + onReply?: () => void onShowingEmojiPicker?: (arg0: boolean) => void style?: Kb.Styles.StylesCrossPlatform } function EmojiRowContainer(p: OwnProps) { - const {className, onShowingEmojiPicker, style} = p + const {className, hasUnfurls, messageType, onReact: onReactProp, onReply: onReplyProp, onShowingEmojiPicker, style} = p const ordinal = useOrdinal() - - const {setReplyTo, toggleMessageReaction, type: subscriptionType, hasUnfurls: subscriptionHasUnfurls} = - Chat.useChatContext( - C.useShallow(s => { - const {toggleMessageReaction, setReplyTo} = s.dispatch - // When both are provided as props, skip message map lookup (constant return = no re-renders) - if (p.messageType !== undefined && p.hasUnfurls !== undefined) { - return {hasUnfurls: false as boolean, setReplyTo, toggleMessageReaction, type: null as T.Chat.MessageType | null} - } - const m = s.messageMap.get(ordinal) - return { - hasUnfurls: p.hasUnfurls !== undefined ? false : (m?.unfurls?.size ?? 0) > 0, - setReplyTo, - toggleMessageReaction, - type: p.messageType !== undefined ? null : (m?.type ?? null), - } - }) - ) - const type = p.messageType ?? subscriptionType ?? undefined - const hasUnfurls = p.hasUnfurls ?? subscriptionHasUnfurls - + const setReplyTo = Chat.useChatContext(s => s.dispatch.setReplyTo) + const toggleMessageReaction = Chat.useChatContext(s => s.dispatch.toggleMessageReaction) const emojis = Chat.useChatState(C.useShallow(s => s.userReacjis.topReacjis.slice(0, 5))) const navigateAppend = Chat.useChatNavigateAppend() const _onForward = () => { @@ -47,14 +30,19 @@ function EmojiRowContainer(p: OwnProps) { })) } const onReact = (emoji: string) => { + if (onReactProp) { + onReactProp(emoji) + return + } toggleMessageReaction(ordinal, emoji) } const _onReply = () => { setReplyTo(ordinal) } - const onForward = hasUnfurls || type === 'attachment' ? _onForward : undefined - const onReply = type === 'text' || type === 'attachment' ? _onReply : undefined + const onForward = hasUnfurls || messageType === 'attachment' ? _onForward : undefined + const onReply = + messageType === 'text' || messageType === 'attachment' ? (onReplyProp ?? _onReply) : undefined const [showingPicker, setShowingPicker] = React.useState(false) const popupAnchor = React.useRef(null) diff --git a/shared/chat/conversation/messages/react-button.tsx b/shared/chat/conversation/messages/react-button.tsx index 5c60f958a98f..912b5de43a0d 100644 --- a/shared/chat/conversation/messages/react-button.tsx +++ b/shared/chat/conversation/messages/react-button.tsx @@ -7,22 +7,28 @@ import type {StyleOverride} from '@/common-adapters/markdown' import {colors, darkColors} from '@/styles/colors' import {useColorScheme} from 'react-native' import {useCurrentUserState} from '@/stores/current-user' +import type * as T from '@/constants/types' export type OwnProps = { className?: string emoji?: string onLongPress?: () => void + reaction?: T.Chat.ReactionDesc showBorder?: boolean style?: StylesCrossPlatform + toggleReaction?: (emoji: string) => void } function ReactButtonContainer(p: OwnProps) { const ordinal = useOrdinal() - const {onLongPress, style, emoji, className} = p + const {onLongPress, style, emoji, className, reaction} = p const me = useCurrentUserState(s => s.username) const isDarkMode = useColorScheme() === 'dark' - const {active, count, decorated} = Chat.useChatContext( + const {active: subscriptionActive, count: subscriptionCount, decorated: subscriptionDecorated} = Chat.useChatContext( C.useShallow(s => { + if (reaction || !emoji) { + return {active: false, count: 0, decorated: ''} + } const message = s.messageMap.get(ordinal) const reaction = message?.reactions?.get(emoji || '') const active = (reaction?.users ?? []).some(r => r.username === me) @@ -35,8 +41,16 @@ function ReactButtonContainer(p: OwnProps) { ) const toggleMessageReaction = Chat.useChatContext(s => s.dispatch.toggleMessageReaction) + const active = reaction ? reaction.users.some(r => r.username === me) : subscriptionActive + const count = reaction?.users.length ?? subscriptionCount + const decorated = reaction?.decorated ?? subscriptionDecorated const onClick = () => { - toggleMessageReaction(ordinal, emoji || '') + if (!emoji) return + if (p.toggleReaction) { + p.toggleReaction(emoji) + return + } + toggleMessageReaction(ordinal, emoji) } const navigateAppend = Chat.useChatNavigateAppend() const onOpenEmojiPicker = () => { diff --git a/shared/chat/conversation/messages/reactions-rows.tsx b/shared/chat/conversation/messages/reactions-rows.tsx index 16cc85a3c417..b8cbaa050518 100644 --- a/shared/chat/conversation/messages/reactions-rows.tsx +++ b/shared/chat/conversation/messages/reactions-rows.tsx @@ -1,6 +1,4 @@ -import * as C from '@/constants' import * as Message from '@/constants/chat/message' -import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' import * as React from 'react' import EmojiRow from './emoji-row' @@ -12,50 +10,51 @@ import {Keyboard} from 'react-native' const emptyEmojis: ReadonlyArray = [] -function ReactionsRowContainer() { - const ordinal = useOrdinal() - const emojis = Chat.useChatContext( - C.useShallow(s => { - const fromMap = s.reactionOrderMap.get(ordinal) - if (fromMap?.length) return fromMap - const reactions = s.messageMap.get(ordinal)?.reactions - return reactions?.size ? Message.getReactionOrder(reactions) : emptyEmojis - }) - ) +type OwnProps = { + hasUnfurls: boolean + messageType: T.Chat.MessageType + onReact: (emoji: string) => void + onReply: () => void + reactionOrder?: ReadonlyArray + reactions?: T.Chat.Reactions +} + +function ReactionsRowContainer(p: OwnProps) { + const {hasUnfurls, messageType, onReact, onReply, reactionOrder, reactions} = p + const emojis = reactionOrder?.length ? reactionOrder : reactions?.size ? Message.getReactionOrder(reactions) : emptyEmojis return emojis.length === 0 ? null : ( {emojis.map((emoji, idx) => ( - + ))} {Kb.Styles.isMobile ? ( ) : ( - + )} ) } -export type Props = { - activeEmoji: string - emojis: Array - ordinal: T.Chat.Ordinal - setActiveEmoji: (s: string) => void - setHideMobileTooltip: () => void - setShowMobileTooltip: () => void - showMobileTooltip: boolean -} - const btnClassName = 'WrapperMessage-emojiButton' const newBtnClassName = 'WrapperMessage-newEmojiButton' type IProps = { emoji: string + onReact: (emoji: string) => void + reaction?: T.Chat.ReactionDesc } function RowItem(p: IProps) { const ordinal = useOrdinal() - const {emoji} = p + const {emoji, onReact, reaction} = p const popupAnchor = React.useRef(null) const [showingPopup, setShowingPopup] = React.useState(false) @@ -84,7 +83,9 @@ function RowItem(p: IProps) { className={btnClassName} emoji={emoji} onLongPress={Kb.Styles.isMobile ? showPopup : undefined} + reaction={reaction} style={styles.button} + toggleReaction={onReact} /> {popup} diff --git a/shared/chat/conversation/messages/text/reply.tsx b/shared/chat/conversation/messages/text/reply.tsx index 1de357127833..aeee837955cd 100644 --- a/shared/chat/conversation/messages/text/reply.tsx +++ b/shared/chat/conversation/messages/text/reply.tsx @@ -1,12 +1,11 @@ -import * as C from '@/constants' -import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' import * as React from 'react' -import {useOrdinal, useIsHighlighted} from '../ids-context' +import * as Chat from '@/stores/chat' +import {useIsHighlighted} from '../ids-context' import type * as T from '@/constants/types' -export const useReply = (showReplyTo: boolean) => { - return showReplyTo ? : null +export const useReply = (replyTo?: T.Chat.MessageReplyTo, onClick?: () => void) => { + return replyTo ? : null } const ReplyToContext = React.createContext(null!) @@ -75,7 +74,7 @@ type RS = { showImage: boolean showEdited: boolean isDeleted: boolean - onClick: () => void + onClick?: () => void } function ReplyStructure(p: RS) { @@ -118,19 +117,7 @@ function ReplyStructure(p: RS) { ) } -function Reply() { - const ordinal = useOrdinal() - const {replyTo, replyJump} = Chat.useChatContext( - C.useShallow(s => { - const m = s.messageMap.get(ordinal) - return {replyJump: s.dispatch.replyJump, replyTo: m?.type === 'text' ? m.replyTo : undefined} - }) - ) - const onClick = () => { - const id = replyTo?.id ?? 0 - id && replyJump(id) - } - +function Reply({replyTo, onClick}: {onClick?: () => void; replyTo: T.Chat.MessageReplyTo}) { if (!replyTo?.id) return null const showEdited = !!replyTo.hasBeenEdited diff --git a/shared/chat/conversation/messages/text/wrapper.tsx b/shared/chat/conversation/messages/text/wrapper.tsx index eca7f0ccffd0..1975f6562f64 100644 --- a/shared/chat/conversation/messages/text/wrapper.tsx +++ b/shared/chat/conversation/messages/text/wrapper.tsx @@ -1,3 +1,4 @@ +import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' import * as React from 'react' import {useReply} from './reply' @@ -49,12 +50,17 @@ function WrapperText(p: Props) { const {ordinal, isCenteredHighlight = false} = p const wrapper = useWrapperMessage(ordinal, isCenteredHighlight) const {messageData} = wrapper - const {isEditing, hasReactions} = messageData + const {isEditing, hasReactions, replyTo} = messageData const {hasCoinFlip, hasUnfurlList, hasUnfurlPrompts, showCenteredHighlight, text, textType, showReplyTo, type} = messageData const bottomChildren = useBottom({hasCoinFlip, hasUnfurlList, hasUnfurlPrompts}) - const reply = useReply(showReplyTo) + const replyJump = Chat.useChatContext(s => s.dispatch.replyJump) + const onReplyClick = () => { + const id = replyTo?.id ?? 0 + id && replyJump(id) + } + const reply = useReply(replyTo, onReplyClick) const setRecycleType = React.useContext(SetRecycleTypeContext) diff --git a/shared/chat/conversation/messages/wrapper/exploding-height-retainer/container.tsx b/shared/chat/conversation/messages/wrapper/exploding-height-retainer/container.tsx deleted file mode 100644 index febd6e851b91..000000000000 --- a/shared/chat/conversation/messages/wrapper/exploding-height-retainer/container.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import * as C from '@/constants' -import * as Chat from '@/stores/chat' -import type * as React from 'react' -import ExplodingHeightRetainer from '.' -import {useOrdinal} from '../../ids-context' - -type OwnProps = { - children: React.ReactElement -} - -function ExplodingHeightRetainerContainer(p: OwnProps) { - const ordinal = useOrdinal() - const {children} = p - const {forceAsh, exploding, exploded, explodedBy, messageKey} = Chat.useChatContext( - C.useShallow(s => { - const m = s.messageMap.get(ordinal) - const forceAsh = !!m?.explodingUnreadable - const exploding = !!m?.exploding - const exploded = !!m?.exploded - const explodedBy = m?.explodedBy - const messageKey = m ? Chat.getMessageKey(m) : '' - return {exploded, explodedBy, exploding, forceAsh, messageKey} - }) - ) - - const retainHeight = forceAsh || exploded - - const props = { - children, - exploded, - explodedBy, - exploding, - messageKey, - retainHeight, - } - - return -} - -export default ExplodingHeightRetainerContainer diff --git a/shared/chat/conversation/messages/wrapper/exploding-meta.tsx b/shared/chat/conversation/messages/wrapper/exploding-meta.tsx index 9ccc5491695f..909bb3d95e34 100644 --- a/shared/chat/conversation/messages/wrapper/exploding-meta.tsx +++ b/shared/chat/conversation/messages/wrapper/exploding-meta.tsx @@ -1,43 +1,24 @@ -import * as C from '@/constants' -import * as Chat from '@/stores/chat' import * as React from 'react' -import {useIsHighlighted, useOrdinal} from '../ids-context' +import {useIsHighlighted} from '../ids-context' import * as Kb from '@/common-adapters' import {addTicker, removeTicker} from '@/util/second-timer' import {formatDurationShort} from '@/util/timestamp' import SharedTimer from './shared-timers' import {animationDuration} from './exploding-height-retainer' +import type * as T from '@/constants/types' -export type OwnProps = {onClick?: () => void} +export type OwnProps = { + exploded: boolean + exploding: boolean + explodesAt: number + messageKey: string + onClick?: () => void + submitState?: T.Chat.Message['submitState'] +} function ExplodingMetaContainer(p: OwnProps) { - const {onClick} = p - const ordinal = useOrdinal() + const {exploded, exploding, explodesAt, messageKey, onClick, submitState} = p const [now, setNow] = React.useState(() => Date.now()) - - const {exploding, exploded, submitState, explodesAt, messageKey} = Chat.useChatContext( - C.useShallow(s => { - const message = s.messageMap.get(ordinal) - if (!message || (message.type !== 'text' && message.type !== 'attachment') || !message.exploding) { - return { - exploded: false, - explodesAt: 0, - exploding: false, - messageKey: '', - submitState: '', - } - } - const messageKey = Chat.getMessageKey(message) - const {exploding, exploded, submitState, explodingTime: explodesAt} = message - return { - exploded, - explodesAt, - exploding, - messageKey, - submitState, - } - }) - ) const pending = submitState === 'pending' || submitState === 'failed' const lastMessageKeyRef = React.useRef(messageKey) diff --git a/shared/chat/conversation/messages/wrapper/send-indicator.tsx b/shared/chat/conversation/messages/wrapper/send-indicator.tsx index eeebfae55d31..ac464cd4692c 100644 --- a/shared/chat/conversation/messages/wrapper/send-indicator.tsx +++ b/shared/chat/conversation/messages/wrapper/send-indicator.tsx @@ -1,6 +1,3 @@ -import * as C from '@/constants' -import * as Chat from '@/stores/chat' -import {useOrdinal} from '../ids-context' import * as React from 'react' import * as Kb from '@/common-adapters' import {useColorScheme} from 'react-native' @@ -48,24 +45,15 @@ const statusToIconDarkExploding = { const shownEncryptingSet = new Set() -function SendIndicatorContainer() { - const ordinal = useOrdinal() - - const {isExploding, sent, failed, id} = Chat.useChatContext( - C.useShallow(s => { - const message = s.messageMap.get(ordinal) - return { - failed: - (message?.type === 'text' || message?.type === 'attachment') && message.submitState === 'failed', - id: message?.timestamp, - isExploding: !!message?.exploding, - sent: - (message?.type !== 'text' && message?.type !== 'attachment') || - !message.submitState || - message.exploded, - } - }) - ) +type OwnProps = { + failed: boolean + id: number + isExploding: boolean + sent: boolean +} + +function SendIndicatorContainer(p: OwnProps) { + const {failed, id, isExploding, sent} = p const [status, setStatus] = React.useState( sent ? 'sent' : failed ? 'error' : !shownEncryptingSet.has(id) ? 'encrypting' : 'sending' diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index c9edbff378ba..8c233540a084 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -4,7 +4,7 @@ import * as Kb from '@/common-adapters' import * as React from 'react' import {MessageContext, useOrdinal} from '../ids-context' import EmojiRow from '../emoji-row' -import ExplodingHeightRetainer from './exploding-height-retainer/container' +import ExplodingHeightRetainer from './exploding-height-retainer' import ExplodingMeta from './exploding-meta' import LongPressable from './long-pressable' import {useMessagePopup} from '../message-popup' @@ -212,6 +212,7 @@ const getCommonMessageData = ({ messageCenterOrdinal, ordinal, paymentStatusMap, + reactionOrderMap, unfurlPrompt, you, }: { @@ -222,6 +223,7 @@ const getCommonMessageData = ({ messageCenterOrdinal: ConvoState['messageCenterOrdinal'] ordinal: T.Chat.Ordinal paymentStatusMap: ReturnType['paymentStatusMap'] + reactionOrderMap: ConvoState['reactionOrderMap'] unfurlPrompt: ConvoState['unfurlPrompt'] you: string }) => { @@ -247,7 +249,11 @@ const getCommonMessageData = ({ const hasUnfurlList = (message.unfurls?.size ?? 0) > 0 const hasUnfurlPrompts = !!id && !!unfurlPrompt.get(id)?.size const textType: 'error' | 'sent' | 'pending' = message.errorReason ? 'error' : !submitState ? 'sent' : 'pending' - const showReplyTo = message.type === 'text' ? !!message.replyTo : false + const replyTo = message.type === 'text' ? message.replyTo : undefined + const reactions = message.reactions + const reactionOrder = reactionOrderMap.get(ordinal) + const isExplodingMessage = message.type === 'text' || message.type === 'attachment' + const showReplyTo = !!replyTo const text = message.type === 'text' ? (message.decoratedText?.stringValue() ?? message.text.stringValue()) : '' const showCenteredHighlight = @@ -264,12 +270,26 @@ const getCommonMessageData = ({ decorate, ecrType, exploding, + exploded, + explodedBy: isExplodingMessage ? message.explodedBy : undefined, + explodesAt: isExplodingMessage ? message.explodingTime : 0, + forceExplodingRetainer: isExplodingMessage ? !!message.explodingUnreadable : false, hasBeenEdited, hasCoinFlip, hasReactions, hasUnfurlList, hasUnfurlPrompts, isEditing: editing === ordinal, + messageKey: isExplodingMessage ? Chat.getMessageKey(message) : '', + reactionOrder, + reactions, + replyTo, + submitState, + sendIndicatorFailed: + (message.type === 'text' || message.type === 'attachment') && message.submitState === 'failed', + sendIndicatorID: message.timestamp, + sendIndicatorSent: + (message.type !== 'text' && message.type !== 'attachment') || !message.submitState || message.exploded, shouldShowPopup, showCenteredHighlight, showCoinsIcon, @@ -298,6 +318,7 @@ export const useMessageData = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: bo messageCenterOrdinal: s.messageCenterOrdinal, ordinal, paymentStatusMap: Chat.useChatState.getState().paymentStatusMap, + reactionOrderMap: s.reactionOrderMap, unfurlPrompt: s.unfurlPrompt, you, }) @@ -320,6 +341,7 @@ const useMessageDataWithMessage = (ordinal: T.Chat.Ordinal, isCenteredHighlight? messageCenterOrdinal: s.messageCenterOrdinal, ordinal, paymentStatusMap: Chat.useChatState.getState().paymentStatusMap, + reactionOrderMap: s.reactionOrderMap, unfurlPrompt: s.unfurlPrompt, you, }), @@ -391,11 +413,22 @@ type TSProps = { decorate: boolean ecrType: EditCancelRetryType exploding: boolean + exploded: boolean + explodedBy?: string + explodesAt: number + forceExplodingRetainer: boolean hasBeenEdited: boolean hasReactions: boolean hasUnfurlList: boolean isHighlighted: boolean + messageKey: string + ordinal: T.Chat.Ordinal popupAnchor: React.RefObject + reactionOrder?: ReadonlyArray + reactions?: T.Chat.Reactions + sendIndicatorFailed: boolean + sendIndicatorID: number + sendIndicatorSent: boolean setShowingPicker: (s: boolean) => void shouldShowPopup: boolean showCoinsIcon: boolean @@ -405,6 +438,7 @@ type TSProps = { showingPicker: boolean showingPopup: boolean showPopup: () => void + submitState?: T.Chat.Message['submitState'] type: T.Chat.MessageType } @@ -424,9 +458,10 @@ const NormalWrapper = ({ function TextAndSiblings(p: TSProps) { const {botname, bottomChildren, canShowReactionsPopup, children, decorate, hasBeenEdited, hasUnfurlList, isHighlighted} = p - const {showingPopup, ecrType, exploding, hasReactions, popupAnchor} = p - const {type, setShowingPicker, showCoinsIcon, shouldShowPopup} = p - const {showPopup, showExplodingCountdown, showRevoked, showSendIndicator, showingPicker} = p + const {showingPopup, ecrType, exploding, exploded, explodedBy, explodesAt, forceExplodingRetainer} = p + const {hasReactions, popupAnchor, reactionOrder, reactions, sendIndicatorFailed, sendIndicatorID} = p + const {sendIndicatorSent, type, setShowingPicker, showCoinsIcon, shouldShowPopup} = p + const {showPopup, showExplodingCountdown, showRevoked, showSendIndicator, showingPicker, submitState} = p const pressableProps = Kb.Styles.isMobile ? { onLongPress: decorate ? showPopup : undefined, @@ -444,7 +479,14 @@ function TextAndSiblings(p: TSProps) { const content = exploding ? ( - {children as React.ReactElement} + + {children as React.ReactElement} + ) : ( children @@ -461,8 +503,11 @@ function TextAndSiblings(p: TSProps) { hasUnfurlList={hasUnfurlList} messageType={type} hasReactions={hasReactions} + ordinal={p.ordinal} bottomChildren={bottomChildren} canShowReactionsPopup={canShowReactionsPopup} + reactionOrder={reactionOrder} + reactions={reactions} setShowingPicker={setShowingPicker} showingPopup={showingPopup} /> @@ -471,12 +516,20 @@ function TextAndSiblings(p: TSProps) { ) @@ -576,14 +629,34 @@ type BProps = { hasReactions: boolean hasUnfurlList: boolean messageType: T.Chat.MessageType + ordinal: T.Chat.Ordinal + reactionOrder?: ReadonlyArray + reactions?: T.Chat.Reactions ecrType: EditCancelRetryType } // reactions function BottomSide(p: BProps) { const {showingPopup, setShowingPicker, bottomChildren, canShowReactionsPopup, ecrType, hasBeenEdited} = p - const {hasReactions, hasUnfurlList, messageType} = p + const {hasReactions, hasUnfurlList, messageType, ordinal, reactionOrder, reactions} = p + const {setReplyTo, toggleMessageReaction} = Chat.useChatContext(s => s.dispatch) - const reactionsRow = hasReactions ? : null + const onReact = (emoji: string) => { + toggleMessageReaction(ordinal, emoji) + } + const onReply = () => { + setReplyTo(ordinal) + } + + const reactionsRow = hasReactions ? ( + + ) : null const canShowDesktopReactionsPopup = !C.isMobile && !hasReactions && canShowReactionsPopup const desktopReactionsPopup = @@ -592,6 +665,8 @@ function BottomSide(p: BProps) { className={Kb.Styles.classNames('WrapperMessage-emojiButton', 'hover-visible')} hasUnfurls={hasUnfurlList} messageType={messageType} + onReact={onReact} + onReply={onReply} onShowingEmojiPicker={setShowingPicker} style={styles.emojiRow} /> @@ -618,15 +693,39 @@ type RProps = { showRevoked: boolean showCoinsIcon: boolean botname: string + exploded: boolean + exploding: boolean + explodesAt: number + messageKey: string shouldShowPopup: boolean popupAnchor: React.RefObject + sendIndicatorFailed: boolean + sendIndicatorID: number + sendIndicatorSent: boolean + submitState?: T.Chat.Message['submitState'] } function RightSide(p: RProps) { const {showPopup, showSendIndicator, showCoinsIcon, popupAnchor} = p const {showExplodingCountdown, showRevoked, botname, shouldShowPopup} = p - const sendIndicator = showSendIndicator ? : null + const sendIndicator = showSendIndicator ? ( + + ) : null - const explodingCountdown = showExplodingCountdown ? : null + const explodingCountdown = showExplodingCountdown ? ( + + ) : null const revokedIcon = showRevoked ? ( @@ -701,7 +800,9 @@ export function WrapperMessage(p: WrapperMessageProps) { const [showingPicker, setShowingPicker] = React.useState(false) const {decorate, type, hasReactions, isEditing, shouldShowPopup} = mdata - const {canShowReactionsPopup, ecrType, showSendIndicator, showRevoked, showExplodingCountdown, exploding} = mdata + const {canShowReactionsPopup, ecrType, exploded, explodesAt, forceExplodingRetainer, messageKey} = mdata + const {reactionOrder, reactions, sendIndicatorFailed, sendIndicatorID, sendIndicatorSent, submitState} = mdata + const {showSendIndicator, showRevoked, showExplodingCountdown, exploding} = mdata const {showCoinsIcon, botname, hasBeenEdited, hasUnfurlList, showCenteredHighlight} = mdata const isHighlighted = showCenteredHighlight || isEditing @@ -713,11 +814,22 @@ export function WrapperMessage(p: WrapperMessageProps) { decorate, ecrType, exploding, + exploded, + explodedBy: mdata.explodedBy, + explodesAt, + forceExplodingRetainer, hasBeenEdited, hasReactions, hasUnfurlList, isHighlighted, + messageKey, + ordinal, popupAnchor, + reactionOrder, + reactions, + sendIndicatorFailed, + sendIndicatorID, + sendIndicatorSent, setShowingPicker, shouldShowPopup, showCoinsIcon, @@ -727,6 +839,7 @@ export function WrapperMessage(p: WrapperMessageProps) { showSendIndicator, showingPicker, showingPopup, + submitState, type, } From 83984f7c5881b6977249472ed540ae120152b573 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 8 Apr 2026 11:44:19 -0400 Subject: [PATCH 15/55] WIP --- shared/chat/conversation/messages/react-button.tsx | 8 ++++---- shared/chat/conversation/messages/text/reply.tsx | 2 +- shared/chat/conversation/messages/wrapper/wrapper.tsx | 9 +++++---- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/shared/chat/conversation/messages/react-button.tsx b/shared/chat/conversation/messages/react-button.tsx index 912b5de43a0d..e6f7070c99c0 100644 --- a/shared/chat/conversation/messages/react-button.tsx +++ b/shared/chat/conversation/messages/react-button.tsx @@ -30,12 +30,12 @@ function ReactButtonContainer(p: OwnProps) { return {active: false, count: 0, decorated: ''} } const message = s.messageMap.get(ordinal) - const reaction = message?.reactions?.get(emoji || '') - const active = (reaction?.users ?? []).some(r => r.username === me) + const reactionDesc = message?.reactions?.get(emoji || '') + const active = (reactionDesc?.users ?? []).some(r => r.username === me) return { active, - count: reaction?.users.length ?? 0, - decorated: reaction?.decorated ?? '', + count: reactionDesc?.users.length ?? 0, + decorated: reactionDesc?.decorated ?? '', } }) ) diff --git a/shared/chat/conversation/messages/text/reply.tsx b/shared/chat/conversation/messages/text/reply.tsx index aeee837955cd..eab74216309b 100644 --- a/shared/chat/conversation/messages/text/reply.tsx +++ b/shared/chat/conversation/messages/text/reply.tsx @@ -118,7 +118,7 @@ function ReplyStructure(p: RS) { } function Reply({replyTo, onClick}: {onClick?: () => void; replyTo: T.Chat.MessageReplyTo}) { - if (!replyTo?.id) return null + if (!replyTo.id) return null const showEdited = !!replyTo.hasBeenEdited const isDeleted = replyTo.exploded || replyTo.type === 'deleted' diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index 8c233540a084..a3560fe31d52 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -227,8 +227,9 @@ const getCommonMessageData = ({ unfurlPrompt: ConvoState['unfurlPrompt'] you: string }) => { - const {exploded, submitState, author, id, botUsername} = message + const {submitState, author, id, botUsername} = message const type = message.type + const exploded = !!message.exploded const idMatchesOrdinal = T.Chat.ordinalToNumber(message.ordinal) === T.Chat.messageIDToNumber(id) const exploding = !!message.exploding const decorate = !exploded && !message.errorReason @@ -269,8 +270,8 @@ const getCommonMessageData = ({ canShowReactionsPopup, decorate, ecrType, - exploding, exploded, + exploding, explodedBy: isExplodingMessage ? message.explodedBy : undefined, explodesAt: isExplodingMessage ? message.explodingTime : 0, forceExplodingRetainer: isExplodingMessage ? !!message.explodingUnreadable : false, @@ -284,12 +285,12 @@ const getCommonMessageData = ({ reactionOrder, reactions, replyTo, - submitState, sendIndicatorFailed: (message.type === 'text' || message.type === 'attachment') && message.submitState === 'failed', sendIndicatorID: message.timestamp, sendIndicatorSent: (message.type !== 'text' && message.type !== 'attachment') || !message.submitState || message.exploded, + submitState, shouldShowPopup, showCenteredHighlight, showCoinsIcon, @@ -813,8 +814,8 @@ export function WrapperMessage(p: WrapperMessageProps) { children, decorate, ecrType, - exploding, exploded, + exploding, explodedBy: mdata.explodedBy, explodesAt, forceExplodingRetainer, From 2b3f975d2db49a15adb5503abea0d4a3d4577b18 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 8 Apr 2026 11:45:27 -0400 Subject: [PATCH 16/55] WIP --- .../conversation/messages/wrapper/wrapper.tsx | 88 +++++++++++++------ 1 file changed, 59 insertions(+), 29 deletions(-) diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index a3560fe31d52..6d3b74d50d04 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -173,7 +173,16 @@ const useAuthorData = (ordinal: T.Chat.Ordinal) => teamType === 'adhoc' && participantInfoNames.length > 0 ? !participantInfoNames.includes(author) : false - return {author, botAlias: botAliases[author] ?? '', isAdhocBot, showUsername, teamID, teamType, teamname, timestamp} + return { + author, + botAlias: botAliases[author] ?? '', + isAdhocBot, + showUsername, + teamID, + teamType, + teamname, + timestamp, + } }) ) @@ -249,7 +258,11 @@ const getCommonMessageData = ({ const hasCoinFlip = message.type === 'text' && !!message.flipGameID const hasUnfurlList = (message.unfurls?.size ?? 0) > 0 const hasUnfurlPrompts = !!id && !!unfurlPrompt.get(id)?.size - const textType: 'error' | 'sent' | 'pending' = message.errorReason ? 'error' : !submitState ? 'sent' : 'pending' + const textType: 'error' | 'sent' | 'pending' = message.errorReason + ? 'error' + : !submitState + ? 'sent' + : 'pending' const replyTo = message.type === 'text' ? message.replyTo : undefined const reactions = message.reactions const reactionOrder = reactionOrderMap.get(ordinal) @@ -271,9 +284,9 @@ const getCommonMessageData = ({ decorate, ecrType, exploded, - exploding, explodedBy: isExplodingMessage ? message.explodedBy : undefined, explodesAt: isExplodingMessage ? message.explodingTime : 0, + exploding, forceExplodingRetainer: isExplodingMessage ? !!message.explodingUnreadable : false, hasBeenEdited, hasCoinFlip, @@ -290,7 +303,6 @@ const getCommonMessageData = ({ sendIndicatorID: message.timestamp, sendIndicatorSent: (message.type !== 'text' && message.type !== 'attachment') || !message.submitState || message.exploded, - submitState, shouldShowPopup, showCenteredHighlight, showCoinsIcon, @@ -298,6 +310,7 @@ const getCommonMessageData = ({ showReplyTo, showRevoked, showSendIndicator, + submitState, text, textType, type, @@ -458,7 +471,16 @@ const NormalWrapper = ({ } function TextAndSiblings(p: TSProps) { - const {botname, bottomChildren, canShowReactionsPopup, children, decorate, hasBeenEdited, hasUnfurlList, isHighlighted} = p + const { + botname, + bottomChildren, + canShowReactionsPopup, + children, + decorate, + hasBeenEdited, + hasUnfurlList, + isHighlighted, + } = p const {showingPopup, ecrType, exploding, exploded, explodedBy, explodesAt, forceExplodingRetainer} = p const {hasReactions, popupAnchor, reactionOrder, reactions, sendIndicatorFailed, sendIndicatorID} = p const {sendIndicatorSent, type, setShowingPicker, showCoinsIcon, shouldShowPopup} = p @@ -495,7 +517,13 @@ function TextAndSiblings(p: TSProps) { return ( - + {content} { - const m = s.messageMap.get(ordinal) - const outboxID = m?.outboxID - const reason = m?.errorReason ?? '' - const exploding = m?.exploding ?? false - const failureDescription = - ecrType === EditCancelRetryType.NOACTION - ? reason - : `This message failed to send${reason ? '. ' : ''}${capitalize(reason)}` - const {messageDelete, messageRetry, setEditing} = s.dispatch - return { - exploding, - failureDescription, - messageDelete, - messageRetry, - outboxID, - setEditing, - } - }) - ) + const {failureDescription, outboxID, exploding, messageDelete, messageRetry, setEditing} = + Chat.useChatContext( + C.useShallow(s => { + const m = s.messageMap.get(ordinal) + const outboxID = m?.outboxID + const reason = m?.errorReason ?? '' + const exploding = m?.exploding ?? false + const failureDescription = + ecrType === EditCancelRetryType.NOACTION + ? reason + : `This message failed to send${reason ? '. ' : ''}${capitalize(reason)}` + const {messageDelete, messageRetry, setEditing} = s.dispatch + return { + exploding, + failureDescription, + messageDelete, + messageRetry, + outboxID, + setEditing, + } + }) + ) const onCancel = () => { messageDelete(ordinal) } @@ -802,7 +831,8 @@ export function WrapperMessage(p: WrapperMessageProps) { const {decorate, type, hasReactions, isEditing, shouldShowPopup} = mdata const {canShowReactionsPopup, ecrType, exploded, explodesAt, forceExplodingRetainer, messageKey} = mdata - const {reactionOrder, reactions, sendIndicatorFailed, sendIndicatorID, sendIndicatorSent, submitState} = mdata + const {reactionOrder, reactions, sendIndicatorFailed, sendIndicatorID, sendIndicatorSent, submitState} = + mdata const {showSendIndicator, showRevoked, showExplodingCountdown, exploding} = mdata const {showCoinsIcon, botname, hasBeenEdited, hasUnfurlList, showCenteredHighlight} = mdata @@ -815,9 +845,9 @@ export function WrapperMessage(p: WrapperMessageProps) { decorate, ecrType, exploded, - exploding, explodedBy: mdata.explodedBy, explodesAt, + exploding, forceExplodingRetainer, hasBeenEdited, hasReactions, From 4203c032529a5da16ef4ce707f7d0a341301cdf1 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 8 Apr 2026 12:49:29 -0400 Subject: [PATCH 17/55] WIP --- PLAN.md | 2 +- .../conversation/list-area/index.native.tsx | 72 +++++++++---------- .../conversation/messages/text/wrapper.tsx | 21 +----- .../conversation/recycle-type-context.tsx | 4 -- shared/stores/convostate.tsx | 54 +++++++++++--- 5 files changed, 81 insertions(+), 72 deletions(-) delete mode 100644 shared/chat/conversation/recycle-type-context.tsx diff --git a/PLAN.md b/PLAN.md index 9eff46b27340..6aa463ca51f2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -79,7 +79,7 @@ Primary files: ### 5. List Data Stability And Recycling - [ ] Remove avoidable array cloning / reversing in the hottest list path. -- [ ] Replace effect-driven recycle subtype reporting with data available before or during row render. +- [x] Replace effect-driven recycle subtype reporting with data available before or during row render. - [ ] Re-check list item type stability after workstreams 1 and 3 land. - [ ] Keep scroll position and centered-message behavior unchanged. diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 40faf05349cc..3c52761c8627 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -11,7 +11,6 @@ import {FlatList} from 'react-native' // import {FlashList, type ListRenderItemInfo} from '@shopify/flash-list' import {MessageRow} from '../messages/wrapper' import {mobileTypingContainerHeight} from '../input-area/normal/typing' -import {SetRecycleTypeContext} from '../recycle-type-context' // import {useChatDebugDump} from '@/constants/chat/debug' import {usingFlashList} from './flashlist-config' import {PerfProfiler} from '@/perf/react-profiler' @@ -107,6 +106,7 @@ const ConversationList = function ConversationList() { const centeredOrdinal = messageCenterOrdinal?.ordinal ?? T.Chat.numberToOrdinal(-1) const messageTypeMap = Chat.useChatContext(s => s.messageTypeMap) const _messageOrdinals = Chat.useChatContext(s => s.messageOrdinals) + const rowRecycleTypeMap = Chat.useChatContext(s => s.rowRecycleTypeMap) const messageOrdinals = [...(_messageOrdinals ?? [])].reverse() @@ -130,19 +130,13 @@ const ConversationList = function ConversationList() { ) } - const recycleTypeRef = React.useRef(new Map()) - const setRecycleType = (ordinal: T.Chat.Ordinal, type: string) => { - recycleTypeRef.current.set(ordinal, type) - } - const numOrdinals = messageOrdinals.length const getItemType = (ordinal: T.Chat.Ordinal, idx: number) => { if (!ordinal) { return 'null' } - // Check recycleType first (set by messages after render — includes subtypes like 'text:reply') - const recycled = recycleTypeRef.current.get(ordinal) + const recycled = rowRecycleTypeMap.get(ordinal) if (recycled) return recycled const baseType = messageTypeMap.get(ordinal) ?? 'text' // Last item is most-recently sent; isolate it to avoid recycling with settled messages @@ -256,38 +250,36 @@ const ConversationList = function ConversationList() { return ( - - - - - {jumpToRecent} - {debugWhichList} - - - + + + + {jumpToRecent} + {debugWhichList} + + ) } diff --git a/shared/chat/conversation/messages/text/wrapper.tsx b/shared/chat/conversation/messages/text/wrapper.tsx index 1975f6562f64..bf177cba3b95 100644 --- a/shared/chat/conversation/messages/text/wrapper.tsx +++ b/shared/chat/conversation/messages/text/wrapper.tsx @@ -4,7 +4,6 @@ import * as React from 'react' import {useReply} from './reply' import {useBottom} from './bottom' import {useOrdinal} from '../ids-context' -import {SetRecycleTypeContext} from '../../recycle-type-context' import {WrapperMessage, useWrapperMessage, type Props} from '../wrapper/wrapper' import type {StyleOverride} from '@/common-adapters/markdown' import {sharedStyles} from '../shared-styles' @@ -50,10 +49,9 @@ function WrapperText(p: Props) { const {ordinal, isCenteredHighlight = false} = p const wrapper = useWrapperMessage(ordinal, isCenteredHighlight) const {messageData} = wrapper - const {isEditing, hasReactions, replyTo} = messageData + const {isEditing, replyTo} = messageData - const {hasCoinFlip, hasUnfurlList, hasUnfurlPrompts, showCenteredHighlight, text, textType, showReplyTo, type} = - messageData + const {hasCoinFlip, hasUnfurlList, hasUnfurlPrompts, showCenteredHighlight, text, textType, type} = messageData const bottomChildren = useBottom({hasCoinFlip, hasUnfurlList, hasUnfurlPrompts}) const replyJump = Chat.useChatContext(s => s.dispatch.replyJump) const onReplyClick = () => { @@ -62,21 +60,6 @@ function WrapperText(p: Props) { } const reply = useReply(replyTo, onReplyClick) - const setRecycleType = React.useContext(SetRecycleTypeContext) - - React.useEffect(() => { - let subType = '' - if (showReplyTo) { - subType += ':reply' - } - if (hasReactions) { - subType += ':reactions' - } - if (subType.length) { - setRecycleType(ordinal, 'text' + subType) - } - }, [ordinal, showReplyTo, hasReactions, setRecycleType]) - const style = getStyle(textType, isEditing, showCenteredHighlight) const children = ( diff --git a/shared/chat/conversation/recycle-type-context.tsx b/shared/chat/conversation/recycle-type-context.tsx deleted file mode 100644 index 8e1fc5847429..000000000000 --- a/shared/chat/conversation/recycle-type-context.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import * as React from 'react' -import type * as T from '@/constants/types' - -export const SetRecycleTypeContext = React.createContext((_ordinal: T.Chat.Ordinal, _type: string) => {}) diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 1f86c44e26ad..ee8cd5403f0b 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -138,6 +138,7 @@ type ConvoStore = T.Immutable<{ pendingOutboxToOrdinal: Map // messages waiting to be sent, reactionOrderMap: Map> replyTo: T.Chat.Ordinal + rowRecycleTypeMap: Map separatorMap: Map showUsernameMap: Map threadLoadStatus: T.RPCChat.UIChatThreadStatusTyp @@ -181,6 +182,7 @@ const initialConvoStore: ConvoStore = { pendingOutboxToOrdinal: new Map(), reactionOrderMap: new Map(), replyTo: T.Chat.numberToOrdinal(0), + rowRecycleTypeMap: new Map(), separatorMap: new Map(), showUsernameMap: new Map(), threadLoadStatus: T.RPCChat.UIChatThreadStatusTyp.none, @@ -650,6 +652,7 @@ const createSlice = for (const ordinal of ordinalsToRefresh) { const idx = findOrdinalIndex(messageOrdinals, ordinal) if (messageOrdinals[idx] !== ordinal) { + s.rowRecycleTypeMap.delete(ordinal) s.separatorMap.delete(ordinal) s.showUsernameMap.delete(ordinal) s.reactionOrderMap.delete(ordinal) @@ -659,6 +662,7 @@ const createSlice = const previousOrdinal = idx > 0 ? messageOrdinals[idx - 1]! : T.Chat.numberToOrdinal(0) const message = s.messageMap.get(ordinal) if (!message) { + s.rowRecycleTypeMap.delete(ordinal) s.separatorMap.delete(ordinal) s.showUsernameMap.delete(ordinal) s.reactionOrderMap.delete(ordinal) @@ -668,11 +672,42 @@ const createSlice = s.separatorMap.set(ordinal, previousOrdinal) const previousMessage = idx > 0 ? s.messageMap.get(previousOrdinal) : undefined s.showUsernameMap.set(ordinal, getUsernameToShow(message, previousMessage, you)) - if (message.reactions?.size) { - s.reactionOrderMap.set(ordinal, Message.getReactionOrder(message.reactions)) - } else { - s.reactionOrderMap.delete(ordinal) - } + setRowRenderDerivedMetadata(s, ordinal, message) + } + } + + const getRowRecycleType = (message: T.Chat.Message): string | undefined => { + if (message.type !== 'text') { + return undefined + } + + let rowRecycleType = 'text' + if (message.replyTo) { + rowRecycleType += ':reply' + } + if (message.reactions?.size) { + rowRecycleType += ':reactions' + } + + return rowRecycleType === 'text' ? undefined : rowRecycleType + } + + const setRowRenderDerivedMetadata = ( + s: Z.WritableDraft, + ordinal: T.Chat.Ordinal, + message: T.Chat.Message + ) => { + const rowRecycleType = getRowRecycleType(message) + if (rowRecycleType) { + s.rowRecycleTypeMap.set(ordinal, rowRecycleType) + } else { + s.rowRecycleTypeMap.delete(ordinal) + } + + if (message.reactions?.size) { + s.reactionOrderMap.set(ordinal, Message.getReactionOrder(message.reactions)) + } else { + s.reactionOrderMap.delete(ordinal) } } @@ -1023,7 +1058,7 @@ const createSlice = users: [{timestamp: Date.now(), username}], }) } - s.reactionOrderMap.set(targetOrdinal, Message.getReactionOrder(m.reactions)) + setRowRenderDerivedMetadata(s, targetOrdinal, m) } }) } @@ -2374,6 +2409,7 @@ const createSlice = s.messageOrdinals = undefined s.messageTypeMap.clear() s.reactionOrderMap.clear() + s.rowRecycleTypeMap.clear() s.separatorMap.clear() s.showUsernameMap.clear() s.validatedOrdinalRange = undefined @@ -2390,7 +2426,9 @@ const createSlice = m.explodedBy = explodedBy || '' m.reactions = new Map() m.unfurls = new Map() - if (ordinal) s.reactionOrderMap.set(ordinal, []) + if (ordinal) { + setRowRenderDerivedMetadata(s, ordinal, m) + } if (m.type === 'text') { m.flipGameID = '' m.mentionsAt = new Set() @@ -3552,7 +3590,7 @@ const createSlice = } m.reactions = T.castDraft(newReactions) } - s.reactionOrderMap.set(targetOrdinal, m.reactions ? Message.getReactionOrder(m.reactions) : []) + setRowRenderDerivedMetadata(s, targetOrdinal, m) } }) } From 1d62edf8218f56af83ea21cb2a561d895fcc4bb1 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 8 Apr 2026 12:50:16 -0400 Subject: [PATCH 18/55] WIP --- shared/chat/conversation/messages/text/wrapper.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/chat/conversation/messages/text/wrapper.tsx b/shared/chat/conversation/messages/text/wrapper.tsx index bf177cba3b95..67c4f9c4daef 100644 --- a/shared/chat/conversation/messages/text/wrapper.tsx +++ b/shared/chat/conversation/messages/text/wrapper.tsx @@ -1,6 +1,5 @@ import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' -import * as React from 'react' import {useReply} from './reply' import {useBottom} from './bottom' import {useOrdinal} from '../ids-context' @@ -51,7 +50,8 @@ function WrapperText(p: Props) { const {messageData} = wrapper const {isEditing, replyTo} = messageData - const {hasCoinFlip, hasUnfurlList, hasUnfurlPrompts, showCenteredHighlight, text, textType, type} = messageData + const {hasCoinFlip, hasUnfurlList, hasUnfurlPrompts, showCenteredHighlight, text, textType, type} = + messageData const bottomChildren = useBottom({hasCoinFlip, hasUnfurlList, hasUnfurlPrompts}) const replyJump = Chat.useChatContext(s => s.dispatch.replyJump) const onReplyClick = () => { From 608dfab191575ab8509a91436a23d921cd38b08a Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 8 Apr 2026 13:00:55 -0400 Subject: [PATCH 19/55] WIP --- shared/stores/convostate.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index ee8cd5403f0b..9a24513b589d 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -695,7 +695,8 @@ const createSlice = const setRowRenderDerivedMetadata = ( s: Z.WritableDraft, ordinal: T.Chat.Ordinal, - message: T.Chat.Message + message: T.Chat.Message, + preserveEmptyReactionOrder = false ) => { const rowRecycleType = getRowRecycleType(message) if (rowRecycleType) { @@ -706,6 +707,8 @@ const createSlice = if (message.reactions?.size) { s.reactionOrderMap.set(ordinal, Message.getReactionOrder(message.reactions)) + } else if (preserveEmptyReactionOrder) { + s.reactionOrderMap.set(ordinal, []) } else { s.reactionOrderMap.delete(ordinal) } @@ -1058,7 +1061,7 @@ const createSlice = users: [{timestamp: Date.now(), username}], }) } - setRowRenderDerivedMetadata(s, targetOrdinal, m) + setRowRenderDerivedMetadata(s, targetOrdinal, m, true) } }) } @@ -2427,7 +2430,7 @@ const createSlice = m.reactions = new Map() m.unfurls = new Map() if (ordinal) { - setRowRenderDerivedMetadata(s, ordinal, m) + setRowRenderDerivedMetadata(s, ordinal, m, true) } if (m.type === 'text') { m.flipGameID = '' @@ -3590,7 +3593,7 @@ const createSlice = } m.reactions = T.castDraft(newReactions) } - setRowRenderDerivedMetadata(s, targetOrdinal, m) + setRowRenderDerivedMetadata(s, targetOrdinal, m, true) } }) } From d5ddf81276c02f81ff506c5e3c7999bdf6578aac Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 8 Apr 2026 13:06:23 -0400 Subject: [PATCH 20/55] WIP --- PLAN.md | 2 +- .../conversation/messages/reactions-rows.tsx | 5 ++--- .../conversation/messages/wrapper/wrapper.tsx | 18 +++--------------- shared/stores/convostate.tsx | 18 ++---------------- shared/stores/tests/convostate.test.ts | 16 +++++----------- 5 files changed, 13 insertions(+), 46 deletions(-) diff --git a/PLAN.md b/PLAN.md index 6aa463ca51f2..28edc30870af 100644 --- a/PLAN.md +++ b/PLAN.md @@ -39,7 +39,7 @@ Primary files: - [x] Stop rebuilding whole-thread derived maps on every `messagesAdd`. - [x] Update separator, username-grouping, and reaction-order metadata only for changed ordinals and any affected neighbors. - [x] Avoid rebuilding and resorting `messageOrdinals` unless thread membership actually changed. -- [ ] Re-evaluate whether some derived metadata should live in store state at all. +- [x] Re-evaluate whether some derived metadata should live in store state at all. Primary files: diff --git a/shared/chat/conversation/messages/reactions-rows.tsx b/shared/chat/conversation/messages/reactions-rows.tsx index b8cbaa050518..2ccd4e68b193 100644 --- a/shared/chat/conversation/messages/reactions-rows.tsx +++ b/shared/chat/conversation/messages/reactions-rows.tsx @@ -15,13 +15,12 @@ type OwnProps = { messageType: T.Chat.MessageType onReact: (emoji: string) => void onReply: () => void - reactionOrder?: ReadonlyArray reactions?: T.Chat.Reactions } function ReactionsRowContainer(p: OwnProps) { - const {hasUnfurls, messageType, onReact, onReply, reactionOrder, reactions} = p - const emojis = reactionOrder?.length ? reactionOrder : reactions?.size ? Message.getReactionOrder(reactions) : emptyEmojis + const {hasUnfurls, messageType, onReact, onReply, reactions} = p + const emojis = reactions?.size ? Message.getReactionOrder(reactions) : emptyEmojis return emojis.length === 0 ? null : ( diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index 6d3b74d50d04..64dd75e226d2 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -221,7 +221,6 @@ const getCommonMessageData = ({ messageCenterOrdinal, ordinal, paymentStatusMap, - reactionOrderMap, unfurlPrompt, you, }: { @@ -232,7 +231,6 @@ const getCommonMessageData = ({ messageCenterOrdinal: ConvoState['messageCenterOrdinal'] ordinal: T.Chat.Ordinal paymentStatusMap: ReturnType['paymentStatusMap'] - reactionOrderMap: ConvoState['reactionOrderMap'] unfurlPrompt: ConvoState['unfurlPrompt'] you: string }) => { @@ -265,7 +263,6 @@ const getCommonMessageData = ({ : 'pending' const replyTo = message.type === 'text' ? message.replyTo : undefined const reactions = message.reactions - const reactionOrder = reactionOrderMap.get(ordinal) const isExplodingMessage = message.type === 'text' || message.type === 'attachment' const showReplyTo = !!replyTo const text = @@ -295,7 +292,6 @@ const getCommonMessageData = ({ hasUnfurlPrompts, isEditing: editing === ordinal, messageKey: isExplodingMessage ? Chat.getMessageKey(message) : '', - reactionOrder, reactions, replyTo, sendIndicatorFailed: @@ -332,7 +328,6 @@ export const useMessageData = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: bo messageCenterOrdinal: s.messageCenterOrdinal, ordinal, paymentStatusMap: Chat.useChatState.getState().paymentStatusMap, - reactionOrderMap: s.reactionOrderMap, unfurlPrompt: s.unfurlPrompt, you, }) @@ -355,7 +350,6 @@ const useMessageDataWithMessage = (ordinal: T.Chat.Ordinal, isCenteredHighlight? messageCenterOrdinal: s.messageCenterOrdinal, ordinal, paymentStatusMap: Chat.useChatState.getState().paymentStatusMap, - reactionOrderMap: s.reactionOrderMap, unfurlPrompt: s.unfurlPrompt, you, }), @@ -438,7 +432,6 @@ type TSProps = { messageKey: string ordinal: T.Chat.Ordinal popupAnchor: React.RefObject - reactionOrder?: ReadonlyArray reactions?: T.Chat.Reactions sendIndicatorFailed: boolean sendIndicatorID: number @@ -482,7 +475,7 @@ function TextAndSiblings(p: TSProps) { isHighlighted, } = p const {showingPopup, ecrType, exploding, exploded, explodedBy, explodesAt, forceExplodingRetainer} = p - const {hasReactions, popupAnchor, reactionOrder, reactions, sendIndicatorFailed, sendIndicatorID} = p + const {hasReactions, popupAnchor, reactions, sendIndicatorFailed, sendIndicatorID} = p const {sendIndicatorSent, type, setShowingPicker, showCoinsIcon, shouldShowPopup} = p const {showPopup, showExplodingCountdown, showRevoked, showSendIndicator, showingPicker, submitState} = p const pressableProps = Kb.Styles.isMobile @@ -535,7 +528,6 @@ function TextAndSiblings(p: TSProps) { ordinal={p.ordinal} bottomChildren={bottomChildren} canShowReactionsPopup={canShowReactionsPopup} - reactionOrder={reactionOrder} reactions={reactions} setShowingPicker={setShowingPicker} showingPopup={showingPopup} @@ -660,14 +652,13 @@ type BProps = { hasUnfurlList: boolean messageType: T.Chat.MessageType ordinal: T.Chat.Ordinal - reactionOrder?: ReadonlyArray reactions?: T.Chat.Reactions ecrType: EditCancelRetryType } // reactions function BottomSide(p: BProps) { const {showingPopup, setShowingPicker, bottomChildren, canShowReactionsPopup, ecrType, hasBeenEdited} = p - const {hasReactions, hasUnfurlList, messageType, ordinal, reactionOrder, reactions} = p + const {hasReactions, hasUnfurlList, messageType, ordinal, reactions} = p const {setReplyTo, toggleMessageReaction} = Chat.useChatContext(s => s.dispatch) const onReact = (emoji: string) => { @@ -683,7 +674,6 @@ function BottomSide(p: BProps) { messageType={messageType} onReact={onReact} onReply={onReply} - reactionOrder={reactionOrder} reactions={reactions} /> ) : null @@ -831,8 +821,7 @@ export function WrapperMessage(p: WrapperMessageProps) { const {decorate, type, hasReactions, isEditing, shouldShowPopup} = mdata const {canShowReactionsPopup, ecrType, exploded, explodesAt, forceExplodingRetainer, messageKey} = mdata - const {reactionOrder, reactions, sendIndicatorFailed, sendIndicatorID, sendIndicatorSent, submitState} = - mdata + const {reactions, sendIndicatorFailed, sendIndicatorID, sendIndicatorSent, submitState} = mdata const {showSendIndicator, showRevoked, showExplodingCountdown, exploding} = mdata const {showCoinsIcon, botname, hasBeenEdited, hasUnfurlList, showCenteredHighlight} = mdata @@ -856,7 +845,6 @@ export function WrapperMessage(p: WrapperMessageProps) { messageKey, ordinal, popupAnchor, - reactionOrder, reactions, sendIndicatorFailed, sendIndicatorID, diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 9a24513b589d..bc57341ebdbd 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -136,7 +136,6 @@ type ConvoStore = T.Immutable<{ participants: T.Chat.ParticipantInfo pendingJumpMessageID?: T.Chat.MessageID pendingOutboxToOrdinal: Map // messages waiting to be sent, - reactionOrderMap: Map> replyTo: T.Chat.Ordinal rowRecycleTypeMap: Map separatorMap: Map @@ -180,7 +179,6 @@ const initialConvoStore: ConvoStore = { participants: noParticipantInfo, pendingJumpMessageID: undefined, pendingOutboxToOrdinal: new Map(), - reactionOrderMap: new Map(), replyTo: T.Chat.numberToOrdinal(0), rowRecycleTypeMap: new Map(), separatorMap: new Map(), @@ -655,7 +653,6 @@ const createSlice = s.rowRecycleTypeMap.delete(ordinal) s.separatorMap.delete(ordinal) s.showUsernameMap.delete(ordinal) - s.reactionOrderMap.delete(ordinal) continue } @@ -665,7 +662,6 @@ const createSlice = s.rowRecycleTypeMap.delete(ordinal) s.separatorMap.delete(ordinal) s.showUsernameMap.delete(ordinal) - s.reactionOrderMap.delete(ordinal) continue } @@ -695,8 +691,7 @@ const createSlice = const setRowRenderDerivedMetadata = ( s: Z.WritableDraft, ordinal: T.Chat.Ordinal, - message: T.Chat.Message, - preserveEmptyReactionOrder = false + message: T.Chat.Message ) => { const rowRecycleType = getRowRecycleType(message) if (rowRecycleType) { @@ -704,14 +699,6 @@ const createSlice = } else { s.rowRecycleTypeMap.delete(ordinal) } - - if (message.reactions?.size) { - s.reactionOrderMap.set(ordinal, Message.getReactionOrder(message.reactions)) - } else if (preserveEmptyReactionOrder) { - s.reactionOrderMap.set(ordinal, []) - } else { - s.reactionOrderMap.delete(ordinal) - } } const mergeMessage = ( @@ -2411,7 +2398,6 @@ const createSlice = s.messageMap.clear() s.messageOrdinals = undefined s.messageTypeMap.clear() - s.reactionOrderMap.clear() s.rowRecycleTypeMap.clear() s.separatorMap.clear() s.showUsernameMap.clear() @@ -3593,7 +3579,7 @@ const createSlice = } m.reactions = T.castDraft(newReactions) } - setRowRenderDerivedMetadata(s, targetOrdinal, m, true) + setRowRenderDerivedMetadata(s, targetOrdinal, m) } }) } diff --git a/shared/stores/tests/convostate.test.ts b/shared/stores/tests/convostate.test.ts index c8062a11a20f..546c0a06e23c 100644 --- a/shared/stores/tests/convostate.test.ts +++ b/shared/stores/tests/convostate.test.ts @@ -206,7 +206,6 @@ const seedStore = ( messageTypeMap, meta: makeMeta(), pendingOutboxToOrdinal, - reactionOrderMap: new Map(), separatorMap: new Map(), showUsernameMap: new Map(), ...extra, @@ -224,7 +223,6 @@ const seedStoreWithAnchoredMessage = () => { messageTypeMap: new Map(), meta: makeMeta(), pendingOutboxToOrdinal: new Map([[outboxID, ordinal]]), - reactionOrderMap: new Map(), separatorMap: new Map([[ordinal, T.Chat.numberToOrdinal(0)]]), showUsernameMap: new Map([[ordinal, 'alice']]), } @@ -307,7 +305,7 @@ test('reaction updates preserve outbox-anchored row identity', () => { const store = seedStoreWithAnchoredMessage() const reactions = new Map([[':+1:', makeReaction('bob', 5)]]) store.getState().dispatch.updateReactions([{reactions, targetMsgID: msgID}]) - expect(store.getState().reactionOrderMap.get(ordinal)?.[0]).toBe(':+1:') + expect(Message.getReactionOrder(store.getState().messageMap.get(ordinal)?.reactions ?? new Map())[0]).toBe(':+1:') expect(store.getState().pendingOutboxToOrdinal.get(outboxID)).toBe(ordinal) }) @@ -329,10 +327,10 @@ test('reaction updates keep existing emoji order and sort new emojis by first ti store.getState().dispatch.updateReactions([{reactions, targetMsgID: msgID}]) - expect(store.getState().reactionOrderMap.get(ordinal)).toEqual([':+1:', ':eyes:', ':fire:', ':wave:']) const message = store.getState().messageMap.get(ordinal) expect(Message.isMessageWithReactions(message!)).toBe(true) if (message && Message.isMessageWithReactions(message)) { + expect(Message.getReactionOrder(message.reactions ?? new Map())).toEqual([':+1:', ':eyes:', ':fire:', ':wave:']) expect([...(message.reactions?.keys() ?? [])]).toEqual([':+1:', ':wave:', ':eyes:', ':fire:']) } }) @@ -342,7 +340,6 @@ test('reaction updates clear message reactions when the server sends none', () = store.getState().dispatch.updateReactions([{targetMsgID: msgID}]) const message = store.getState().messageMap.get(ordinal) expect(message && Message.isMessageWithReactions(message) ? message.reactions : undefined).toBeUndefined() - expect(store.getState().reactionOrderMap.get(ordinal)).toEqual([]) }) test('reaction updates ignore deleted and placeholder rows', () => { @@ -368,7 +365,6 @@ test('reaction updates ignore deleted and placeholder rows', () => { {reactions, targetMsgID: placeholderMsgID}, ]) - expect(store.getState().reactionOrderMap.size).toBe(0) expect(store.getState().messageMap.get(T.Chat.numberToOrdinal(401))?.type).toBe('deleted') expect(store.getState().messageMap.get(T.Chat.numberToOrdinal(402))?.type).toBe('placeholder') }) @@ -458,14 +454,12 @@ test('explode-now clears text content and transient metadata in place', () => { expect(message?.type === 'text' ? [...(message.mentionsAt ?? [])] : undefined).toEqual([]) expect(message?.reactions?.size ?? 0).toBe(0) expect(message?.unfurls?.size ?? 0).toBe(0) - expect(state.reactionOrderMap.get(ordinal)).toEqual([]) }) test('messagesClear resets all message indexes and maps', () => { const store = seedStoreWithAnchoredMessage() applyState(store, { loaded: true, - reactionOrderMap: new Map([[ordinal, [':+1:']]]), separatorMap: new Map([[ordinal, T.Chat.numberToOrdinal(0)]]), showUsernameMap: new Map([[ordinal, 'alice']]), validatedOrdinalRange: {from: ordinal, to: ordinal}, @@ -477,7 +471,6 @@ test('messagesClear resets all message indexes and maps', () => { expect(store.getState().messageTypeMap.size).toBe(0) expect(store.getState().pendingOutboxToOrdinal.size).toBe(0) expect(store.getState().messageIDToOrdinal.size).toBe(0) - expect(store.getState().reactionOrderMap.size).toBe(0) expect(store.getState().separatorMap.size).toBe(0) expect(store.getState().showUsernameMap.size).toBe(0) expect(store.getState().validatedOrdinalRange).toBeUndefined() @@ -495,7 +488,6 @@ test('server ack preserves the outbox-anchored ordinal and later msgID lookups h messageTypeMap: new Map(), meta: makeMeta(), pendingOutboxToOrdinal: new Map([[outboxID, pendingOrdinal]]), - reactionOrderMap: new Map(), separatorMap: new Map([[pendingOrdinal, T.Chat.numberToOrdinal(0)]]), showUsernameMap: new Map([[pendingOrdinal, 'alice']]), } @@ -516,7 +508,9 @@ test('server ack preserves the outbox-anchored ordinal and later msgID lookups h const reactions = new Map([[':+1:', makeReaction('bob', 5)]]) store.getState().dispatch.updateReactions([{reactions, targetMsgID: serverMsgID}]) - expect(store.getState().reactionOrderMap.get(pendingOrdinal)?.[0]).toBe(':+1:') + expect(Message.getReactionOrder(store.getState().messageMap.get(pendingOrdinal)?.reactions ?? new Map())[0]).toBe( + ':+1:' + ) store.getState().dispatch.messagesWereDeleted({messageIDs: [serverMsgID]}) From fba9ece55e3ec54b3ac70973ce4309a27c1a1477 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 8 Apr 2026 13:07:44 -0400 Subject: [PATCH 21/55] WIP --- shared/stores/convostate.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index bc57341ebdbd..4d020d1afcb5 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -1048,7 +1048,7 @@ const createSlice = users: [{timestamp: Date.now(), username}], }) } - setRowRenderDerivedMetadata(s, targetOrdinal, m, true) + setRowRenderDerivedMetadata(s, targetOrdinal, m) } }) } @@ -2416,7 +2416,7 @@ const createSlice = m.reactions = new Map() m.unfurls = new Map() if (ordinal) { - setRowRenderDerivedMetadata(s, ordinal, m, true) + setRowRenderDerivedMetadata(s, ordinal, m) } if (m.type === 'text') { m.flipGameID = '' From f20d79ebc08df9ae56a2b3a3e450e44eb12a72c9 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Wed, 8 Apr 2026 13:20:19 -0400 Subject: [PATCH 22/55] WIP --- PLAN.md | 2 +- .../messages/attachment/audio.tsx | 12 +- .../conversation/messages/attachment/file.tsx | 56 +++---- .../messages/attachment/image/imageimpl.d.ts | 3 +- .../attachment/image/imageimpl.desktop.tsx | 7 +- .../attachment/image/imageimpl.native.tsx | 7 +- .../messages/attachment/image/index.tsx | 29 ++-- .../messages/attachment/image/use-state.tsx | 29 ---- .../messages/attachment/shared.tsx | 147 +++++++----------- .../messages/attachment/video/index.tsx | 29 ++-- .../messages/attachment/video/use-state.tsx | 18 --- .../messages/attachment/video/videoimpl.d.ts | 2 + .../attachment/video/videoimpl.desktop.tsx | 8 +- .../attachment/video/videoimpl.native.tsx | 7 +- .../messages/attachment/wrapper.tsx | 34 ++-- .../conversation/messages/text/bottom.tsx | 23 ++- .../text/unfurl/prompt-list/container.tsx | 12 +- .../text/unfurl/unfurl-list/generic.tsx | 89 ++++------- .../text/unfurl/unfurl-list/giphy.tsx | 57 +++---- .../text/unfurl/unfurl-list/index.tsx | 83 +++++----- .../messages/text/unfurl/unfurl-list/map.tsx | 60 ++----- .../text/unfurl/unfurl-list/use-state.tsx | 15 -- .../conversation/messages/text/wrapper.tsx | 16 +- 23 files changed, 306 insertions(+), 439 deletions(-) delete mode 100644 shared/chat/conversation/messages/attachment/image/use-state.tsx delete mode 100644 shared/chat/conversation/messages/attachment/video/use-state.tsx diff --git a/PLAN.md b/PLAN.md index 28edc30870af..2647e6acf00b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -51,7 +51,7 @@ Primary files: - [ ] Move toward one main convo-store subscription per mounted row. - [x] Push row data down as props instead of reopening store subscriptions in reply, reactions, emoji, send-indicator, exploding-meta, and similar children. -- [ ] Audit attachment and unfurl helpers for repeated `messageMap.get(ordinal)` selectors. +- [x] Audit attachment and unfurl helpers for repeated `messageMap.get(ordinal)` selectors. - [ ] Keep selectors narrow and stable when a child still needs to subscribe directly. Primary files: diff --git a/shared/chat/conversation/messages/attachment/audio.tsx b/shared/chat/conversation/messages/attachment/audio.tsx index 663952856b61..a7924e61cfc3 100644 --- a/shared/chat/conversation/messages/attachment/audio.tsx +++ b/shared/chat/conversation/messages/attachment/audio.tsx @@ -1,12 +1,9 @@ import * as Chat from '@/stores/chat' import type * as T from '@/constants/types' import * as Kb from '@/common-adapters' -import {useOrdinal} from '../ids-context' import AudioPlayer from '@/chat/audio/audio-player' import {useFSState} from '@/stores/fs' -const missingMessage = Chat.makeMessageAttachment() - const messageAttachmentHasProgress = (message: T.Chat.MessageAttachment) => { return ( !!message.transferState && @@ -14,14 +11,7 @@ const messageAttachmentHasProgress = (message: T.Chat.MessageAttachment) => { message.transferState !== 'mobileSaving' ) } -const AudioAttachment = () => { - const ordinal = useOrdinal() - - // TODO not message - const message = Chat.useChatContext(s => { - const m = s.messageMap.get(ordinal) - return m?.type === 'attachment' ? m : missingMessage - }) +const AudioAttachment = ({message}: {message: T.Chat.MessageAttachment}) => { const progressLabel = Chat.messageAttachmentTransferStateToProgressLabel(message.transferState) const hasProgress = messageAttachmentHasProgress(message) const openLocalPathInSystemFileManagerDesktop = useFSState( diff --git a/shared/chat/conversation/messages/attachment/file.tsx b/shared/chat/conversation/messages/attachment/file.tsx index e49ff89e6fad..b89b0fa7d307 100644 --- a/shared/chat/conversation/messages/attachment/file.tsx +++ b/shared/chat/conversation/messages/attachment/file.tsx @@ -1,8 +1,8 @@ import * as C from '@/constants' import * as CryptoRoutes from '@/constants/crypto' import * as Chat from '@/stores/chat' +import type * as T from '@/constants/types' import {isPathSaltpack, isPathSaltpackEncrypted, isPathSaltpackSigned} from '@/util/path' -import {useOrdinal} from '@/chat/conversation/messages/ids-context' import captialize from 'lodash/capitalize' import * as Kb from '@/common-adapters' import type {StyleOverride} from '@/common-adapters/markdown' @@ -10,41 +10,31 @@ import {getEditStyle, ShowToastAfterSaving} from './shared' import {useFSState} from '@/stores/fs' import {makeUUID} from '@/util/uuid' -type OwnProps = {showPopup: () => void} - -const missingMessage = Chat.makeMessageAttachment({}) +type OwnProps = { + isEditing: boolean + message: T.Chat.MessageAttachment + ordinal: T.Chat.Ordinal + showPopup: () => void +} function FileContainer(p: OwnProps) { - const ordinal = useOrdinal() - const data = Chat.useChatContext( - C.useShallow(s => { - const m = s.messageMap.get(ordinal) ?? missingMessage - const isEditing = !!s.editing - const conversationIDKey = s.id - const {downloadPath, fileName, fileType, transferErrMsg, transferState} = m - const title = m.decoratedText?.stringValue() || m.title || m.fileName - const progress = m.type === 'attachment' ? m.transferProgress : 0 - - const {dispatch} = s - const {attachmentDownload, messageAttachmentNativeShare} = dispatch - return { - attachmentDownload, - conversationIDKey, - downloadPath, - fileName, - fileType, - isEditing, - messageAttachmentNativeShare, - progress, - title, - transferErrMsg, - transferState, - } - }) + const {isEditing, message, ordinal} = p + const {attachmentDownload, messageAttachmentNativeShare} = Chat.useChatContext( + C.useShallow(s => ({ + attachmentDownload: s.dispatch.attachmentDownload, + messageAttachmentNativeShare: s.dispatch.messageAttachmentNativeShare, + })) ) - - const {conversationIDKey, fileType, downloadPath, isEditing, progress, messageAttachmentNativeShare} = data - const {attachmentDownload, title, transferState, transferErrMsg, fileName: _fileName} = data + const { + conversationIDKey, + downloadPath, + fileName: _fileName, + fileType, + transferErrMsg, + transferProgress: progress, + transferState, + } = message + const title = message.decoratedText?.stringValue() || message.title || message.fileName const switchTab = C.Router2.switchTab const navigateAppend = C.Router2.navigateAppend diff --git a/shared/chat/conversation/messages/attachment/image/imageimpl.d.ts b/shared/chat/conversation/messages/attachment/image/imageimpl.d.ts index 21bf021a53b4..1f586de3e41f 100644 --- a/shared/chat/conversation/messages/attachment/image/imageimpl.d.ts +++ b/shared/chat/conversation/messages/attachment/image/imageimpl.d.ts @@ -1,3 +1,4 @@ import type * as React from 'react' -declare const ImageImpl: () => React.ReactNode +import type * as T from '@/constants/types' +declare const ImageImpl: (p: {message: T.Chat.MessageAttachment}) => React.ReactNode export default ImageImpl diff --git a/shared/chat/conversation/messages/attachment/image/imageimpl.desktop.tsx b/shared/chat/conversation/messages/attachment/image/imageimpl.desktop.tsx index c2cc1a0fee9d..c2afda0ba109 100644 --- a/shared/chat/conversation/messages/attachment/image/imageimpl.desktop.tsx +++ b/shared/chat/conversation/messages/attachment/image/imageimpl.desktop.tsx @@ -1,9 +1,10 @@ import * as Kb from '@/common-adapters' -import {useState} from './use-state' +import type * as T from '@/constants/types' +import {getAttachmentPreviewSize} from '../shared' // its important we use explicit height/width so we never CLS while loading -const ImageImpl = () => { - const {previewURL, height, width} = useState() +const ImageImpl = ({message}: {message: T.Chat.MessageAttachment}) => { + const {previewURL, height, width} = getAttachmentPreviewSize(message, true) return ( { - const {previewURL, height, width} = useState() +const ImageImpl = ({message}: {message: T.Chat.MessageAttachment}) => { + const {previewURL, height, width} = getAttachmentPreviewSize(message, true) return } diff --git a/shared/chat/conversation/messages/attachment/image/index.tsx b/shared/chat/conversation/messages/attachment/image/index.tsx index 31b1ec5f5369..2e0ca548591d 100644 --- a/shared/chat/conversation/messages/attachment/image/index.tsx +++ b/shared/chat/conversation/messages/attachment/image/index.tsx @@ -1,26 +1,37 @@ import * as Kb from '@/common-adapters' import * as React from 'react' +import * as Chat from '@/stores/chat' +import * as T from '@/constants/types' import ImageImpl from './imageimpl' import { + getAttachmentDisplayFileName, ShowToastAfterSaving, Title, - useAttachmentState, useCollapseIcon, Collapsed, Transferring, TransferIcon, } from '../shared' +import {Keyboard} from 'react-native' type Props = { + message: T.Chat.MessageAttachment + ordinal: T.Chat.Ordinal showPopup: () => void } function Image(p: Props) { - const {showPopup} = p - const {fileName, isCollapsed, showTitle, openFullscreen, transferState, transferProgress} = - useAttachmentState() + const {message, ordinal, showPopup} = p + const {isCollapsed, title, transferProgress, transferState} = message + const attachmentPreviewSelect = Chat.useChatContext(s => s.dispatch.attachmentPreviewSelect) + const fileName = getAttachmentDisplayFileName(message) + const showTitle = !!title + const openFullscreen = () => { + Keyboard.dismiss() + attachmentPreviewSelect(ordinal) + } const containerStyle = styles.container - const collapseIcon = useCollapseIcon(false) + const collapseIcon = useCollapseIcon(ordinal, isCollapsed, false) const filename = Kb.Styles.isMobile || !fileName ? null : ( @@ -55,19 +66,19 @@ function Image(p: Props) { style={styles.imageContainer} ref={toastTargetRef} > - + - {showTitle ? : null} + {showTitle ? <Title message={message} /> : null} <Transferring transferState={transferState} ratio={transferProgress} /> </Kb.Box2> - <TransferIcon style={Kb.Styles.isMobile ? styles.transferIcon : undefined} /> + <TransferIcon message={message} ordinal={ordinal} style={Kb.Styles.isMobile ? styles.transferIcon : undefined} /> </Kb.Box2> </> ) return ( <Kb.Box2 direction="vertical" fullWidth={true} style={containerStyle} alignItems="flex-start"> - {isCollapsed ? <Collapsed /> : content} + {isCollapsed ? <Collapsed isCollapsed={isCollapsed} ordinal={ordinal} /> : content} </Kb.Box2> ) } diff --git a/shared/chat/conversation/messages/attachment/image/use-state.tsx b/shared/chat/conversation/messages/attachment/image/use-state.tsx deleted file mode 100644 index e540129c10e3..000000000000 --- a/shared/chat/conversation/messages/attachment/image/use-state.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import * as C from '@/constants' -import * as Chat from '@/stores/chat' -import {useOrdinal} from '@/chat/conversation/messages/ids-context' -import {maxWidth, maxHeight} from '../shared' - -const missingMessage = Chat.makeMessageAttachment() - -export const useState = () => { - const ordinal = useOrdinal() - return Chat.useChatContext( - C.useShallow(s => { - const m = s.messageMap.get(ordinal) - const message = m?.type === 'attachment' ? m : missingMessage - const {fileURL, previewHeight, previewWidth} = message - let {previewURL} = message - let {height, width} = Chat.clampImageSize(previewWidth, previewHeight, maxWidth, maxHeight) - // This is mostly a sanity check and also allows us to handle HEIC even though the go side doesn't - // understand - if (height === 0 || width === 0) { - height = 320 - width = 320 - } - if (!previewURL) { - previewURL = fileURL - } - return {height, previewURL, width} - }) - ) -} diff --git a/shared/chat/conversation/messages/attachment/shared.tsx b/shared/chat/conversation/messages/attachment/shared.tsx index f2afc22fcf62..7d6f208f9687 100644 --- a/shared/chat/conversation/messages/attachment/shared.tsx +++ b/shared/chat/conversation/messages/attachment/shared.tsx @@ -3,9 +3,7 @@ import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' import * as React from 'react' import * as T from '@/constants/types' -import {useOrdinal} from '../ids-context' import {sharedStyles} from '../shared-styles' -import {Keyboard} from 'react-native' import {useFSState} from '@/stores/fs' type Props = { @@ -17,8 +15,6 @@ type Props = { export const maxWidth = Kb.Styles.isMobile ? Math.min(356, Kb.Styles.dimensionWidth - 70) : 356 export const maxHeight = 320 -export const missingMessage = Chat.makeMessageAttachment() - export const ShowToastAfterSaving = ({transferState, toastTargetRef}: Props) => { const [showingToast, setShowingToast] = React.useState(false) const lastTransferStateRef = React.useRef(transferState) @@ -62,33 +58,29 @@ export const ShowToastAfterSaving = ({transferState, toastTargetRef}: Props) => ) : null } -export const TransferIcon = (p: {style: Kb.Styles.StylesCrossPlatform}) => { - const {style} = p - const ordinal = useOrdinal() - const {state, downloadPath, download} = Chat.useChatContext( - C.useShallow(s => { - const m = s.messageMap.get(ordinal) - let state: 'none' | 'doneWithPath' | 'done' | 'downloading' = 'none' - let downloadPath = '' - if (m?.type === 'attachment') { - downloadPath = m.downloadPath ?? '' - if (downloadPath.length) { - state = 'doneWithPath' - } else if (m.transferProgress === 1) { - state = 'done' - } else { - switch (m.transferState) { - case 'downloading': - case 'mobileSaving': - state = 'downloading' - break - default: - } - } - } - const download = C.isMobile ? s.dispatch.messageAttachmentNativeSave : s.dispatch.attachmentDownload - return {download, downloadPath, state} - }) +export const TransferIcon = (p: { + message: T.Chat.MessageAttachment + ordinal: T.Chat.Ordinal + style: Kb.Styles.StylesCrossPlatform +}) => { + const {message, ordinal, style} = p + let state: 'none' | 'doneWithPath' | 'done' | 'downloading' = 'none' + const downloadPath = message.downloadPath ?? '' + if (downloadPath.length) { + state = 'doneWithPath' + } else if (message.transferProgress === 1) { + state = 'done' + } else { + switch (message.transferState) { + case 'downloading': + case 'mobileSaving': + state = 'downloading' + break + default: + } + } + const download = Chat.useChatContext(s => + C.isMobile ? s.dispatch.messageAttachmentNativeSave : s.dispatch.attachmentDownload ) const onDownload = () => { download(ordinal) @@ -169,12 +161,33 @@ export const getEditStyle = (isEditing: boolean) => { return isEditing ? sharedStyles.sentEditing : sharedStyles.sent } -export const Title = () => { - const ordinal = useOrdinal() - const title = Chat.useChatContext(s => { - const m = s.messageMap.get(ordinal) - return m?.type === 'attachment' ? (m.decoratedText?.stringValue() ?? m.title) : '' - }) +export const getAttachmentDisplayFileName = (message: T.Chat.MessageAttachment) => { + return message.deviceType === 'desktop' + ? message.fileName + : `${message.inlineVideoPlayable ? 'Video' : 'Image'} from mobile` +} + +export const getAttachmentPreviewSize = ( + message: T.Chat.MessageAttachment, + useSquareFallback = false +) => { + const {fileURL, previewHeight, previewWidth} = message + let {previewURL} = message + let {height, width} = Chat.clampImageSize(previewWidth, previewHeight, maxWidth, maxHeight) + // This is mostly a sanity check and also allows us to handle HEIC even though the go side doesn't + // understand. + if (useSquareFallback && (height === 0 || width === 0)) { + height = 320 + width = 320 + } + if (!previewURL) { + previewURL = fileURL + } + return {height, previewURL, width} +} + +export const Title = ({message}: {message: T.Chat.MessageAttachment}) => { + const title = message.decoratedText?.stringValue() ?? message.title const styleOverride = Kb.Styles.isMobile ? {paragraph: {backgroundColor: Kb.Styles.globalColors.black_05_on_white}} @@ -194,14 +207,7 @@ export const Title = () => { ) } -const CollapseIcon = ({isWhite}: {isWhite: boolean}) => { - const ordinal = useOrdinal() - const isCollapsed = Chat.useChatContext(s => { - const m = s.messageMap.get(ordinal) - const message = m?.type === 'attachment' ? m : missingMessage - const {isCollapsed} = message - return isCollapsed - }) +const CollapseIcon = ({isCollapsed, isWhite}: {isCollapsed: boolean; isWhite: boolean}) => { return ( <Kb.Icon style={isWhite ? styles.collapseLabelWhite : undefined} @@ -227,8 +233,7 @@ const styles = Kb.Styles.styleSheetCreate(() => ({ }, })) -const useCollapseAction = () => { - const ordinal = useOrdinal() +const useCollapseAction = (ordinal: T.Chat.Ordinal) => { const toggleMessageCollapse = Chat.useChatContext(s => s.dispatch.toggleMessageCollapse) const onCollapse = () => { toggleMessageCollapse(T.Chat.numberToMessageID(T.Chat.ordinalToNumber(ordinal)), ordinal) @@ -237,57 +242,23 @@ const useCollapseAction = () => { } // not showing this for now -const useCollapseIconDesktop = (isWhite: boolean) => { - const onCollapse = useCollapseAction() +const useCollapseIconDesktop = (ordinal: T.Chat.Ordinal, isCollapsed: boolean, isWhite: boolean) => { + const onCollapse = useCollapseAction(ordinal) return ( <Kb.ClickableBox2 onClick={onCollapse}> <Kb.Box2 alignSelf="flex-start" direction="horizontal" gap="xtiny"> - <CollapseIcon isWhite={isWhite} /> + <CollapseIcon isCollapsed={isCollapsed} isWhite={isWhite} /> </Kb.Box2> </Kb.ClickableBox2> ) } -const useCollapseIconMobile = (_isWhite: boolean) => null +const useCollapseIconMobile = (_ordinal: T.Chat.Ordinal, _isCollapsed: boolean, _isWhite: boolean) => null export const useCollapseIcon = C.isMobile ? useCollapseIconMobile : useCollapseIconDesktop -export const useAttachmentState = () => { - const ordinal = useOrdinal() - const {attachmentPreviewSelect, fileName, isCollapsed, isEditing, showTitle, submitState, transferProgress, transferState} = - Chat.useChatContext( - C.useShallow(s => { - const m = s.messageMap.get(ordinal) - const message = m?.type === 'attachment' ? m : missingMessage - const {isCollapsed, title, fileName: fileNameRaw, transferProgress} = message - const {deviceType, inlineVideoPlayable, transferState, submitState} = message - const isEditing = s.editing === ordinal - const showTitle = !!title - const fileName = - deviceType === 'desktop' ? fileNameRaw : `${inlineVideoPlayable ? 'Video' : 'Image'} from mobile` - - return {attachmentPreviewSelect: s.dispatch.attachmentPreviewSelect, fileName, isCollapsed, isEditing, showTitle, submitState, transferProgress, transferState} - }) - ) - const openFullscreen = () => { - Keyboard.dismiss() - attachmentPreviewSelect(ordinal) - } - - return { - fileName, - isCollapsed, - isEditing, - openFullscreen, - showTitle, - submitState, - transferProgress, - transferState, - } -} - -export const Collapsed = () => { - const onCollapse = useCollapseAction() - const collapseIcon = useCollapseIcon(false) +export const Collapsed = ({isCollapsed, ordinal}: {isCollapsed: boolean; ordinal: T.Chat.Ordinal}) => { + const onCollapse = useCollapseAction(ordinal) + const collapseIcon = useCollapseIcon(ordinal, isCollapsed, false) return ( <Kb.Box2 direction="horizontal" fullWidth={true}> <Kb.Text type="BodyTiny" onClick={onCollapse}> diff --git a/shared/chat/conversation/messages/attachment/video/index.tsx b/shared/chat/conversation/messages/attachment/video/index.tsx index bc81e8f504c8..a59994e134a0 100644 --- a/shared/chat/conversation/messages/attachment/video/index.tsx +++ b/shared/chat/conversation/messages/attachment/video/index.tsx @@ -1,27 +1,37 @@ import * as React from 'react' import * as Kb from '@/common-adapters' +import * as Chat from '@/stores/chat' +import * as T from '@/constants/types' import VideoImpl from './videoimpl' import { Title, - useAttachmentState, Collapsed, useCollapseIcon, Transferring, TransferIcon, ShowToastAfterSaving, + getAttachmentDisplayFileName, } from '../shared' +import {Keyboard} from 'react-native' type Props = { + message: T.Chat.MessageAttachment + ordinal: T.Chat.Ordinal showPopup: () => void } function Video(p: Props) { - const {showPopup} = p - const r = useAttachmentState() - const {transferState, transferProgress, submitState} = r - const {fileName, isCollapsed, showTitle, openFullscreen} = r + const {message, ordinal, showPopup} = p + const {isCollapsed, submitState, title, transferProgress, transferState} = message + const attachmentPreviewSelect = Chat.useChatContext(s => s.dispatch.attachmentPreviewSelect) + const fileName = getAttachmentDisplayFileName(message) + const showTitle = !!title + const openFullscreen = () => { + Keyboard.dismiss() + attachmentPreviewSelect(ordinal) + } const containerStyle = styles.container - const collapseIcon = useCollapseIcon(false) + const collapseIcon = useCollapseIcon(ordinal, isCollapsed, false) const filename = Kb.Styles.isMobile || !fileName ? null : ( <Kb.Box2 direction="horizontal" alignSelf="flex-start" gap="xtiny"> @@ -52,21 +62,22 @@ function Video(p: Props) { > <ShowToastAfterSaving transferState={transferState} toastTargetRef={toastTargetRef} /> <VideoImpl + message={message} openFullscreen={openFullscreen} showPopup={showPopup} allowPlay={transferState !== 'uploading' && submitState !== 'pending'} /> - {showTitle ? <Title /> : null} + {showTitle ? <Title message={message} /> : null} <Transferring transferState={transferState} ratio={transferProgress} /> </Kb.Box2> - <TransferIcon style={Kb.Styles.isMobile ? styles.transferIcon : undefined} /> + <TransferIcon message={message} ordinal={ordinal} style={Kb.Styles.isMobile ? styles.transferIcon : undefined} /> </Kb.Box2> </> ) return ( <Kb.Box2 direction="vertical" fullWidth={true} relative={true} style={containerStyle} alignItems="flex-start"> - {isCollapsed ? <Collapsed /> : content} + {isCollapsed ? <Collapsed isCollapsed={isCollapsed} ordinal={ordinal} /> : content} </Kb.Box2> ) } diff --git a/shared/chat/conversation/messages/attachment/video/use-state.tsx b/shared/chat/conversation/messages/attachment/video/use-state.tsx deleted file mode 100644 index 6234a9ea7b2b..000000000000 --- a/shared/chat/conversation/messages/attachment/video/use-state.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as C from '@/constants' -import * as Chat from '@/stores/chat' -import {useOrdinal} from '@/chat/conversation/messages/ids-context' -import {missingMessage, maxWidth, maxHeight} from '../shared' - -export const useState = () => { - const ordinal = useOrdinal() - return Chat.useChatContext( - C.useShallow(s => { - const m = s.messageMap.get(ordinal) - const message = m?.type === 'attachment' ? m : missingMessage - const {previewURL, previewHeight, previewWidth} = message - const {fileURL, downloadPath, transferState, videoDuration} = message - const {height, width} = Chat.clampImageSize(previewWidth, previewHeight, maxWidth, maxHeight) - return {downloadPath, height, previewURL, transferState, url: fileURL, videoDuration, width} - }) - ) -} diff --git a/shared/chat/conversation/messages/attachment/video/videoimpl.d.ts b/shared/chat/conversation/messages/attachment/video/videoimpl.d.ts index 90c8d64f1432..ebf4aa6f607a 100644 --- a/shared/chat/conversation/messages/attachment/video/videoimpl.d.ts +++ b/shared/chat/conversation/messages/attachment/video/videoimpl.d.ts @@ -1,8 +1,10 @@ import type * as React from 'react' +import type * as T from '@/constants/types' export type Props = { openFullscreen: () => void showPopup: () => void allowPlay: boolean + message: T.Chat.MessageAttachment } declare const VideoImpl: (p: Props) => React.ReactNode export default VideoImpl diff --git a/shared/chat/conversation/messages/attachment/video/videoimpl.desktop.tsx b/shared/chat/conversation/messages/attachment/video/videoimpl.desktop.tsx index 64436c40b305..6376ab95b414 100644 --- a/shared/chat/conversation/messages/attachment/video/videoimpl.desktop.tsx +++ b/shared/chat/conversation/messages/attachment/video/videoimpl.desktop.tsx @@ -1,13 +1,13 @@ import * as Kb from '@/common-adapters' import * as React from 'react' import type {Props} from './videoimpl' -import {useState} from './use-state' -import {maxWidth, maxHeight} from '../shared' +import {getAttachmentPreviewSize, maxWidth, maxHeight} from '../shared' // its important we use explicit height/width so we never CLS while loading const VideoImpl = (p: Props) => { - const {openFullscreen, allowPlay} = p - const {previewURL, height, width, url, videoDuration} = useState() + const {allowPlay, message, openFullscreen} = p + const {fileURL: url, videoDuration} = message + const {previewURL, height, width} = getAttachmentPreviewSize(message) const [showPoster, setShowPoster] = React.useState(true) const [lastUrl, setLastUrl] = React.useState(url) diff --git a/shared/chat/conversation/messages/attachment/video/videoimpl.native.tsx b/shared/chat/conversation/messages/attachment/video/videoimpl.native.tsx index 250735259dbe..aa2107c32175 100644 --- a/shared/chat/conversation/messages/attachment/video/videoimpl.native.tsx +++ b/shared/chat/conversation/messages/attachment/video/videoimpl.native.tsx @@ -1,15 +1,16 @@ import * as React from 'react' import * as Kb from '@/common-adapters' -import {useState} from './use-state' import {ShowToastAfterSaving} from '../shared' import {useVideoPlayer, VideoView} from 'expo-video' import {useEventListener} from 'expo' import {Pressable} from 'react-native' import type {Props} from './videoimpl' +import {getAttachmentPreviewSize} from '../shared' const VideoImpl = (p: Props) => { - const {allowPlay, showPopup} = p - const {previewURL, height, width, url, transferState, videoDuration} = useState() + const {allowPlay, message, showPopup} = p + const {fileURL: url, transferState, videoDuration} = message + const {previewURL, height, width} = getAttachmentPreviewSize(message) const sourceUri = `${url}&contentforce=true` const player = useVideoPlayer(sourceUri, pl => { diff --git a/shared/chat/conversation/messages/attachment/wrapper.tsx b/shared/chat/conversation/messages/attachment/wrapper.tsx index a605c122d5bd..5357b8576410 100644 --- a/shared/chat/conversation/messages/attachment/wrapper.tsx +++ b/shared/chat/conversation/messages/attachment/wrapper.tsx @@ -2,52 +2,68 @@ import type AudioAttachmentType from './audio' import type FileAttachmentType from './file' import type ImageAttachmentType from './image' import type VideoAttachmentType from './video' -import {WrapperMessage, useWrapperMessage, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' export function WrapperAttachmentAudio(p: Props) { const {ordinal, isCenteredHighlight = false} = p - const wrapper = useWrapperMessage(ordinal, isCenteredHighlight) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData + if (message.type !== 'attachment') { + return null + } const {default: AudioAttachment} = require('./audio') as {default: typeof AudioAttachmentType} return ( <WrapperMessage {...p} {...wrapper}> - <AudioAttachment /> + <AudioAttachment message={message} /> </WrapperMessage> ) } export function WrapperAttachmentFile(p: Props) { const {ordinal, isCenteredHighlight = false} = p - const wrapper = useWrapperMessage(ordinal, isCenteredHighlight) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) const {showPopup} = wrapper + const {message, isEditing} = wrapper.messageData + if (message.type !== 'attachment') { + return null + } const {default: FileAttachment} = require('./file') as {default: typeof FileAttachmentType} return ( <WrapperMessage {...p} {...wrapper}> - <FileAttachment showPopup={showPopup} /> + <FileAttachment isEditing={isEditing} message={message} ordinal={ordinal} showPopup={showPopup} /> </WrapperMessage> ) } export function WrapperAttachmentVideo(p: Props) { const {ordinal, isCenteredHighlight = false} = p - const wrapper = useWrapperMessage(ordinal, isCenteredHighlight) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) const {showPopup} = wrapper + const {message} = wrapper.messageData + if (message.type !== 'attachment') { + return null + } const {default: VideoAttachment} = require('./video') as {default: typeof VideoAttachmentType} return ( <WrapperMessage {...p} {...wrapper}> - <VideoAttachment showPopup={showPopup} /> + <VideoAttachment message={message} ordinal={ordinal} showPopup={showPopup} /> </WrapperMessage> ) } export function WrapperAttachmentImage(p: Props) { const {ordinal, isCenteredHighlight = false} = p - const wrapper = useWrapperMessage(ordinal, isCenteredHighlight) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) const {showPopup} = wrapper + const {message} = wrapper.messageData + if (message.type !== 'attachment') { + return null + } const {default: ImageAttachment} = require('./image') as {default: typeof ImageAttachmentType} return ( <WrapperMessage {...p} {...wrapper}> - <ImageAttachment showPopup={showPopup} /> + <ImageAttachment message={message} ordinal={ordinal} showPopup={showPopup} /> </WrapperMessage> ) } diff --git a/shared/chat/conversation/messages/text/bottom.tsx b/shared/chat/conversation/messages/text/bottom.tsx index a42ef72f9778..d6540e0bfc0d 100644 --- a/shared/chat/conversation/messages/text/bottom.tsx +++ b/shared/chat/conversation/messages/text/bottom.tsx @@ -1,26 +1,41 @@ import type CoinFlipType from './coinflip' import type UnfurlListType from './unfurl/unfurl-list' import type UnfurlPromptListType from './unfurl/prompt-list/container' +import type * as T from '@/constants/types' type Props = { + author: string + conversationIDKey: T.Chat.ConversationIDKey hasUnfurlPrompts: boolean hasUnfurlList: boolean hasCoinFlip: boolean + messageID: T.Chat.MessageID + unfurls?: T.Chat.UnfurlMap } export const useBottom = (data: Props) => { - return <WrapperTextBottom hasCoinFlip={data.hasCoinFlip} hasUnfurlList={data.hasUnfurlList} hasUnfurlPrompts={data.hasUnfurlPrompts} /> + return ( + <WrapperTextBottom + author={data.author} + conversationIDKey={data.conversationIDKey} + hasCoinFlip={data.hasCoinFlip} + hasUnfurlList={data.hasUnfurlList} + hasUnfurlPrompts={data.hasUnfurlPrompts} + messageID={data.messageID} + unfurls={data.unfurls} + /> + ) } const WrapperTextBottom = function WrapperTextBottom(p: Props) { - const {hasUnfurlPrompts, hasUnfurlList, hasCoinFlip} = p + const {author, conversationIDKey, hasUnfurlPrompts, hasUnfurlList, hasCoinFlip, messageID, unfurls} = p const unfurlPrompts = (() => { if (hasUnfurlPrompts) { const {default: UnfurlPromptList} = require('./unfurl/prompt-list/container') as { default: typeof UnfurlPromptListType } - return <UnfurlPromptList /> + return <UnfurlPromptList messageID={messageID} /> } return null })() @@ -28,7 +43,7 @@ const WrapperTextBottom = function WrapperTextBottom(p: Props) { const unfurlList = (() => { const {default: UnfurlList} = require('./unfurl/unfurl-list') as {default: typeof UnfurlListType} if (hasUnfurlList) { - return <UnfurlList key="UnfurlList" /> + return <UnfurlList author={author} conversationIDKey={conversationIDKey} key="UnfurlList" unfurls={unfurls} /> } return null })() diff --git a/shared/chat/conversation/messages/text/unfurl/prompt-list/container.tsx b/shared/chat/conversation/messages/text/unfurl/prompt-list/container.tsx index afc8741958ac..8f52867ca53e 100644 --- a/shared/chat/conversation/messages/text/unfurl/prompt-list/container.tsx +++ b/shared/chat/conversation/messages/text/unfurl/prompt-list/container.tsx @@ -1,21 +1,15 @@ import * as C from '@/constants' import * as Chat from '@/stores/chat' -import {useOrdinal} from '@/chat/conversation/messages/ids-context' import * as T from '@/constants/types' import * as Kb from '@/common-adapters' import Prompt from './prompt' -const noMessageID = T.Chat.numberToMessageID(0) - -function UnfurlPromptListContainer() { - const ordinal = useOrdinal() - const {unfurlResolvePrompt, messageID, promptDomains} = Chat.useChatContext( +function UnfurlPromptListContainer({messageID}: {messageID: T.Chat.MessageID}) { + const {unfurlResolvePrompt, promptDomains} = Chat.useChatContext( C.useShallow(s => { - const message = s.messageMap.get(ordinal) - const messageID = message?.type === 'text' ? message.id : noMessageID const unfurlResolvePrompt = s.dispatch.unfurlResolvePrompt const promptDomains = s.unfurlPrompt.get(messageID) - return {messageID, promptDomains, unfurlResolvePrompt} + return {promptDomains, unfurlResolvePrompt} }) ) const _setPolicy = (domain: string, result: T.RPCChat.UnfurlPromptResult) => { diff --git a/shared/chat/conversation/messages/text/unfurl/unfurl-list/generic.tsx b/shared/chat/conversation/messages/text/unfurl/unfurl-list/generic.tsx index d28ca29110bb..319c03af6a5c 100644 --- a/shared/chat/conversation/messages/text/unfurl/unfurl-list/generic.tsx +++ b/shared/chat/conversation/messages/text/unfurl/unfurl-list/generic.tsx @@ -1,75 +1,42 @@ -import * as C from '@/constants' -import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters/index' import * as T from '@/constants/types' import UnfurlImage from './image' -import {useOrdinal} from '@/chat/conversation/messages/ids-context' import {formatTimeForMessages} from '@/util/timestamp' -import {getUnfurlInfo, useActions} from './use-state' - -function UnfurlGeneric(p: {idx: number}) { - const {idx} = p - const ordinal = useOrdinal() - - const data = Chat.useChatContext( - C.useShallow(s => { - const {unfurl, isCollapsed, unfurlMessageID, youAreAuthor} = getUnfurlInfo(s, ordinal, idx) - if (unfurl?.unfurlType !== T.RPCChat.UnfurlType.generic) { - return null - } - const {generic} = unfurl - const {description, publishTime, favicon, media, siteName, title, url} = generic - const {height, width, isVideo, url: mediaUrl} = media || {height: 0, isVideo: false, url: '', width: 0} - const showImageOnSide = - !Kb.Styles.isMobile && height >= width && !isVideo && (title.length > 0 || !!description) - const imageLocation = isCollapsed - ? 'collapsed' - : showImageOnSide - ? 'side' - : width > 0 && height > 0 - ? 'bottom' - : 'none' - - return { - description: description || undefined, - favicon: favicon?.url, - height, - imageLocation, - isCollapsed, - isVideo, - mediaUrl, - publishTime: publishTime ? publishTime * 1000 : 0, - siteName, - title, - unfurlMessageID, - url, - width, - youAreAuthor, - } - }) - ) +import {useActions} from './use-state' +function UnfurlGeneric(p: { + author: string + conversationIDKey: T.Chat.ConversationIDKey + ordinal: T.Chat.Ordinal + unfurlInfo: T.RPCChat.UIMessageUnfurlInfo + youAreAuthor: boolean +}) { + const {ordinal, unfurlInfo, youAreAuthor} = p + const {isCollapsed, unfurl, unfurlMessageID} = unfurlInfo + if (unfurl.unfurlType !== T.RPCChat.UnfurlType.generic || unfurl.generic.mapInfo) { + return null + } + const {generic} = unfurl + const {description, publishTime, favicon, media, siteName, title, url} = generic + const {height, width, isVideo, url: mediaUrl} = media || {height: 0, isVideo: false, url: '', width: 0} + const showImageOnSide = + !Kb.Styles.isMobile && height >= width && !isVideo && (title.length > 0 || !!description) + const imageLocation = isCollapsed ? 'collapsed' : showImageOnSide ? 'side' : width > 0 && height > 0 ? 'bottom' : 'none' const {onClose, onToggleCollapse} = useActions( - data?.youAreAuthor ?? false, - T.Chat.numberToMessageID(data?.unfurlMessageID ?? 0), + youAreAuthor, + T.Chat.numberToMessageID(unfurlMessageID), ordinal ) - - const titleUrlProps = Kb.useClickURL(data?.url ?? '') - - if (!data) return null - - const {description, favicon, height, isCollapsed, isVideo, publishTime} = data - const {siteName, title, url, width, imageLocation, mediaUrl} = data + const titleUrlProps = Kb.useClickURL(url) const publisher = ( <Kb.Box2 style={styles.siteNameContainer} gap="tiny" fullWidth={true} direction="horizontal"> - {favicon ? <Kb.Image src={favicon} style={styles.favicon} /> : null} + {favicon?.url ? <Kb.Image src={favicon.url} style={styles.favicon} /> : null} <Kb.BoxGrow> <Kb.Text type="BodySmall" lineClamp={1}> {siteName} {publishTime ? ( - <Kb.Text type="BodySmall"> • Published {formatTimeForMessages(publishTime)}</Kb.Text> + <Kb.Text type="BodySmall"> • Published {formatTimeForMessages(publishTime * 1000)}</Kb.Text> ) : null} </Kb.Text> </Kb.BoxGrow> @@ -108,13 +75,13 @@ function UnfurlGeneric(p: {idx: number}) { imageLocation === 'bottom' ? ( <Kb.Box2 direction="vertical" fullWidth={true}> <UnfurlImage - url={mediaUrl || ''} + url={mediaUrl} linkURL={url} - height={height || 0} - width={width || 0} + height={height} + width={width} widthPadding={Kb.Styles.isMobile ? Kb.Styles.globalMargins.tiny : undefined} style={styles.bottomImage} - isVideo={isVideo || false} + isVideo={isVideo} autoplayVideo={false} /> </Kb.Box2> diff --git a/shared/chat/conversation/messages/text/unfurl/unfurl-list/giphy.tsx b/shared/chat/conversation/messages/text/unfurl/unfurl-list/giphy.tsx index 19a93923633a..3d6a5d3ae2cf 100644 --- a/shared/chat/conversation/messages/text/unfurl/unfurl-list/giphy.tsx +++ b/shared/chat/conversation/messages/text/unfurl/unfurl-list/giphy.tsx @@ -1,55 +1,36 @@ -import * as C from '@/constants' -import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters/index' import UnfurlImage from './image' import * as T from '@/constants/types' -import {useOrdinal} from '@/chat/conversation/messages/ids-context' -import {getUnfurlInfo, useActions} from './use-state' - -function UnfurlGiphy(p: {idx: number}) { - const {idx} = p - const ordinal = useOrdinal() - - const data = Chat.useChatContext( - C.useShallow(s => { - const {unfurl, isCollapsed, unfurlMessageID, youAreAuthor} = getUnfurlInfo(s, ordinal, idx) - if (unfurl?.unfurlType !== T.RPCChat.UnfurlType.giphy) { - return null - } - const {giphy} = unfurl - const {favicon, image, video} = giphy - const {height, isVideo, url, width} = video || image || {height: 0, isVideo: false, url: '', width: 0} - - return { - favicon: favicon?.url, - height, - isCollapsed, - isVideo, - unfurlMessageID, - url, - width, - youAreAuthor, - } - }) - ) +import {useActions} from './use-state' +function UnfurlGiphy(p: { + author: string + conversationIDKey: T.Chat.ConversationIDKey + ordinal: T.Chat.Ordinal + unfurlInfo: T.RPCChat.UIMessageUnfurlInfo + youAreAuthor: boolean +}) { + const {ordinal, unfurlInfo, youAreAuthor} = p + const {isCollapsed, unfurl, unfurlMessageID} = unfurlInfo + if (unfurl.unfurlType !== T.RPCChat.UnfurlType.giphy) { + return null + } + const {giphy} = unfurl + const {favicon, image, video} = giphy + const {height, isVideo, url, width} = video || image || {height: 0, isVideo: false, url: '', width: 0} const {onClose, onToggleCollapse} = useActions( - data?.youAreAuthor ?? false, - T.Chat.numberToMessageID(data?.unfurlMessageID ?? 0), + youAreAuthor, + T.Chat.numberToMessageID(unfurlMessageID), ordinal ) - if (data === null) return null - - const {favicon, isCollapsed, isVideo, url, width, height} = data - return ( <Kb.Box2 style={styles.container} gap="tiny" direction="horizontal"> {!Kb.Styles.isMobile && <Kb.Box2 direction="horizontal" style={styles.quoteContainer} />} <Kb.Box2 style={styles.innerContainer} gap="xtiny" direction="vertical"> <Kb.Box2 style={styles.siteNameContainer} gap="tiny" fullWidth={true} direction="horizontal" justifyContent="space-between"> <Kb.Box2 direction="horizontal" gap="tiny"> - {favicon ? <Kb.Image src={favicon} style={styles.favicon} /> : null} + {favicon?.url ? <Kb.Image src={favicon.url} style={styles.favicon} /> : null} <Kb.Text type="BodySmall"> Giphy </Kb.Text> diff --git a/shared/chat/conversation/messages/text/unfurl/unfurl-list/index.tsx b/shared/chat/conversation/messages/text/unfurl/unfurl-list/index.tsx index bb404d92acd7..dde60932c1b6 100644 --- a/shared/chat/conversation/messages/text/unfurl/unfurl-list/index.tsx +++ b/shared/chat/conversation/messages/text/unfurl/unfurl-list/index.tsx @@ -1,5 +1,3 @@ -import * as C from '@/constants' -import * as Chat from '@/stores/chat' import * as T from '@/constants/types' import type * as React from 'react' import UnfurlGeneric from './generic' @@ -7,30 +5,14 @@ import UnfurlGiphy from './giphy' import UnfurlMap from './map' import * as Kb from '@/common-adapters' import {useOrdinal} from '@/chat/conversation/messages/ids-context' +import {useCurrentUserState} from '@/stores/current-user' -export type UnfurlListItem = { - unfurl: T.RPCChat.UnfurlDisplay - url: string - isCollapsed: boolean - onClose?: () => void - onCollapse: () => void -} - -export type ListProps = { - isAuthor: boolean - author?: string - toggleMessagePopup: () => void - unfurls: Array<UnfurlListItem> -} - -export type UnfurlProps = { - isAuthor: boolean - author?: string - isCollapsed: boolean - onClose?: () => void - onCollapse: () => void - toggleMessagePopup: () => void - unfurl: T.RPCChat.UnfurlDisplay +type UnfurlItemProps = { + author: string + conversationIDKey: T.Chat.ConversationIDKey + ordinal: T.Chat.Ordinal + unfurlInfo: T.RPCChat.UIMessageUnfurlInfo + youAreAuthor: boolean } const styles = Kb.Styles.styleSheetCreate( @@ -49,34 +31,51 @@ const styles = Kb.Styles.styleSheetCreate( type UnfurlRenderType = 'generic' | 'map' | 'giphy' -const renderTypeToClass = new Map<UnfurlRenderType, React.ComponentType<{idx: number}>>([ +const renderTypeToClass = new Map<UnfurlRenderType, React.ComponentType<UnfurlItemProps>>([ ['generic', UnfurlGeneric], ['map', UnfurlMap], ['giphy', UnfurlGiphy], ]) -function UnfurlListContainer() { +function UnfurlListContainer({ + author, + conversationIDKey, + unfurls, +}: { + author: string + conversationIDKey: T.Chat.ConversationIDKey + unfurls?: T.Chat.UnfurlMap +}) { const ordinal = useOrdinal() - const unfurlTypes: Array<UnfurlRenderType | 'none'> = Chat.useChatContext( - C.useShallow(s => - [...(s.messageMap.get(ordinal)?.unfurls?.values() ?? [])].map(u => { - const ut = u.unfurl.unfurlType + const you = useCurrentUserState(s => s.username) + const youAreAuthor = author === you + const items = [...(unfurls?.values() ?? [])] + return ( + <Kb.Box2 direction="vertical" gap="tiny" style={styles.container}> + {items.map((unfurlInfo, idx) => { + const ut = unfurlInfo.unfurl.unfurlType + let renderType: UnfurlRenderType | 'none' switch (ut) { case T.RPCChat.UnfurlType.giphy: - return 'giphy' + renderType = 'giphy' + break case T.RPCChat.UnfurlType.generic: - return u.unfurl.generic.mapInfo ? 'map' : 'generic' + renderType = unfurlInfo.unfurl.generic.mapInfo ? 'map' : 'generic' + break default: - return 'none' + renderType = 'none' } - }) - ) - ) - return ( - <Kb.Box2 direction="vertical" gap="tiny" style={styles.container}> - {unfurlTypes.map((ut, idx) => { - const Clazz = ut === 'none' ? null : renderTypeToClass.get(ut) - return Clazz ? <Clazz key={String(idx)} idx={idx} /> : null + const Clazz = renderType === 'none' ? null : renderTypeToClass.get(renderType) + return Clazz ? ( + <Clazz + author={author} + conversationIDKey={conversationIDKey} + key={String(idx)} + ordinal={ordinal} + unfurlInfo={unfurlInfo} + youAreAuthor={youAreAuthor} + /> + ) : null })} </Kb.Box2> ) diff --git a/shared/chat/conversation/messages/text/unfurl/unfurl-list/map.tsx b/shared/chat/conversation/messages/text/unfurl/unfurl-list/map.tsx index 2cfe5c6dad44..84c651c925b1 100644 --- a/shared/chat/conversation/messages/text/unfurl/unfurl-list/map.tsx +++ b/shared/chat/conversation/messages/text/unfurl/unfurl-list/map.tsx @@ -1,64 +1,34 @@ import * as C from '@/constants' -import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters/index' import * as T from '@/constants/types' import * as React from 'react' import UnfurlImage from './image' -import {useOrdinal} from '@/chat/conversation/messages/ids-context' import {formatDurationForLocation} from '@/util/timestamp' -import {getUnfurlInfo} from './use-state' import {maxWidth} from '@/chat/conversation/messages/attachment/shared' -function UnfurlMap(p: {idx: number}) { - const {idx} = p - const ordinal = useOrdinal() +function UnfurlMap(p: { + author: string + conversationIDKey: T.Chat.ConversationIDKey + ordinal: T.Chat.Ordinal + unfurlInfo: T.RPCChat.UIMessageUnfurlInfo + youAreAuthor: boolean +}) { + const {author, conversationIDKey, unfurlInfo, youAreAuthor} = p const navigateAppend = C.Router2.navigateAppend - - const data = Chat.useChatContext( - C.useShallow(s => { - const {unfurl, youAreAuthor, author} = getUnfurlInfo(s, ordinal, idx) - if (unfurl?.unfurlType !== T.RPCChat.UnfurlType.generic) { - return null - } - const {generic} = unfurl - const {mapInfo, media, url} = generic - const {coord, isLiveLocationDone, liveLocationEndTime, time} = mapInfo || { - coord: {accuracy: 0, lat: 0, lon: 0}, - isLiveLocationDone: false, - liveLocationEndTime: 0, - time: 0, - } - const {height, width, url: imageURL} = media || {height: 0, url: '', width: 0} - const {id} = s - - return { - author, - coord, - height, - id, - imageURL, - isLiveLocationDone, - liveLocationEndTime, - time, - url, - width, - youAreAuthor, - } - }) - ) - - if (!data) { + const {unfurl} = unfurlInfo + if (unfurl.unfurlType !== T.RPCChat.UnfurlType.generic || !unfurl.generic.mapInfo) { return null } - - const {author, url, coord, isLiveLocationDone, liveLocationEndTime} = data - const {height, width, imageURL, youAreAuthor, time, id} = data + const {generic} = unfurl + const {mapInfo, media, url} = generic + const {coord, isLiveLocationDone, liveLocationEndTime, time} = mapInfo + const {height, width, url: imageURL} = media || {height: 0, url: '', width: 0} const onViewMap = () => { navigateAppend({ name: 'chatUnfurlMapPopup', params: { author, - conversationIDKey: id, + conversationIDKey, coord, isAuthor: youAreAuthor, isLiveLocation: !!liveLocationEndTime && !isLiveLocationDone, diff --git a/shared/chat/conversation/messages/text/unfurl/unfurl-list/use-state.tsx b/shared/chat/conversation/messages/text/unfurl/unfurl-list/use-state.tsx index 666461afda8d..39d979e542f9 100644 --- a/shared/chat/conversation/messages/text/unfurl/unfurl-list/use-state.tsx +++ b/shared/chat/conversation/messages/text/unfurl/unfurl-list/use-state.tsx @@ -1,6 +1,5 @@ import * as Chat from '@/stores/chat' import type * as T from '@/constants/types' -import {useCurrentUserState} from '@/stores/current-user' export const useActions = (youAreAuthor: boolean, messageID: T.Chat.MessageID, ordinal: T.Chat.Ordinal) => { const unfurlRemove = Chat.useChatContext(s => s.dispatch.unfurlRemove) @@ -14,17 +13,3 @@ export const useActions = (youAreAuthor: boolean, messageID: T.Chat.MessageID, o return {onClose: youAreAuthor ? onClose : undefined, onToggleCollapse} } - -export const getUnfurlInfo = (state: Chat.ConvoState, ordinal: T.Chat.Ordinal, idx: number) => { - const message = state.messageMap.get(ordinal) - const author = message?.author - const you = useCurrentUserState.getState().username - const youAreAuthor = author === you - const unfurlInfo: undefined | T.RPCChat.UIMessageUnfurlInfo = [...(message?.unfurls?.values() ?? [])][idx] - - if (!unfurlInfo) - return {author: '', isCollapsed: false, unfurl: null, unfurlMessageID: 0, youAreAuthor: false} - - const {isCollapsed, unfurl, unfurlMessageID} = unfurlInfo - return {author, isCollapsed, unfurl, unfurlMessageID, youAreAuthor} -} diff --git a/shared/chat/conversation/messages/text/wrapper.tsx b/shared/chat/conversation/messages/text/wrapper.tsx index 67c4f9c4daef..a1a0771dd9f9 100644 --- a/shared/chat/conversation/messages/text/wrapper.tsx +++ b/shared/chat/conversation/messages/text/wrapper.tsx @@ -3,7 +3,7 @@ import * as Kb from '@/common-adapters' import {useReply} from './reply' import {useBottom} from './bottom' import {useOrdinal} from '../ids-context' -import {WrapperMessage, useWrapperMessage, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type {StyleOverride} from '@/common-adapters/markdown' import {sharedStyles} from '../shared-styles' @@ -46,13 +46,21 @@ function MessageMarkdown({style, text}: {style: Kb.Styles.StylesCrossPlatform; t function WrapperText(p: Props) { const {ordinal, isCenteredHighlight = false} = p - const wrapper = useWrapperMessage(ordinal, isCenteredHighlight) + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) const {messageData} = wrapper - const {isEditing, replyTo} = messageData + const {isEditing, message, replyTo} = messageData const {hasCoinFlip, hasUnfurlList, hasUnfurlPrompts, showCenteredHighlight, text, textType, type} = messageData - const bottomChildren = useBottom({hasCoinFlip, hasUnfurlList, hasUnfurlPrompts}) + const bottomChildren = useBottom({ + author: message.author, + conversationIDKey: message.conversationIDKey, + hasCoinFlip, + hasUnfurlList, + hasUnfurlPrompts, + messageID: message.id, + unfurls: message.type === 'text' ? message.unfurls : undefined, + }) const replyJump = Chat.useChatContext(s => s.dispatch.replyJump) const onReplyClick = () => { const id = replyTo?.id ?? 0 From e5bd8b5d03b09bd44de6736cd60d05fa65c95073 Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Wed, 8 Apr 2026 13:25:12 -0400 Subject: [PATCH 23/55] WIP --- .../conversation/messages/attachment/file.tsx | 2 +- .../messages/attachment/image/index.tsx | 2 +- .../messages/attachment/video/index.tsx | 2 +- .../messages/text/unfurl/unfurl-list/generic.tsx | 16 ++++++++-------- .../messages/text/unfurl/unfurl-list/giphy.tsx | 10 +++++----- .../messages/text/unfurl/unfurl-list/map.tsx | 5 ++++- 6 files changed, 20 insertions(+), 17 deletions(-) diff --git a/shared/chat/conversation/messages/attachment/file.tsx b/shared/chat/conversation/messages/attachment/file.tsx index b89b0fa7d307..223db9e84d87 100644 --- a/shared/chat/conversation/messages/attachment/file.tsx +++ b/shared/chat/conversation/messages/attachment/file.tsx @@ -95,7 +95,7 @@ function FileContainer(p: OwnProps) { !!transferState && transferState !== 'remoteUploading' && transferState !== 'mobileSaving' const errorMsg = transferErrMsg || '' - const fileName = _fileName ?? '' + const fileName = _fileName const isSaltpackFile = !!fileName && isPathSaltpack(fileName) const onShowInFinder = !C.isMobile && downloadPath ? _onShowInFinder : undefined const showMessageMenu = p.showPopup diff --git a/shared/chat/conversation/messages/attachment/image/index.tsx b/shared/chat/conversation/messages/attachment/image/index.tsx index 2e0ca548591d..6f433768822b 100644 --- a/shared/chat/conversation/messages/attachment/image/index.tsx +++ b/shared/chat/conversation/messages/attachment/image/index.tsx @@ -1,7 +1,7 @@ import * as Kb from '@/common-adapters' import * as React from 'react' import * as Chat from '@/stores/chat' -import * as T from '@/constants/types' +import type * as T from '@/constants/types' import ImageImpl from './imageimpl' import { getAttachmentDisplayFileName, diff --git a/shared/chat/conversation/messages/attachment/video/index.tsx b/shared/chat/conversation/messages/attachment/video/index.tsx index a59994e134a0..750be423ad16 100644 --- a/shared/chat/conversation/messages/attachment/video/index.tsx +++ b/shared/chat/conversation/messages/attachment/video/index.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import * as Kb from '@/common-adapters' import * as Chat from '@/stores/chat' -import * as T from '@/constants/types' +import type * as T from '@/constants/types' import VideoImpl from './videoimpl' import { Title, diff --git a/shared/chat/conversation/messages/text/unfurl/unfurl-list/generic.tsx b/shared/chat/conversation/messages/text/unfurl/unfurl-list/generic.tsx index 319c03af6a5c..3c7850a47e78 100644 --- a/shared/chat/conversation/messages/text/unfurl/unfurl-list/generic.tsx +++ b/shared/chat/conversation/messages/text/unfurl/unfurl-list/generic.tsx @@ -13,21 +13,21 @@ function UnfurlGeneric(p: { }) { const {ordinal, unfurlInfo, youAreAuthor} = p const {isCollapsed, unfurl, unfurlMessageID} = unfurlInfo - if (unfurl.unfurlType !== T.RPCChat.UnfurlType.generic || unfurl.generic.mapInfo) { + const {onClose, onToggleCollapse} = useActions( + youAreAuthor, + T.Chat.numberToMessageID(unfurlMessageID), + ordinal + ) + const generic = unfurl.unfurlType === T.RPCChat.UnfurlType.generic ? unfurl.generic : undefined + const titleUrlProps = Kb.useClickURL(generic?.mapInfo ? '' : (generic?.url ?? '')) + if (!generic || generic.mapInfo) { return null } - const {generic} = unfurl const {description, publishTime, favicon, media, siteName, title, url} = generic const {height, width, isVideo, url: mediaUrl} = media || {height: 0, isVideo: false, url: '', width: 0} const showImageOnSide = !Kb.Styles.isMobile && height >= width && !isVideo && (title.length > 0 || !!description) const imageLocation = isCollapsed ? 'collapsed' : showImageOnSide ? 'side' : width > 0 && height > 0 ? 'bottom' : 'none' - const {onClose, onToggleCollapse} = useActions( - youAreAuthor, - T.Chat.numberToMessageID(unfurlMessageID), - ordinal - ) - const titleUrlProps = Kb.useClickURL(url) const publisher = ( <Kb.Box2 style={styles.siteNameContainer} gap="tiny" fullWidth={true} direction="horizontal"> diff --git a/shared/chat/conversation/messages/text/unfurl/unfurl-list/giphy.tsx b/shared/chat/conversation/messages/text/unfurl/unfurl-list/giphy.tsx index 3d6a5d3ae2cf..05d35522cb29 100644 --- a/shared/chat/conversation/messages/text/unfurl/unfurl-list/giphy.tsx +++ b/shared/chat/conversation/messages/text/unfurl/unfurl-list/giphy.tsx @@ -12,17 +12,17 @@ function UnfurlGiphy(p: { }) { const {ordinal, unfurlInfo, youAreAuthor} = p const {isCollapsed, unfurl, unfurlMessageID} = unfurlInfo + const {onClose, onToggleCollapse} = useActions( + youAreAuthor, + T.Chat.numberToMessageID(unfurlMessageID), + ordinal + ) if (unfurl.unfurlType !== T.RPCChat.UnfurlType.giphy) { return null } const {giphy} = unfurl const {favicon, image, video} = giphy const {height, isVideo, url, width} = video || image || {height: 0, isVideo: false, url: '', width: 0} - const {onClose, onToggleCollapse} = useActions( - youAreAuthor, - T.Chat.numberToMessageID(unfurlMessageID), - ordinal - ) return ( <Kb.Box2 style={styles.container} gap="tiny" direction="horizontal"> diff --git a/shared/chat/conversation/messages/text/unfurl/unfurl-list/map.tsx b/shared/chat/conversation/messages/text/unfurl/unfurl-list/map.tsx index 84c651c925b1..2173db6e3002 100644 --- a/shared/chat/conversation/messages/text/unfurl/unfurl-list/map.tsx +++ b/shared/chat/conversation/messages/text/unfurl/unfurl-list/map.tsx @@ -16,11 +16,14 @@ function UnfurlMap(p: { const {author, conversationIDKey, unfurlInfo, youAreAuthor} = p const navigateAppend = C.Router2.navigateAppend const {unfurl} = unfurlInfo - if (unfurl.unfurlType !== T.RPCChat.UnfurlType.generic || !unfurl.generic.mapInfo) { + if (unfurl.unfurlType !== T.RPCChat.UnfurlType.generic) { return null } const {generic} = unfurl const {mapInfo, media, url} = generic + if (!mapInfo) { + return null + } const {coord, isLiveLocationDone, liveLocationEndTime, time} = mapInfo const {height, width, url: imageURL} = media || {height: 0, url: '', width: 0} const onViewMap = () => { From ead9819e3ca7897496b8c815c999f227776c0a96 Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Wed, 8 Apr 2026 14:14:29 -0400 Subject: [PATCH 24/55] WIP --- PLAN.md | 2 +- .../conversation/messages/text/wrapper.tsx | 4 +- .../conversation/messages/wrapper/wrapper.tsx | 221 +++++++++++------- shared/chat/inbox/index.desktop.tsx | 49 +++- shared/engine/rpc-transport.ts | 36 +++ 5 files changed, 216 insertions(+), 96 deletions(-) diff --git a/PLAN.md b/PLAN.md index 2647e6acf00b..f3ce82fbd70a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -49,7 +49,7 @@ Primary files: ### 3. Row Subscription Consolidation -- [ ] Move toward one main convo-store subscription per mounted row. +- [x] Move toward one main convo-store subscription per mounted row. - [x] Push row data down as props instead of reopening store subscriptions in reply, reactions, emoji, send-indicator, exploding-meta, and similar children. - [x] Audit attachment and unfurl helpers for repeated `messageMap.get(ordinal)` selectors. - [ ] Keep selectors narrow and stable when a child still needs to subscribe directly. diff --git a/shared/chat/conversation/messages/text/wrapper.tsx b/shared/chat/conversation/messages/text/wrapper.tsx index a1a0771dd9f9..59602f283178 100644 --- a/shared/chat/conversation/messages/text/wrapper.tsx +++ b/shared/chat/conversation/messages/text/wrapper.tsx @@ -1,4 +1,3 @@ -import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' import {useReply} from './reply' import {useBottom} from './bottom' @@ -61,10 +60,9 @@ function WrapperText(p: Props) { messageID: message.id, unfurls: message.type === 'text' ? message.unfurls : undefined, }) - const replyJump = Chat.useChatContext(s => s.dispatch.replyJump) const onReplyClick = () => { const id = replyTo?.id ?? 0 - id && replyJump(id) + id && messageData.replyJump(id) } const reply = useReply(replyTo, onReplyClick) diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index 64dd75e226d2..6988d598d05d 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -62,6 +62,21 @@ type AuthorProps = { showUsername: string } +type RowActions = Pick< + ConvoState['dispatch'], + 'messageDelete' | 'messageRetry' | 'replyJump' | 'setEditing' | 'setReplyTo' | 'toggleMessageReaction' +> + +type EditCancelRetryData = { + failureDescription: string + outboxID?: T.Chat.OutboxID +} + +const getRowActions = (dispatch: ConvoState['dispatch']): RowActions => { + const {messageDelete, messageRetry, replyJump, setEditing, setReplyTo, toggleMessageReaction} = dispatch + return {messageDelete, messageRetry, replyJump, setEditing, setReplyTo, toggleMessageReaction} +} + function AuthorSection(p: AuthorProps) { const {author, botAlias, isAdhocBot, teamID, teamType, teamname, timestamp, showUsername} = p @@ -149,47 +164,35 @@ function AuthorSection(p: AuthorProps) { ) } -const useAuthorData = (ordinal: T.Chat.Ordinal) => - Chat.useChatContext( - C.useShallow(s => { - const showUsername = s.showUsernameMap.get(ordinal) ?? '' - if (!showUsername) { - return { - author: '', - botAlias: '', - isAdhocBot: false, - showUsername, - teamID: '' as T.Teams.TeamID, - teamType: 'adhoc' as T.Chat.TeamType, - teamname: '', - timestamp: 0, - } - } - const m = s.messageMap.get(ordinal) ?? missingMessage - const {author, timestamp} = m - const {teamID, botAliases, teamType, teamname} = s.meta - const participantInfoNames = s.participants.name - const isAdhocBot = - teamType === 'adhoc' && participantInfoNames.length > 0 - ? !participantInfoNames.includes(author) - : false - return { - author, - botAlias: botAliases[author] ?? '', - isAdhocBot, - showUsername, - teamID, - teamType, - teamname, - timestamp, - } - }) - ) +const getAuthorData = ( + message: T.Chat.Message, + meta: ConvoState['meta'], + participants: ConvoState['participants'], + showUsername: string +): AuthorProps | null => { + if (!showUsername) { + return null + } + const {author, timestamp} = message + const {teamID, botAliases, teamType, teamname} = meta + const participantInfoNames = participants.name + const isAdhocBot = + teamType === 'adhoc' && participantInfoNames.length > 0 ? !participantInfoNames.includes(author) : false + return { + author, + botAlias: botAliases[author] ?? '', + isAdhocBot, + showUsername, + teamID, + teamType, + teamname, + timestamp, + } +} -function AuthorHeader({ordinal}: {ordinal: T.Chat.Ordinal}) { - const data = useAuthorData(ordinal) - if (!data.showUsername) return null - return <AuthorSection {...data} /> +function AuthorHeader({authorData}: {authorData: AuthorProps | null}) { + if (!authorData) return null + return <AuthorSection {...authorData} /> } const getEcrType = (message: T.Chat.Message, you: string) => { @@ -313,6 +316,20 @@ const getCommonMessageData = ({ } } +const getEditCancelRetryData = ( + ecrType: EditCancelRetryType, + message: T.Chat.Message +): EditCancelRetryData => { + const reason = message.errorReason ?? '' + return { + failureDescription: + ecrType === EditCancelRetryType.NOACTION + ? reason + : `This message failed to send${reason ? '. ' : ''}${capitalize(reason)}`, + outboxID: message.outboxID, + } +} + // Combined selector hook that fetches all common wrapper data in a single subscription. export const useMessageData = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: boolean) => { const you = useCurrentUserState(s => s.username) @@ -320,7 +337,7 @@ export const useMessageData = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: bo return Chat.useChatContext( C.useShallow(s => { const message = s.messageMap.get(ordinal) ?? missingMessage - return getCommonMessageData({ + const commonData = getCommonMessageData({ accountsInfoMap: s.accountsInfoMap, editing: s.editing, isCenteredHighlight, @@ -331,6 +348,12 @@ export const useMessageData = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: bo unfurlPrompt: s.unfurlPrompt, you, }) + return { + ...commonData, + ...getEditCancelRetryData(commonData.ecrType, message), + ...getRowActions(s.dispatch), + authorData: getAuthorData(message, s.meta, s.participants, s.showUsernameMap.get(ordinal) ?? ''), + } }) ) } @@ -341,18 +364,22 @@ const useMessageDataWithMessage = (ordinal: T.Chat.Ordinal, isCenteredHighlight? return Chat.useChatContext( C.useShallow(s => { const message = s.messageMap.get(ordinal) ?? missingMessage + const commonData = getCommonMessageData({ + accountsInfoMap: s.accountsInfoMap, + editing: s.editing, + isCenteredHighlight, + message, + messageCenterOrdinal: s.messageCenterOrdinal, + ordinal, + paymentStatusMap: Chat.useChatState.getState().paymentStatusMap, + unfurlPrompt: s.unfurlPrompt, + you, + }) return { - ...getCommonMessageData({ - accountsInfoMap: s.accountsInfoMap, - editing: s.editing, - isCenteredHighlight, - message, - messageCenterOrdinal: s.messageCenterOrdinal, - ordinal, - paymentStatusMap: Chat.useChatState.getState().paymentStatusMap, - unfurlPrompt: s.unfurlPrompt, - you, - }), + ...commonData, + ...getEditCancelRetryData(commonData.ecrType, message), + ...getRowActions(s.dispatch), + authorData: getAuthorData(message, s.meta, s.participants, s.showUsernameMap.get(ordinal) ?? ''), message, } }) @@ -430,22 +457,29 @@ type TSProps = { hasUnfurlList: boolean isHighlighted: boolean messageKey: string + messageDelete: RowActions['messageDelete'] + messageRetry: RowActions['messageRetry'] ordinal: T.Chat.Ordinal + outboxID?: T.Chat.OutboxID popupAnchor: React.RefObject<Kb.MeasureRef | null> reactions?: T.Chat.Reactions sendIndicatorFailed: boolean sendIndicatorID: number sendIndicatorSent: boolean + setEditing: RowActions['setEditing'] + setReplyTo: RowActions['setReplyTo'] setShowingPicker: (s: boolean) => void shouldShowPopup: boolean showCoinsIcon: boolean showExplodingCountdown: boolean + failureDescription: string showRevoked: boolean showSendIndicator: boolean showingPicker: boolean showingPopup: boolean showPopup: () => void submitState?: T.Chat.Message['submitState'] + toggleMessageReaction: RowActions['toggleMessageReaction'] type: T.Chat.MessageType } @@ -521,16 +555,24 @@ function TextAndSiblings(p: TSProps) { {content} <BottomSide ecrType={ecrType} + exploding={exploding} + failureDescription={p.failureDescription} hasBeenEdited={hasBeenEdited} - hasUnfurlList={hasUnfurlList} - messageType={type} hasReactions={hasReactions} - ordinal={p.ordinal} bottomChildren={bottomChildren} canShowReactionsPopup={canShowReactionsPopup} + hasUnfurlList={hasUnfurlList} + messageDelete={p.messageDelete} + messageRetry={p.messageRetry} + messageType={type} + ordinal={p.ordinal} + outboxID={p.outboxID} reactions={reactions} + setEditing={p.setEditing} + setReplyTo={p.setReplyTo} setShowingPicker={setShowingPicker} showingPopup={showingPopup} + toggleMessageReaction={p.toggleMessageReaction} /> </NormalWrapper> </Kb.Box2> @@ -564,31 +606,17 @@ enum EditCancelRetryType { EDIT_CANCEL, RETRY_CANCEL, } -function EditCancelRetry(p: {ecrType: EditCancelRetryType}) { - const {ecrType} = p +function EditCancelRetry(p: { + ecrType: EditCancelRetryType + exploding: boolean + failureDescription: string + messageDelete: RowActions['messageDelete'] + messageRetry: RowActions['messageRetry'] + outboxID?: T.Chat.OutboxID + setEditing: RowActions['setEditing'] +}) { + const {ecrType, exploding, failureDescription, messageDelete, messageRetry, outboxID, setEditing} = p const ordinal = useOrdinal() - const {failureDescription, outboxID, exploding, messageDelete, messageRetry, setEditing} = - Chat.useChatContext( - C.useShallow(s => { - const m = s.messageMap.get(ordinal) - const outboxID = m?.outboxID - const reason = m?.errorReason ?? '' - const exploding = m?.exploding ?? false - const failureDescription = - ecrType === EditCancelRetryType.NOACTION - ? reason - : `This message failed to send${reason ? '. ' : ''}${capitalize(reason)}` - const {messageDelete, messageRetry, setEditing} = s.dispatch - return { - exploding, - failureDescription, - messageDelete, - messageRetry, - outboxID, - setEditing, - } - }) - ) const onCancel = () => { messageDelete(ordinal) } @@ -647,19 +675,27 @@ type BProps = { setShowingPicker: (s: boolean) => void bottomChildren?: React.ReactNode canShowReactionsPopup: boolean + exploding: boolean + failureDescription: string hasBeenEdited: boolean hasReactions: boolean hasUnfurlList: boolean messageType: T.Chat.MessageType + messageDelete: RowActions['messageDelete'] + messageRetry: RowActions['messageRetry'] ordinal: T.Chat.Ordinal + outboxID?: T.Chat.OutboxID reactions?: T.Chat.Reactions + setEditing: RowActions['setEditing'] + setReplyTo: RowActions['setReplyTo'] + toggleMessageReaction: RowActions['toggleMessageReaction'] ecrType: EditCancelRetryType } // reactions function BottomSide(p: BProps) { const {showingPopup, setShowingPicker, bottomChildren, canShowReactionsPopup, ecrType, hasBeenEdited} = p - const {hasReactions, hasUnfurlList, messageType, ordinal, reactions} = p - const {setReplyTo, toggleMessageReaction} = Chat.useChatContext(s => s.dispatch) + const {exploding, failureDescription, hasReactions, hasUnfurlList, messageType, ordinal, reactions} = p + const {messageDelete, messageRetry, outboxID, setEditing, setReplyTo, toggleMessageReaction} = p const onReact = (emoji: string) => { toggleMessageReaction(ordinal, emoji) @@ -698,7 +734,17 @@ function BottomSide(p: BProps) { <> {edited} {bottomChildren ?? null} - {ecrType !== EditCancelRetryType.NONE ? <EditCancelRetry ecrType={ecrType} /> : null} + {ecrType !== EditCancelRetryType.NONE ? ( + <EditCancelRetry + ecrType={ecrType} + exploding={exploding} + failureDescription={failureDescription} + messageDelete={messageDelete} + messageRetry={messageRetry} + outboxID={outboxID} + setEditing={setEditing} + /> + ) : null} {reactionsRow} {desktopReactionsPopup} </> @@ -824,6 +870,8 @@ export function WrapperMessage(p: WrapperMessageProps) { const {reactions, sendIndicatorFailed, sendIndicatorID, sendIndicatorSent, submitState} = mdata const {showSendIndicator, showRevoked, showExplodingCountdown, exploding} = mdata const {showCoinsIcon, botname, hasBeenEdited, hasUnfurlList, showCenteredHighlight} = mdata + const {failureDescription, messageDelete, messageRetry, outboxID} = mdata + const {setEditing, setReplyTo, toggleMessageReaction} = mdata const isHighlighted = showCenteredHighlight || isEditing const tsprops = { @@ -837,18 +885,24 @@ export function WrapperMessage(p: WrapperMessageProps) { explodedBy: mdata.explodedBy, explodesAt, exploding, + failureDescription, forceExplodingRetainer, hasBeenEdited, hasReactions, hasUnfurlList, isHighlighted, messageKey, + messageDelete, + messageRetry, ordinal, + outboxID, popupAnchor, reactions, sendIndicatorFailed, sendIndicatorID, sendIndicatorSent, + setEditing, + setReplyTo, setShowingPicker, shouldShowPopup, showCoinsIcon, @@ -859,6 +913,7 @@ export function WrapperMessage(p: WrapperMessageProps) { showingPicker, showingPopup, submitState, + toggleMessageReaction, type, } @@ -867,7 +922,7 @@ export function WrapperMessage(p: WrapperMessageProps) { return ( <MessageContext value={messageContext}> <Kb.Box2 direction="vertical" relative={true} fullWidth={true}> - <AuthorHeader ordinal={ordinal} /> + <AuthorHeader authorData={mdata.authorData} /> <TextAndSiblings {...tsprops} /> </Kb.Box2> {popup} diff --git a/shared/chat/inbox/index.desktop.tsx b/shared/chat/inbox/index.desktop.tsx index 019ad39fad18..993ddfd9fe7c 100644 --- a/shared/chat/inbox/index.desktop.tsx +++ b/shared/chat/inbox/index.desktop.tsx @@ -213,15 +213,46 @@ function Inbox(props: InboxProps) { const didInitialUnboxRef = React.useRef(false) React.useEffect(() => { if (didInitialUnboxRef.current || rows.length === 0) return - didInitialUnboxRef.current = true - const toUnbox = rows.reduce<Array<T.Chat.ConversationIDKey>>((arr, r) => { - if ((r.type === 'small' || r.type === 'big') && r.conversationIDKey) { - arr.push(r.conversationIDKey) - } - return arr - }, []) - if (toUnbox.length > 0) { - onUntrustedInboxVisible(toUnbox) + let cancelled = false + let frame = 0 + let attempts = 0 + + const queueInitialUnbox = () => { + frame = requestAnimationFrame(() => { + if (cancelled || didInitialUnboxRef.current) { + return + } + + const state = listRef.current?.getState() + const start = Math.max(0, state?.start ?? -1) + const end = Math.min(rows.length - 1, state?.end ?? -1) + + if (end < start) { + attempts++ + if (attempts < 10) { + queueInitialUnbox() + } + return + } + + didInitialUnboxRef.current = true + const toUnbox = rows.slice(start, end + 1).reduce<Array<T.Chat.ConversationIDKey>>((arr, r) => { + if ((r.type === 'small' || r.type === 'big') && r.conversationIDKey) { + arr.push(r.conversationIDKey) + } + return arr + }, []) + if (toUnbox.length > 0) { + onUntrustedInboxVisible(toUnbox) + } + }) + } + + queueInitialUnbox() + + return () => { + cancelled = true + cancelAnimationFrame(frame) } }, [rows, onUntrustedInboxVisible]) diff --git a/shared/engine/rpc-transport.ts b/shared/engine/rpc-transport.ts index 8bad614fc886..42dac7b5c8ce 100644 --- a/shared/engine/rpc-transport.ts +++ b/shared/engine/rpc-transport.ts @@ -78,6 +78,14 @@ type PendingItem = const queueMax = 1000 const maxFrameSize = 64 * 1024 * 1024 // 64 MB; rejects oversized frames before buffering payload bytes +const TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX = true + +const tempRPCDebugLog = (event: string, details: object) => { + if (!TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX || !__DEV__) { + return + } + console.warn(`[TEMP requestInboxUnbox rpc debug] ${event}`, details) +} const makeTransportError = (name: ErrorName): ErrorType => ({ code: errors[name], @@ -130,6 +138,7 @@ export abstract class RPCTransport { private _connectCallback?: ConnectDisconnectCB private _disconnectCallback?: ConnectDisconnectCB private _invocations = new Map<number, InvocationCallback>() + private _invocationMethods = new Map<number, string>() private _pending = new Array<PendingItem>() private _seqid = 1 @@ -205,7 +214,14 @@ export abstract class RPCTransport { protected failOutstanding(err: unknown, data: unknown) { const invocations = this._invocations + const invocationMethods = this._invocationMethods this._invocations = new Map() + this._invocationMethods = new Map() + invocationMethods.forEach((method, seqid) => { + if (method === 'chat.1.local.requestInboxUnbox') { + tempRPCDebugLog('failOutstanding', {data, err, seqid}) + } + }) invocations.forEach(cb => cb(err, data)) } @@ -272,6 +288,12 @@ export abstract class RPCTransport { console.warn('Invalid invoke packet received') return } + if (method === 'chat.1.chatUi.chatInboxConversation') { + tempRPCDebugLog('incoming invoke chatInboxConversation', { + param0: param[0], + seqid, + }) + } const payload = { method, param: param as Array<{sessionID?: number}>, @@ -302,11 +324,17 @@ export abstract class RPCTransport { console.warn('Invalid response packet received') return } + const method = this._invocationMethods.get(seqid) const cb = this._invocations.get(seqid) if (!cb) { + tempRPCDebugLog('response with no invocation', {error, method, result, seqid}) return } this._invocations.delete(seqid) + this._invocationMethods.delete(seqid) + if (method === 'chat.1.local.requestInboxUnbox') { + tempRPCDebugLog('response matched invocation', {error, result, seqid}) + } cb(this.unwrapIncomingError(error), result) return } @@ -316,6 +344,10 @@ export abstract class RPCTransport { console.warn('Invalid cancel packet received') return } + const method = this._invocationMethods.get(seqid) + if (method === 'chat.1.local.requestInboxUnbox') { + tempRPCDebugLog('incoming cancel', {seqid}) + } this._incomingRPCCallback?.({ method: '', param: [], @@ -387,6 +419,10 @@ export abstract class RPCTransport { const seqid = this._seqid this._seqid += 1 this._invocations.set(seqid, cb) + this._invocationMethods.set(seqid, method) + if (method === 'chat.1.local.requestInboxUnbox') { + tempRPCDebugLog('invokeNow', {args: args[0], seqid}) + } this.writeMessage([MESSAGE_TYPE_INVOKE, seqid, method, args]) } From af72dd9329eb5f6e79b53219af2df153a384b698 Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Wed, 8 Apr 2026 14:35:49 -0400 Subject: [PATCH 25/55] WIP --- shared/engine/rpc-transport.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shared/engine/rpc-transport.ts b/shared/engine/rpc-transport.ts index 42dac7b5c8ce..57453df55bbf 100644 --- a/shared/engine/rpc-transport.ts +++ b/shared/engine/rpc-transport.ts @@ -430,9 +430,11 @@ export abstract class RPCTransport { return { cancelled: false, error: err => { + tempRPCDebugLog('send response error', {err, seqid}) this.send([MESSAGE_TYPE_RESPONSE, seqid, err, null]) }, result: result => { + tempRPCDebugLog('send response result', {result, seqid}) this.send([MESSAGE_TYPE_RESPONSE, seqid, null, result]) }, seqid, From b14b0ea235b305828e485146c4f4621bc731e34d Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Wed, 8 Apr 2026 15:52:04 -0400 Subject: [PATCH 26/55] WIP --- shared/chat/inbox/index.desktop.tsx | 49 ++++++----------------------- 1 file changed, 9 insertions(+), 40 deletions(-) diff --git a/shared/chat/inbox/index.desktop.tsx b/shared/chat/inbox/index.desktop.tsx index 993ddfd9fe7c..019ad39fad18 100644 --- a/shared/chat/inbox/index.desktop.tsx +++ b/shared/chat/inbox/index.desktop.tsx @@ -213,46 +213,15 @@ function Inbox(props: InboxProps) { const didInitialUnboxRef = React.useRef(false) React.useEffect(() => { if (didInitialUnboxRef.current || rows.length === 0) return - let cancelled = false - let frame = 0 - let attempts = 0 - - const queueInitialUnbox = () => { - frame = requestAnimationFrame(() => { - if (cancelled || didInitialUnboxRef.current) { - return - } - - const state = listRef.current?.getState() - const start = Math.max(0, state?.start ?? -1) - const end = Math.min(rows.length - 1, state?.end ?? -1) - - if (end < start) { - attempts++ - if (attempts < 10) { - queueInitialUnbox() - } - return - } - - didInitialUnboxRef.current = true - const toUnbox = rows.slice(start, end + 1).reduce<Array<T.Chat.ConversationIDKey>>((arr, r) => { - if ((r.type === 'small' || r.type === 'big') && r.conversationIDKey) { - arr.push(r.conversationIDKey) - } - return arr - }, []) - if (toUnbox.length > 0) { - onUntrustedInboxVisible(toUnbox) - } - }) - } - - queueInitialUnbox() - - return () => { - cancelled = true - cancelAnimationFrame(frame) + didInitialUnboxRef.current = true + const toUnbox = rows.reduce<Array<T.Chat.ConversationIDKey>>((arr, r) => { + if ((r.type === 'small' || r.type === 'big') && r.conversationIDKey) { + arr.push(r.conversationIDKey) + } + return arr + }, []) + if (toUnbox.length > 0) { + onUntrustedInboxVisible(toUnbox) } }, [rows, onUntrustedInboxVisible]) From 2ef186dfe31e50d6a55cf097393fa808d3c6ef18 Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Wed, 8 Apr 2026 16:04:37 -0400 Subject: [PATCH 27/55] WIP --- shared/desktop/app/ipc-handlers.desktop.tsx | 8 ++++++++ shared/engine/index.platform.desktop.tsx | 14 ++++++++++++++ shared/engine/rpc-transport.ts | 2 ++ 3 files changed, 24 insertions(+) diff --git a/shared/desktop/app/ipc-handlers.desktop.tsx b/shared/desktop/app/ipc-handlers.desktop.tsx index f8d0dea9ec88..42c89fae51fd 100644 --- a/shared/desktop/app/ipc-handlers.desktop.tsx +++ b/shared/desktop/app/ipc-handlers.desktop.tsx @@ -190,6 +190,14 @@ export const setupIPCHandlers = (deps: { switch (action.type) { case 'engineSend': { const {buf} = action.payload + if (__DEV__) { + const [type, seqid, methodOrError] = buf + if (type === 1) { + console.warn('[TEMP requestInboxUnbox bridge debug] main received response', {buf, seqid}) + } else if (type === 0 && methodOrError === 'chat.1.local.requestInboxUnbox') { + console.warn('[TEMP requestInboxUnbox bridge debug] main received invoke', {buf, seqid}) + } + } deps.nodeEngine._rpcClient.transport.send(buf) return } diff --git a/shared/engine/index.platform.desktop.tsx b/shared/engine/index.platform.desktop.tsx index 59245bb20db5..7da2c1466aa4 100644 --- a/shared/engine/index.platform.desktop.tsx +++ b/shared/engine/index.platform.desktop.tsx @@ -9,6 +9,14 @@ import KB2 from '@/util/electron.desktop' const {engineSend, ipcRendererOn, mainWindowDispatchEngineIncoming} = KB2.functions const {isRenderer} = KB2.constants +const TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX = true + +const tempRPCBridgeLog = (event: string, details: object) => { + if (!TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX || !__DEV__) { + return + } + console.warn(`[TEMP requestInboxUnbox bridge debug] ${event}`, details) +} // used by node class NativeTransport extends TransportShared { @@ -135,6 +143,12 @@ class NativeTransport extends TransportShared { class ProxyNativeTransport extends LocalTransport { protected writeMessage(message: RPCMessage) { + const [type, seqid, methodOrError] = message + if (type === 1) { + tempRPCBridgeLog('renderer write response', {message, seqid}) + } else if (type === 0 && methodOrError === 'chat.1.local.requestInboxUnbox') { + tempRPCBridgeLog('renderer write invoke', {message, seqid}) + } engineSend?.(message) } } diff --git a/shared/engine/rpc-transport.ts b/shared/engine/rpc-transport.ts index 57453df55bbf..607d4c94e6b0 100644 --- a/shared/engine/rpc-transport.ts +++ b/shared/engine/rpc-transport.ts @@ -289,8 +289,10 @@ export abstract class RPCTransport { return } if (method === 'chat.1.chatUi.chatInboxConversation') { + const firstParam = param[0] as {sessionID?: number} | undefined tempRPCDebugLog('incoming invoke chatInboxConversation', { param0: param[0], + sessionID: firstParam?.sessionID, seqid, }) } From db173c750d0fc33ce04031ebadea801059f54ecb Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Wed, 8 Apr 2026 16:18:45 -0400 Subject: [PATCH 28/55] WIP --- shared/engine/rpc-transport.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shared/engine/rpc-transport.ts b/shared/engine/rpc-transport.ts index 607d4c94e6b0..cacf484402c1 100644 --- a/shared/engine/rpc-transport.ts +++ b/shared/engine/rpc-transport.ts @@ -79,12 +79,13 @@ type PendingItem = const queueMax = 1000 const maxFrameSize = 64 * 1024 * 1024 // 64 MB; rejects oversized frames before buffering payload bytes const TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX = true +const tempRPCDebugProcessType = typeof process !== 'undefined' ? process.type || 'unknown' : 'unknown' const tempRPCDebugLog = (event: string, details: object) => { if (!TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX || !__DEV__) { return } - console.warn(`[TEMP requestInboxUnbox rpc debug] ${event}`, details) + console.warn(`[TEMP requestInboxUnbox rpc debug][${tempRPCDebugProcessType}] ${event}`, details) } const makeTransportError = (name: ErrorName): ErrorType => ({ From c3c9a9bd6fc5660ffb584fa60ee170e65056dcb1 Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Wed, 8 Apr 2026 16:23:53 -0400 Subject: [PATCH 29/55] WIP --- shared/engine/index-impl.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/shared/engine/index-impl.tsx b/shared/engine/index-impl.tsx index 9c424f3e0302..d10f799dce7c 100644 --- a/shared/engine/index-impl.tsx +++ b/shared/engine/index-impl.tsx @@ -13,6 +13,14 @@ import {type RPCError, convertToError} from '@/util/errors' import type * as EngineGen from '@/constants/rpc' type WaitingKey = string | ReadonlyArray<string> +const TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX = true + +const tempInboxUnboxSessionLog = (event: string, details: object) => { + if (!TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX || !__DEV__) { + return + } + console.warn(`[TEMP requestInboxUnbox session debug] ${event}`, details) +} class Engine { _onConnectedCB: (c: boolean) => void @@ -220,6 +228,12 @@ class Engine { incomingCallMap, waitingKey, }) + if (method === 'chat.1.local.requestInboxUnbox') { + tempInboxUnboxSessionLog('session start', { + params, + sessionID: session.getId(), + }) + } session.start(method, params, callback) return session.getId() } @@ -263,6 +277,11 @@ class Engine { // Cleanup a session that ended _sessionEnded(session: Session) { + if (session._startMethod === 'chat.1.local.requestInboxUnbox') { + tempInboxUnboxSessionLog('session end', { + sessionID: session.getId(), + }) + } rpcLog({ extra: { sessionID: session.getId(), From 95caec208d72f777fbbe923bbe6d3ac841be23e5 Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Wed, 8 Apr 2026 16:30:39 -0400 Subject: [PATCH 30/55] WIP --- .../conversation/messages/wrapper/wrapper.tsx | 48 +++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index 6988d598d05d..612ecf99816a 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -72,6 +72,28 @@ type EditCancelRetryData = { outboxID?: T.Chat.OutboxID } +type FlatAuthorData = { + author: string + botAlias: string + isAdhocBot: boolean + showUsername: string + teamID: T.Teams.TeamID + teamType: T.Chat.TeamType + teamname: string + timestamp: number +} + +const emptyAuthorData: FlatAuthorData = { + author: '', + botAlias: '', + isAdhocBot: false, + showUsername: '', + teamID: '' as T.Teams.TeamID, + teamType: 'adhoc', + teamname: '', + timestamp: 0, +} + const getRowActions = (dispatch: ConvoState['dispatch']): RowActions => { const {messageDelete, messageRetry, replyJump, setEditing, setReplyTo, toggleMessageReaction} = dispatch return {messageDelete, messageRetry, replyJump, setEditing, setReplyTo, toggleMessageReaction} @@ -169,9 +191,9 @@ const getAuthorData = ( meta: ConvoState['meta'], participants: ConvoState['participants'], showUsername: string -): AuthorProps | null => { +): FlatAuthorData => { if (!showUsername) { - return null + return emptyAuthorData } const {author, timestamp} = message const {teamID, botAliases, teamType, teamname} = meta @@ -190,9 +212,9 @@ const getAuthorData = ( } } -function AuthorHeader({authorData}: {authorData: AuthorProps | null}) { - if (!authorData) return null - return <AuthorSection {...authorData} /> +function AuthorHeader(p: FlatAuthorData) { + if (!p.showUsername) return null + return <AuthorSection {...p} /> } const getEcrType = (message: T.Chat.Message, you: string) => { @@ -352,7 +374,7 @@ export const useMessageData = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: bo ...commonData, ...getEditCancelRetryData(commonData.ecrType, message), ...getRowActions(s.dispatch), - authorData: getAuthorData(message, s.meta, s.participants, s.showUsernameMap.get(ordinal) ?? ''), + ...getAuthorData(message, s.meta, s.participants, s.showUsernameMap.get(ordinal) ?? ''), } }) ) @@ -379,7 +401,7 @@ const useMessageDataWithMessage = (ordinal: T.Chat.Ordinal, isCenteredHighlight? ...commonData, ...getEditCancelRetryData(commonData.ecrType, message), ...getRowActions(s.dispatch), - authorData: getAuthorData(message, s.meta, s.participants, s.showUsernameMap.get(ordinal) ?? ''), + ...getAuthorData(message, s.meta, s.participants, s.showUsernameMap.get(ordinal) ?? ''), message, } }) @@ -872,6 +894,7 @@ export function WrapperMessage(p: WrapperMessageProps) { const {showCoinsIcon, botname, hasBeenEdited, hasUnfurlList, showCenteredHighlight} = mdata const {failureDescription, messageDelete, messageRetry, outboxID} = mdata const {setEditing, setReplyTo, toggleMessageReaction} = mdata + const {author, botAlias, isAdhocBot, showUsername, teamID, teamType, teamname, timestamp} = mdata const isHighlighted = showCenteredHighlight || isEditing const tsprops = { @@ -922,7 +945,16 @@ export function WrapperMessage(p: WrapperMessageProps) { return ( <MessageContext value={messageContext}> <Kb.Box2 direction="vertical" relative={true} fullWidth={true}> - <AuthorHeader authorData={mdata.authorData} /> + <AuthorHeader + author={author} + botAlias={botAlias} + isAdhocBot={isAdhocBot} + showUsername={showUsername} + teamID={teamID} + teamType={teamType} + teamname={teamname} + timestamp={timestamp} + /> <TextAndSiblings {...tsprops} /> </Kb.Box2> {popup} From b7944354675beba2141ac721dc16f4cea8638ed7 Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Wed, 8 Apr 2026 16:37:22 -0400 Subject: [PATCH 31/55] WIP --- shared/engine/rpc-transport.ts | 40 +++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/shared/engine/rpc-transport.ts b/shared/engine/rpc-transport.ts index cacf484402c1..4a9b51d408c2 100644 --- a/shared/engine/rpc-transport.ts +++ b/shared/engine/rpc-transport.ts @@ -75,6 +75,10 @@ export type RPCMessage = [number, ...Array<unknown>] type PendingItem = | {type: 'invoke'; method: string; args: [object]; cb: InvocationCallback} | {type: 'message'; message: RPCMessage} +type InvocationMeta = { + method: string + sessionID?: number +} const queueMax = 1000 const maxFrameSize = 64 * 1024 * 1024 // 64 MB; rejects oversized frames before buffering payload bytes @@ -139,7 +143,7 @@ export abstract class RPCTransport { private _connectCallback?: ConnectDisconnectCB private _disconnectCallback?: ConnectDisconnectCB private _invocations = new Map<number, InvocationCallback>() - private _invocationMethods = new Map<number, string>() + private _invocationMeta = new Map<number, InvocationMeta>() private _pending = new Array<PendingItem>() private _seqid = 1 @@ -215,12 +219,12 @@ export abstract class RPCTransport { protected failOutstanding(err: unknown, data: unknown) { const invocations = this._invocations - const invocationMethods = this._invocationMethods + const invocationMeta = this._invocationMeta this._invocations = new Map() - this._invocationMethods = new Map() - invocationMethods.forEach((method, seqid) => { + this._invocationMeta = new Map() + invocationMeta.forEach(({method, sessionID}, seqid) => { if (method === 'chat.1.local.requestInboxUnbox') { - tempRPCDebugLog('failOutstanding', {data, err, seqid}) + tempRPCDebugLog('failOutstanding', {data, err, seqid, sessionID}) } }) invocations.forEach(cb => cb(err, data)) @@ -327,16 +331,21 @@ export abstract class RPCTransport { console.warn('Invalid response packet received') return } - const method = this._invocationMethods.get(seqid) + const meta = this._invocationMeta.get(seqid) const cb = this._invocations.get(seqid) if (!cb) { - tempRPCDebugLog('response with no invocation', {error, method, result, seqid}) + tempRPCDebugLog('response with no invocation', {error, meta, result, seqid}) return } this._invocations.delete(seqid) - this._invocationMethods.delete(seqid) - if (method === 'chat.1.local.requestInboxUnbox') { - tempRPCDebugLog('response matched invocation', {error, result, seqid}) + this._invocationMeta.delete(seqid) + if (meta?.method === 'chat.1.local.requestInboxUnbox') { + tempRPCDebugLog('response matched invocation', { + error, + result, + seqid, + sessionID: meta.sessionID, + }) } cb(this.unwrapIncomingError(error), result) return @@ -347,9 +356,9 @@ export abstract class RPCTransport { console.warn('Invalid cancel packet received') return } - const method = this._invocationMethods.get(seqid) - if (method === 'chat.1.local.requestInboxUnbox') { - tempRPCDebugLog('incoming cancel', {seqid}) + const meta = this._invocationMeta.get(seqid) + if (meta?.method === 'chat.1.local.requestInboxUnbox') { + tempRPCDebugLog('incoming cancel', {seqid, sessionID: meta.sessionID}) } this._incomingRPCCallback?.({ method: '', @@ -422,9 +431,10 @@ export abstract class RPCTransport { const seqid = this._seqid this._seqid += 1 this._invocations.set(seqid, cb) - this._invocationMethods.set(seqid, method) + const firstArg = args[0] as {sessionID?: number} | undefined + this._invocationMeta.set(seqid, {method, sessionID: firstArg?.sessionID}) if (method === 'chat.1.local.requestInboxUnbox') { - tempRPCDebugLog('invokeNow', {args: args[0], seqid}) + tempRPCDebugLog('invokeNow', {args: args[0], seqid, sessionID: firstArg?.sessionID}) } this.writeMessage([MESSAGE_TYPE_INVOKE, seqid, method, args]) } From efe229fa586b4b4fc00a4a79b4998dcb2db3237e Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Wed, 8 Apr 2026 16:53:10 -0400 Subject: [PATCH 32/55] WIP --- shared/engine/index.platform.desktop.tsx | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/shared/engine/index.platform.desktop.tsx b/shared/engine/index.platform.desktop.tsx index 7da2c1466aa4..d8b73f033d23 100644 --- a/shared/engine/index.platform.desktop.tsx +++ b/shared/engine/index.platform.desktop.tsx @@ -61,6 +61,23 @@ class NativeTransport extends TransportShared { if (printRPCBytes) { logger.debug('[RPC] Read', m.length) } + if (__DEV__ && TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX) { + try { + const decoded = this.decodeMessageForDebug(m) + const [type, seqid] = decoded + if (type === 1) { + console.warn('[TEMP requestInboxUnbox daemon debug] node received response from daemon', { + decoded, + seqid, + }) + } else if (type === 0) { + console.warn('[TEMP requestInboxUnbox daemon debug] node received invoke from daemon', { + decoded, + seqid, + }) + } + } catch {} + } mainWindowDispatchEngineIncoming(m) } @@ -139,6 +156,16 @@ class NativeTransport extends TransportShared { this.connectOnce() }, 1000) } + + private decodeMessageForDebug(data: Uint8Array): RPCMessage { + const packet = this.copyForDebug(data) + const payload = require('@msgpack/msgpack').decode(packet.slice(5)) + return payload as RPCMessage + } + + private copyForDebug(data: Uint8Array) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + } } class ProxyNativeTransport extends LocalTransport { From 11aa6902ab9c9e642d0c4a91ac1be2bad86e3179 Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Wed, 8 Apr 2026 17:04:20 -0400 Subject: [PATCH 33/55] WIP --- shared/engine/index.platform.desktop.tsx | 176 ++++++++++++++++++++--- 1 file changed, 155 insertions(+), 21 deletions(-) diff --git a/shared/engine/index.platform.desktop.tsx b/shared/engine/index.platform.desktop.tsx index d8b73f033d23..a09a6e73f5ac 100644 --- a/shared/engine/index.platform.desktop.tsx +++ b/shared/engine/index.platform.desktop.tsx @@ -1,4 +1,5 @@ import type {Socket} from 'net' +import {decode} from '@msgpack/msgpack' import logger from '@/logger' import {TransportShared, LocalTransport, sharedCreateClient, rpcLog} from './transport-shared' import {socketPath} from '@/constants/platform.desktop' @@ -23,6 +24,9 @@ class NativeTransport extends TransportShared { private _socket?: Socket private _reconnectTimer?: ReturnType<typeof setTimeout> private _connecting = false + private _debugBufferedBytes = 0 + private _debugChunks = new Array<Uint8Array>() + private _debugChunkOffset = 0 constructor( incomingRPCCallback: IncomingRPCCallbackType, @@ -61,23 +65,7 @@ class NativeTransport extends TransportShared { if (printRPCBytes) { logger.debug('[RPC] Read', m.length) } - if (__DEV__ && TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX) { - try { - const decoded = this.decodeMessageForDebug(m) - const [type, seqid] = decoded - if (type === 1) { - console.warn('[TEMP requestInboxUnbox daemon debug] node received response from daemon', { - decoded, - seqid, - }) - } else if (type === 0) { - console.warn('[TEMP requestInboxUnbox daemon debug] node received invoke from daemon', { - decoded, - seqid, - }) - } - } catch {} - } + this.debugPacketizeData(m) mainWindowDispatchEngineIncoming(m) } @@ -157,10 +145,156 @@ class NativeTransport extends TransportShared { }, 1000) } - private decodeMessageForDebug(data: Uint8Array): RPCMessage { - const packet = this.copyForDebug(data) - const payload = require('@msgpack/msgpack').decode(packet.slice(5)) - return payload as RPCMessage + private debugPacketizeData(data: Uint8Array) { + if (!__DEV__ || !TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX) { + return + } + try { + this.appendDebugChunk(data) + while (this._debugBufferedBytes > 0) { + const firstByte = this.peekDebugByte() + if (firstByte === undefined) { + return + } + const headerLen = this.frameHeaderLength(firstByte) + if (!headerLen || this._debugBufferedBytes < headerLen) { + return + } + const header = this.copyDebugBufferedBytes(headerLen) + if (!header) { + return + } + const payloadLen = decode(header) + if (typeof payloadLen !== 'number' || payloadLen < 0) { + this.resetDebugPacketizer() + return + } + if (this._debugBufferedBytes < headerLen + payloadLen) { + return + } + this.consumeDebugBufferedBytes(headerLen) + const payloadBytes = this.copyDebugBufferedBytes(payloadLen) + if (!payloadBytes) { + return + } + const payload = decode(payloadBytes) as RPCMessage + this.consumeDebugBufferedBytes(payloadLen) + const [type, seqid] = payload + if (type === 1) { + console.warn('[TEMP requestInboxUnbox daemon debug] node received response from daemon', { + payload, + seqid, + }) + } else if (type === 0) { + console.warn('[TEMP requestInboxUnbox daemon debug] node received invoke from daemon', { + payload, + seqid, + }) + } + } + } catch { + this.resetDebugPacketizer() + } + } + + private frameHeaderLength(leadByte: number) { + if (leadByte < 0x80) { + return 1 + } + switch (leadByte) { + case 0xcc: + return 2 + case 0xcd: + return 3 + case 0xce: + return 5 + default: + return 0 + } + } + + private resetDebugPacketizer() { + this._debugBufferedBytes = 0 + this._debugChunks = [] + this._debugChunkOffset = 0 + } + + private appendDebugChunk(data: Uint8Array) { + const chunk = new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + if (!chunk.length) { + return + } + this._debugChunks.push(chunk) + this._debugBufferedBytes += chunk.length + } + + private peekDebugByte() { + const firstChunk = this._debugChunks[0] + if (!firstChunk) { + return undefined + } + return firstChunk[this._debugChunkOffset] + } + + private copyDebugBufferedBytes(length: number) { + if (length > this._debugBufferedBytes) { + return undefined + } + + const firstChunk = this._debugChunks[0] + if (firstChunk) { + const available = firstChunk.length - this._debugChunkOffset + if (available >= length) { + return firstChunk.slice(this._debugChunkOffset, this._debugChunkOffset + length) + } + } + + const out = new Uint8Array(length) + let outOffset = 0 + let remaining = length + let chunkIndex = 0 + let chunkOffset = this._debugChunkOffset + + while (remaining > 0) { + const chunk = this._debugChunks[chunkIndex] + if (!chunk) { + return undefined + } + + const available = chunk.length - chunkOffset + const toCopy = Math.min(remaining, available) + out.set(chunk.subarray(chunkOffset, chunkOffset + toCopy), outOffset) + outOffset += toCopy + remaining -= toCopy + chunkIndex += 1 + chunkOffset = 0 + } + + return out + } + + private consumeDebugBufferedBytes(length: number) { + let remaining = length + while (remaining > 0) { + const chunk = this._debugChunks[0] + if (!chunk) { + this._debugChunkOffset = 0 + this._debugBufferedBytes = 0 + return + } + + const available = chunk.length - this._debugChunkOffset + if (remaining < available) { + this._debugChunkOffset += remaining + this._debugBufferedBytes -= length + return + } + + remaining -= available + this._debugChunks.shift() + this._debugChunkOffset = 0 + } + this._debugBufferedBytes -= length } private copyForDebug(data: Uint8Array) { From 1afa564bbe610c25375e4a5e62d6394ee760eebb Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Wed, 8 Apr 2026 17:08:50 -0400 Subject: [PATCH 34/55] WIP --- shared/engine/index.platform.desktop.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/shared/engine/index.platform.desktop.tsx b/shared/engine/index.platform.desktop.tsx index a09a6e73f5ac..c8edf0b97734 100644 --- a/shared/engine/index.platform.desktop.tsx +++ b/shared/engine/index.platform.desktop.tsx @@ -45,6 +45,20 @@ class NativeTransport extends TransportShared { if (!this._socket) { throw new Error('write attempt with no active stream') } + const [type, seqid, methodOrError] = message + if (__DEV__ && TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX) { + if (type === 0 && methodOrError === 'chat.1.local.requestInboxUnbox') { + console.warn('[TEMP requestInboxUnbox daemon debug] node wrote invoke to daemon', { + message, + seqid, + }) + } else if (type === 1) { + console.warn('[TEMP requestInboxUnbox daemon debug] node wrote response to daemon', { + message, + seqid, + }) + } + } const framed = this.encodeMessage(message) if (printRPCBytes) { logger.debug('[RPC] Writing', framed.length) From 102d0debe1b9e1de2d3772e1cd78943e02877d7d Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Wed, 8 Apr 2026 17:25:25 -0400 Subject: [PATCH 35/55] WIP --- shared/engine/index.platform.desktop.tsx | 40 +++++++++++++++++++----- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/shared/engine/index.platform.desktop.tsx b/shared/engine/index.platform.desktop.tsx index c8edf0b97734..c19bd662f09f 100644 --- a/shared/engine/index.platform.desktop.tsx +++ b/shared/engine/index.platform.desktop.tsx @@ -19,6 +19,25 @@ const tempRPCBridgeLog = (event: string, details: object) => { console.warn(`[TEMP requestInboxUnbox bridge debug] ${event}`, details) } +const cloneChunkForIPC = (data: Uint8Array) => Uint8Array.from(data) + +const normalizeIncomingChunk = (data: unknown) => { + if (data instanceof Uint8Array) { + return cloneChunkForIPC(data) + } + if (ArrayBuffer.isView(data)) { + const view = data as ArrayBufferView + return new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)) + } + if (data instanceof ArrayBuffer) { + return new Uint8Array(data.slice(0)) + } + if (Array.isArray(data)) { + return Uint8Array.from(data) + } + return undefined +} + // used by node class NativeTransport extends TransportShared { private _socket?: Socket @@ -79,8 +98,11 @@ class NativeTransport extends TransportShared { if (printRPCBytes) { logger.debug('[RPC] Read', m.length) } - this.debugPacketizeData(m) - mainWindowDispatchEngineIncoming(m) + // Socket reads can arrive as Buffer-backed subarray views. Normalize them before crossing + // Electron IPC so the renderer packetizer always sees an exact standalone byte range. + const chunk = cloneChunkForIPC(m) + this.debugPacketizeData(chunk) + mainWindowDispatchEngineIncoming(chunk) } close() { @@ -310,10 +332,6 @@ class NativeTransport extends TransportShared { } this._debugBufferedBytes -= length } - - private copyForDebug(data: Uint8Array) { - return new Uint8Array(data.buffer, data.byteOffset, data.byteLength) - } } class ProxyNativeTransport extends LocalTransport { @@ -343,7 +361,15 @@ function createClient( // plumb back data from the node side ipcRendererOn?.('engineIncoming', (_e: unknown, data: unknown) => { try { - client.transport.packetizeData(data as Uint8Array) + const chunk = normalizeIncomingChunk(data) + if (!chunk) { + logger.error('>>>> rpcOnJs invalid engineIncoming chunk', { + constructorName: data && typeof data === 'object' ? data.constructor?.name : undefined, + type: typeof data, + }) + return + } + client.transport.packetizeData(chunk) } catch (e) { logger.error('>>>> rpcOnJs JS thrown!', e) } From a03a2df812e69f96052920dc856083d53cd23805 Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Wed, 8 Apr 2026 17:34:05 -0400 Subject: [PATCH 36/55] WIP --- shared/engine/index.platform.desktop.tsx | 150 +++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/shared/engine/index.platform.desktop.tsx b/shared/engine/index.platform.desktop.tsx index c19bd662f09f..05b42ca1a682 100644 --- a/shared/engine/index.platform.desktop.tsx +++ b/shared/engine/index.platform.desktop.tsx @@ -38,6 +38,152 @@ const normalizeIncomingChunk = (data: unknown) => { return undefined } +const createBridgeFrameLogger = (label: string) => { + let bufferedBytes = 0 + let chunks = new Array<Uint8Array>() + let chunkOffset = 0 + + const reset = () => { + bufferedBytes = 0 + chunks = [] + chunkOffset = 0 + } + + const frameHeaderLength = (leadByte: number) => { + if (leadByte < 0x80) { + return 1 + } + switch (leadByte) { + case 0xcc: + return 2 + case 0xcd: + return 3 + case 0xce: + return 5 + default: + return 0 + } + } + + const appendChunk = (data: Uint8Array) => { + if (!data.length) { + return + } + chunks.push(data) + bufferedBytes += data.length + } + + const peekByte = () => { + const firstChunk = chunks[0] + if (!firstChunk) { + return undefined + } + return firstChunk[chunkOffset] + } + + const copyBufferedBytes = (length: number) => { + if (length > bufferedBytes) { + return undefined + } + + const firstChunk = chunks[0] + if (firstChunk) { + const available = firstChunk.length - chunkOffset + if (available >= length) { + return firstChunk.slice(chunkOffset, chunkOffset + length) + } + } + + const out = new Uint8Array(length) + let outOffset = 0 + let remaining = length + let localChunkIndex = 0 + let localChunkOffset = chunkOffset + + while (remaining > 0) { + const chunk = chunks[localChunkIndex] + if (!chunk) { + return undefined + } + const available = chunk.length - localChunkOffset + const toCopy = Math.min(remaining, available) + out.set(chunk.subarray(localChunkOffset, localChunkOffset + toCopy), outOffset) + outOffset += toCopy + remaining -= toCopy + localChunkIndex += 1 + localChunkOffset = 0 + } + + return out + } + + const consumeBufferedBytes = (length: number) => { + let remaining = length + while (remaining > 0) { + const chunk = chunks[0] + if (!chunk) { + chunkOffset = 0 + bufferedBytes = 0 + return + } + const available = chunk.length - chunkOffset + if (remaining < available) { + chunkOffset += remaining + bufferedBytes -= length + return + } + remaining -= available + chunks.shift() + chunkOffset = 0 + } + bufferedBytes -= length + } + + return (data: Uint8Array) => { + if (!__DEV__ || !TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX) { + return + } + try { + appendChunk(data) + while (bufferedBytes > 0) { + const firstByte = peekByte() + if (firstByte === undefined) { + return + } + const headerLen = frameHeaderLength(firstByte) + if (!headerLen || bufferedBytes < headerLen) { + return + } + const header = copyBufferedBytes(headerLen) + if (!header) { + return + } + const payloadLen = decode(header) + if (typeof payloadLen !== 'number' || payloadLen < 0) { + reset() + return + } + if (bufferedBytes < headerLen + payloadLen) { + return + } + consumeBufferedBytes(headerLen) + const payloadBytes = copyBufferedBytes(payloadLen) + if (!payloadBytes) { + return + } + const payload = decode(payloadBytes) as RPCMessage + consumeBufferedBytes(payloadLen) + const [type, seqid] = payload + if (type === 1) { + tempRPCBridgeLog(label, {payload, seqid}) + } + } + } catch { + reset() + } + } +} + // used by node class NativeTransport extends TransportShared { private _socket?: Socket @@ -46,6 +192,7 @@ class NativeTransport extends TransportShared { private _debugBufferedBytes = 0 private _debugChunks = new Array<Uint8Array>() private _debugChunkOffset = 0 + private _bridgeToRendererLogger = createBridgeFrameLogger('node sent response chunk to renderer') constructor( incomingRPCCallback: IncomingRPCCallbackType, @@ -102,6 +249,7 @@ class NativeTransport extends TransportShared { // Electron IPC so the renderer packetizer always sees an exact standalone byte range. const chunk = cloneChunkForIPC(m) this.debugPacketizeData(chunk) + this._bridgeToRendererLogger(chunk) mainWindowDispatchEngineIncoming(chunk) } @@ -357,6 +505,7 @@ function createClient( const client = sharedCreateClient( new ProxyNativeTransport(incomingRPCCallback, connectCallback, disconnectCallback) ) + const bridgeFromNodeLogger = createBridgeFrameLogger('renderer received response chunk from node') // plumb back data from the node side ipcRendererOn?.('engineIncoming', (_e: unknown, data: unknown) => { @@ -369,6 +518,7 @@ function createClient( }) return } + bridgeFromNodeLogger(chunk) client.transport.packetizeData(chunk) } catch (e) { logger.error('>>>> rpcOnJs JS thrown!', e) From c398d7aad51055eb94e19ecd2aee32acd1d20f9f Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Wed, 8 Apr 2026 17:41:10 -0400 Subject: [PATCH 37/55] WIP --- shared/desktop/app/ipc-handlers.desktop.tsx | 12 ++--- shared/desktop/app/ipctypes.tsx | 4 +- shared/desktop/renderer/preload.desktop.tsx | 3 +- shared/engine/index.platform.desktop.tsx | 55 ++++++++++++++++++++- shared/util/electron.desktop.tsx | 5 +- 5 files changed, 62 insertions(+), 17 deletions(-) diff --git a/shared/desktop/app/ipc-handlers.desktop.tsx b/shared/desktop/app/ipc-handlers.desktop.tsx index 42c89fae51fd..8c4746e3b358 100644 --- a/shared/desktop/app/ipc-handlers.desktop.tsx +++ b/shared/desktop/app/ipc-handlers.desktop.tsx @@ -190,15 +190,11 @@ export const setupIPCHandlers = (deps: { switch (action.type) { case 'engineSend': { const {buf} = action.payload - if (__DEV__) { - const [type, seqid, methodOrError] = buf - if (type === 1) { - console.warn('[TEMP requestInboxUnbox bridge debug] main received response', {buf, seqid}) - } else if (type === 0 && methodOrError === 'chat.1.local.requestInboxUnbox') { - console.warn('[TEMP requestInboxUnbox bridge debug] main received invoke', {buf, seqid}) + ;( + deps.nodeEngine._rpcClient.transport as { + sendFramedBytes?: (data: Uint8Array) => boolean } - } - deps.nodeEngine._rpcClient.transport.send(buf) + ).sendFramedBytes?.(buf) return } case 'uninstallDokan': { diff --git a/shared/desktop/app/ipctypes.tsx b/shared/desktop/app/ipctypes.tsx index a6fbf9783d56..09e86ef80463 100644 --- a/shared/desktop/app/ipctypes.tsx +++ b/shared/desktop/app/ipctypes.tsx @@ -1,4 +1,4 @@ -import type {EngineRPCMessage, OpenDialogOptions, SaveDialogOptions} from '@/util/electron.desktop' +import type {OpenDialogOptions, SaveDialogOptions} from '@/util/electron.desktop' import type * as RPCTypes from '@/constants/rpc/rpc-gen' export type Action = @@ -79,4 +79,4 @@ export type Action = | {type: 'clipboardAvailableFormats'} | {type: 'installCachedDokan'} | {type: 'uninstallDokan'; payload: {execPath: string}} - | {type: 'engineSend'; payload: {buf: EngineRPCMessage}} + | {type: 'engineSend'; payload: {buf: Uint8Array}} diff --git a/shared/desktop/renderer/preload.desktop.tsx b/shared/desktop/renderer/preload.desktop.tsx index f02a5ed08af6..b4dcaf7eb2ee 100644 --- a/shared/desktop/renderer/preload.desktop.tsx +++ b/shared/desktop/renderer/preload.desktop.tsx @@ -1,7 +1,6 @@ import * as Electron from 'electron' import type {Actions} from '@/constants/remote-actions' import { - type EngineRPCMessage, injectPreload, type KB2, type OpenDialogOptions, @@ -73,7 +72,7 @@ if (isRenderer) { type: 'dumpNodeLogger', }) }, - engineSend: (buf: EngineRPCMessage) => { + engineSend: (buf: Uint8Array) => { ignorePromise(invoke({payload: {buf}, type: 'engineSend'})) }, exitApp: (code: number) => { diff --git a/shared/engine/index.platform.desktop.tsx b/shared/engine/index.platform.desktop.tsx index 05b42ca1a682..d5fe55633023 100644 --- a/shared/engine/index.platform.desktop.tsx +++ b/shared/engine/index.platform.desktop.tsx @@ -189,6 +189,7 @@ class NativeTransport extends TransportShared { private _socket?: Socket private _reconnectTimer?: ReturnType<typeof setTimeout> private _connecting = false + private _pendingFramedBytes = new Array<Uint8Array>() private _debugBufferedBytes = 0 private _debugChunks = new Array<Uint8Array>() private _debugChunkOffset = 0 @@ -226,6 +227,48 @@ class NativeTransport extends TransportShared { } } const framed = this.encodeMessage(message) + this.writeFramedBytes(framed) + } + + sendFramedBytes(data: Uint8Array) { + const chunk = cloneChunkForIPC(data) + const [type, seqid] = (() => { + try { + const payload = decode(chunk.slice(5)) as RPCMessage + return [payload[0], payload[1]] + } catch { + return [undefined, undefined] + } + })() + if (__DEV__ && TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX) { + if (type === 1) { + console.warn('[TEMP requestInboxUnbox bridge debug] main received response', {seqid}) + } else if (type === 0) { + const payload = (() => { + try { + return decode(chunk.slice(5)) as RPCMessage + } catch { + return undefined + } + })() + if (payload?.[2] === 'chat.1.local.requestInboxUnbox') { + console.warn('[TEMP requestInboxUnbox bridge debug] main received invoke', {seqid}) + } + } + } + if (this._socket) { + this.writeFramedBytes(chunk) + return true + } + if (this.isExplicitClose()) { + console.warn('send framed bytes after explicit close') + return false + } + this._pendingFramedBytes.push(chunk) + return true + } + + private writeFramedBytes(framed: Uint8Array) { if (printRPCBytes) { logger.debug('[RPC] Writing', framed.length) } @@ -308,6 +351,7 @@ class NativeTransport extends TransportShared { }) this.onConnected() + this.flushPendingFramedBytes() cb?.() } @@ -329,6 +373,15 @@ class NativeTransport extends TransportShared { }, 1000) } + private flushPendingFramedBytes() { + if (!this._socket || !this._pendingFramedBytes.length) { + return + } + const pending = this._pendingFramedBytes + this._pendingFramedBytes = [] + pending.forEach(frame => this.writeFramedBytes(frame)) + } + private debugPacketizeData(data: Uint8Array) { if (!__DEV__ || !TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX) { return @@ -490,7 +543,7 @@ class ProxyNativeTransport extends LocalTransport { } else if (type === 0 && methodOrError === 'chat.1.local.requestInboxUnbox') { tempRPCBridgeLog('renderer write invoke', {message, seqid}) } - engineSend?.(message) + engineSend?.(this.encodeMessage(message)) } } diff --git a/shared/util/electron.desktop.tsx b/shared/util/electron.desktop.tsx index fe4bde44a8fd..0f85e63fdce6 100644 --- a/shared/util/electron.desktop.tsx +++ b/shared/util/electron.desktop.tsx @@ -23,9 +23,6 @@ export type SaveDialogOptions = { message?: string } -import type {RPCMessage as EngineRPCMessage} from '@/engine/rpc-transport' -export type {EngineRPCMessage} - export type KB2 = { constants: { assetRoot: string @@ -66,7 +63,7 @@ export type KB2 = { windowsBinPath: string } functions: { - engineSend?: (message: EngineRPCMessage) => void + engineSend?: (message: Uint8Array) => void appStartedUp?: () => Promise<void> isDirectory?: (path: string) => Promise<boolean> getPathForFile?: (file: File) => string From aed19b3e40db8d8942a40489381bb739ee183cc2 Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Wed, 8 Apr 2026 17:46:05 -0400 Subject: [PATCH 38/55] WIP --- shared/engine/rpc-transport.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/engine/rpc-transport.ts b/shared/engine/rpc-transport.ts index 4a9b51d408c2..d19636e2e9df 100644 --- a/shared/engine/rpc-transport.ts +++ b/shared/engine/rpc-transport.ts @@ -444,11 +444,11 @@ export abstract class RPCTransport { cancelled: false, error: err => { tempRPCDebugLog('send response error', {err, seqid}) - this.send([MESSAGE_TYPE_RESPONSE, seqid, err, null]) + this.send([MESSAGE_TYPE_RESPONSE, seqid, err ?? null, null]) }, result: result => { tempRPCDebugLog('send response result', {result, seqid}) - this.send([MESSAGE_TYPE_RESPONSE, seqid, null, result]) + this.send([MESSAGE_TYPE_RESPONSE, seqid, null, result === undefined ? null : result]) }, seqid, } From 913f3bd9918a8476d7ceb4dc4048f36649bf6aa4 Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Wed, 8 Apr 2026 18:03:11 -0400 Subject: [PATCH 39/55] WIP --- shared/engine/index.platform.desktop.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/shared/engine/index.platform.desktop.tsx b/shared/engine/index.platform.desktop.tsx index d5fe55633023..e8457d270b14 100644 --- a/shared/engine/index.platform.desktop.tsx +++ b/shared/engine/index.platform.desktop.tsx @@ -571,6 +571,26 @@ function createClient( }) return } + if (__DEV__ && TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX) { + try { + const framed = decode(chunk.slice(5)) as RPCMessage + const [type, seqid] = framed + if (type === 1 && typeof seqid === 'number') { + const meta = ( + client.transport as unknown as { + _invocationMeta?: Map<number, {method: string; sessionID?: number}> + } + )._invocationMeta?.get(seqid) + if (meta?.method === 'chat.1.local.requestInboxUnbox') { + tempRPCBridgeLog('renderer received response chunk from node', { + method: meta.method, + seqid, + sessionID: meta.sessionID, + }) + } + } + } catch {} + } bridgeFromNodeLogger(chunk) client.transport.packetizeData(chunk) } catch (e) { From ceec4933449a1bcaa88aa439fbe2c82b0cd12403 Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Wed, 8 Apr 2026 18:19:53 -0400 Subject: [PATCH 40/55] WIP --- shared/desktop/app/ipc-handlers.desktop.tsx | 6 +- shared/desktop/app/ipctypes.tsx | 4 +- shared/desktop/renderer/preload.desktop.tsx | 3 +- shared/engine/index-impl.tsx | 19 - shared/engine/index.platform.desktop.tsx | 444 +------------------- shared/engine/rpc-transport.ts | 55 +-- shared/util/electron.desktop.tsx | 5 +- 7 files changed, 14 insertions(+), 522 deletions(-) diff --git a/shared/desktop/app/ipc-handlers.desktop.tsx b/shared/desktop/app/ipc-handlers.desktop.tsx index 8c4746e3b358..f8d0dea9ec88 100644 --- a/shared/desktop/app/ipc-handlers.desktop.tsx +++ b/shared/desktop/app/ipc-handlers.desktop.tsx @@ -190,11 +190,7 @@ export const setupIPCHandlers = (deps: { switch (action.type) { case 'engineSend': { const {buf} = action.payload - ;( - deps.nodeEngine._rpcClient.transport as { - sendFramedBytes?: (data: Uint8Array) => boolean - } - ).sendFramedBytes?.(buf) + deps.nodeEngine._rpcClient.transport.send(buf) return } case 'uninstallDokan': { diff --git a/shared/desktop/app/ipctypes.tsx b/shared/desktop/app/ipctypes.tsx index 09e86ef80463..a6fbf9783d56 100644 --- a/shared/desktop/app/ipctypes.tsx +++ b/shared/desktop/app/ipctypes.tsx @@ -1,4 +1,4 @@ -import type {OpenDialogOptions, SaveDialogOptions} from '@/util/electron.desktop' +import type {EngineRPCMessage, OpenDialogOptions, SaveDialogOptions} from '@/util/electron.desktop' import type * as RPCTypes from '@/constants/rpc/rpc-gen' export type Action = @@ -79,4 +79,4 @@ export type Action = | {type: 'clipboardAvailableFormats'} | {type: 'installCachedDokan'} | {type: 'uninstallDokan'; payload: {execPath: string}} - | {type: 'engineSend'; payload: {buf: Uint8Array}} + | {type: 'engineSend'; payload: {buf: EngineRPCMessage}} diff --git a/shared/desktop/renderer/preload.desktop.tsx b/shared/desktop/renderer/preload.desktop.tsx index b4dcaf7eb2ee..f02a5ed08af6 100644 --- a/shared/desktop/renderer/preload.desktop.tsx +++ b/shared/desktop/renderer/preload.desktop.tsx @@ -1,6 +1,7 @@ import * as Electron from 'electron' import type {Actions} from '@/constants/remote-actions' import { + type EngineRPCMessage, injectPreload, type KB2, type OpenDialogOptions, @@ -72,7 +73,7 @@ if (isRenderer) { type: 'dumpNodeLogger', }) }, - engineSend: (buf: Uint8Array) => { + engineSend: (buf: EngineRPCMessage) => { ignorePromise(invoke({payload: {buf}, type: 'engineSend'})) }, exitApp: (code: number) => { diff --git a/shared/engine/index-impl.tsx b/shared/engine/index-impl.tsx index d10f799dce7c..9c424f3e0302 100644 --- a/shared/engine/index-impl.tsx +++ b/shared/engine/index-impl.tsx @@ -13,14 +13,6 @@ import {type RPCError, convertToError} from '@/util/errors' import type * as EngineGen from '@/constants/rpc' type WaitingKey = string | ReadonlyArray<string> -const TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX = true - -const tempInboxUnboxSessionLog = (event: string, details: object) => { - if (!TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX || !__DEV__) { - return - } - console.warn(`[TEMP requestInboxUnbox session debug] ${event}`, details) -} class Engine { _onConnectedCB: (c: boolean) => void @@ -228,12 +220,6 @@ class Engine { incomingCallMap, waitingKey, }) - if (method === 'chat.1.local.requestInboxUnbox') { - tempInboxUnboxSessionLog('session start', { - params, - sessionID: session.getId(), - }) - } session.start(method, params, callback) return session.getId() } @@ -277,11 +263,6 @@ class Engine { // Cleanup a session that ended _sessionEnded(session: Session) { - if (session._startMethod === 'chat.1.local.requestInboxUnbox') { - tempInboxUnboxSessionLog('session end', { - sessionID: session.getId(), - }) - } rpcLog({ extra: { sessionID: session.getId(), diff --git a/shared/engine/index.platform.desktop.tsx b/shared/engine/index.platform.desktop.tsx index e8457d270b14..59245bb20db5 100644 --- a/shared/engine/index.platform.desktop.tsx +++ b/shared/engine/index.platform.desktop.tsx @@ -1,5 +1,4 @@ import type {Socket} from 'net' -import {decode} from '@msgpack/msgpack' import logger from '@/logger' import {TransportShared, LocalTransport, sharedCreateClient, rpcLog} from './transport-shared' import {socketPath} from '@/constants/platform.desktop' @@ -10,190 +9,12 @@ import KB2 from '@/util/electron.desktop' const {engineSend, ipcRendererOn, mainWindowDispatchEngineIncoming} = KB2.functions const {isRenderer} = KB2.constants -const TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX = true - -const tempRPCBridgeLog = (event: string, details: object) => { - if (!TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX || !__DEV__) { - return - } - console.warn(`[TEMP requestInboxUnbox bridge debug] ${event}`, details) -} - -const cloneChunkForIPC = (data: Uint8Array) => Uint8Array.from(data) - -const normalizeIncomingChunk = (data: unknown) => { - if (data instanceof Uint8Array) { - return cloneChunkForIPC(data) - } - if (ArrayBuffer.isView(data)) { - const view = data as ArrayBufferView - return new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)) - } - if (data instanceof ArrayBuffer) { - return new Uint8Array(data.slice(0)) - } - if (Array.isArray(data)) { - return Uint8Array.from(data) - } - return undefined -} - -const createBridgeFrameLogger = (label: string) => { - let bufferedBytes = 0 - let chunks = new Array<Uint8Array>() - let chunkOffset = 0 - - const reset = () => { - bufferedBytes = 0 - chunks = [] - chunkOffset = 0 - } - - const frameHeaderLength = (leadByte: number) => { - if (leadByte < 0x80) { - return 1 - } - switch (leadByte) { - case 0xcc: - return 2 - case 0xcd: - return 3 - case 0xce: - return 5 - default: - return 0 - } - } - - const appendChunk = (data: Uint8Array) => { - if (!data.length) { - return - } - chunks.push(data) - bufferedBytes += data.length - } - - const peekByte = () => { - const firstChunk = chunks[0] - if (!firstChunk) { - return undefined - } - return firstChunk[chunkOffset] - } - - const copyBufferedBytes = (length: number) => { - if (length > bufferedBytes) { - return undefined - } - - const firstChunk = chunks[0] - if (firstChunk) { - const available = firstChunk.length - chunkOffset - if (available >= length) { - return firstChunk.slice(chunkOffset, chunkOffset + length) - } - } - - const out = new Uint8Array(length) - let outOffset = 0 - let remaining = length - let localChunkIndex = 0 - let localChunkOffset = chunkOffset - - while (remaining > 0) { - const chunk = chunks[localChunkIndex] - if (!chunk) { - return undefined - } - const available = chunk.length - localChunkOffset - const toCopy = Math.min(remaining, available) - out.set(chunk.subarray(localChunkOffset, localChunkOffset + toCopy), outOffset) - outOffset += toCopy - remaining -= toCopy - localChunkIndex += 1 - localChunkOffset = 0 - } - - return out - } - - const consumeBufferedBytes = (length: number) => { - let remaining = length - while (remaining > 0) { - const chunk = chunks[0] - if (!chunk) { - chunkOffset = 0 - bufferedBytes = 0 - return - } - const available = chunk.length - chunkOffset - if (remaining < available) { - chunkOffset += remaining - bufferedBytes -= length - return - } - remaining -= available - chunks.shift() - chunkOffset = 0 - } - bufferedBytes -= length - } - - return (data: Uint8Array) => { - if (!__DEV__ || !TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX) { - return - } - try { - appendChunk(data) - while (bufferedBytes > 0) { - const firstByte = peekByte() - if (firstByte === undefined) { - return - } - const headerLen = frameHeaderLength(firstByte) - if (!headerLen || bufferedBytes < headerLen) { - return - } - const header = copyBufferedBytes(headerLen) - if (!header) { - return - } - const payloadLen = decode(header) - if (typeof payloadLen !== 'number' || payloadLen < 0) { - reset() - return - } - if (bufferedBytes < headerLen + payloadLen) { - return - } - consumeBufferedBytes(headerLen) - const payloadBytes = copyBufferedBytes(payloadLen) - if (!payloadBytes) { - return - } - const payload = decode(payloadBytes) as RPCMessage - consumeBufferedBytes(payloadLen) - const [type, seqid] = payload - if (type === 1) { - tempRPCBridgeLog(label, {payload, seqid}) - } - } - } catch { - reset() - } - } -} // used by node class NativeTransport extends TransportShared { private _socket?: Socket private _reconnectTimer?: ReturnType<typeof setTimeout> private _connecting = false - private _pendingFramedBytes = new Array<Uint8Array>() - private _debugBufferedBytes = 0 - private _debugChunks = new Array<Uint8Array>() - private _debugChunkOffset = 0 - private _bridgeToRendererLogger = createBridgeFrameLogger('node sent response chunk to renderer') constructor( incomingRPCCallback: IncomingRPCCallbackType, @@ -212,63 +33,7 @@ class NativeTransport extends TransportShared { if (!this._socket) { throw new Error('write attempt with no active stream') } - const [type, seqid, methodOrError] = message - if (__DEV__ && TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX) { - if (type === 0 && methodOrError === 'chat.1.local.requestInboxUnbox') { - console.warn('[TEMP requestInboxUnbox daemon debug] node wrote invoke to daemon', { - message, - seqid, - }) - } else if (type === 1) { - console.warn('[TEMP requestInboxUnbox daemon debug] node wrote response to daemon', { - message, - seqid, - }) - } - } const framed = this.encodeMessage(message) - this.writeFramedBytes(framed) - } - - sendFramedBytes(data: Uint8Array) { - const chunk = cloneChunkForIPC(data) - const [type, seqid] = (() => { - try { - const payload = decode(chunk.slice(5)) as RPCMessage - return [payload[0], payload[1]] - } catch { - return [undefined, undefined] - } - })() - if (__DEV__ && TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX) { - if (type === 1) { - console.warn('[TEMP requestInboxUnbox bridge debug] main received response', {seqid}) - } else if (type === 0) { - const payload = (() => { - try { - return decode(chunk.slice(5)) as RPCMessage - } catch { - return undefined - } - })() - if (payload?.[2] === 'chat.1.local.requestInboxUnbox') { - console.warn('[TEMP requestInboxUnbox bridge debug] main received invoke', {seqid}) - } - } - } - if (this._socket) { - this.writeFramedBytes(chunk) - return true - } - if (this.isExplicitClose()) { - console.warn('send framed bytes after explicit close') - return false - } - this._pendingFramedBytes.push(chunk) - return true - } - - private writeFramedBytes(framed: Uint8Array) { if (printRPCBytes) { logger.debug('[RPC] Writing', framed.length) } @@ -288,12 +53,7 @@ class NativeTransport extends TransportShared { if (printRPCBytes) { logger.debug('[RPC] Read', m.length) } - // Socket reads can arrive as Buffer-backed subarray views. Normalize them before crossing - // Electron IPC so the renderer packetizer always sees an exact standalone byte range. - const chunk = cloneChunkForIPC(m) - this.debugPacketizeData(chunk) - this._bridgeToRendererLogger(chunk) - mainWindowDispatchEngineIncoming(chunk) + mainWindowDispatchEngineIncoming(m) } close() { @@ -351,7 +111,6 @@ class NativeTransport extends TransportShared { }) this.onConnected() - this.flushPendingFramedBytes() cb?.() } @@ -372,178 +131,11 @@ class NativeTransport extends TransportShared { this.connectOnce() }, 1000) } - - private flushPendingFramedBytes() { - if (!this._socket || !this._pendingFramedBytes.length) { - return - } - const pending = this._pendingFramedBytes - this._pendingFramedBytes = [] - pending.forEach(frame => this.writeFramedBytes(frame)) - } - - private debugPacketizeData(data: Uint8Array) { - if (!__DEV__ || !TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX) { - return - } - try { - this.appendDebugChunk(data) - while (this._debugBufferedBytes > 0) { - const firstByte = this.peekDebugByte() - if (firstByte === undefined) { - return - } - const headerLen = this.frameHeaderLength(firstByte) - if (!headerLen || this._debugBufferedBytes < headerLen) { - return - } - const header = this.copyDebugBufferedBytes(headerLen) - if (!header) { - return - } - const payloadLen = decode(header) - if (typeof payloadLen !== 'number' || payloadLen < 0) { - this.resetDebugPacketizer() - return - } - if (this._debugBufferedBytes < headerLen + payloadLen) { - return - } - this.consumeDebugBufferedBytes(headerLen) - const payloadBytes = this.copyDebugBufferedBytes(payloadLen) - if (!payloadBytes) { - return - } - const payload = decode(payloadBytes) as RPCMessage - this.consumeDebugBufferedBytes(payloadLen) - const [type, seqid] = payload - if (type === 1) { - console.warn('[TEMP requestInboxUnbox daemon debug] node received response from daemon', { - payload, - seqid, - }) - } else if (type === 0) { - console.warn('[TEMP requestInboxUnbox daemon debug] node received invoke from daemon', { - payload, - seqid, - }) - } - } - } catch { - this.resetDebugPacketizer() - } - } - - private frameHeaderLength(leadByte: number) { - if (leadByte < 0x80) { - return 1 - } - switch (leadByte) { - case 0xcc: - return 2 - case 0xcd: - return 3 - case 0xce: - return 5 - default: - return 0 - } - } - - private resetDebugPacketizer() { - this._debugBufferedBytes = 0 - this._debugChunks = [] - this._debugChunkOffset = 0 - } - - private appendDebugChunk(data: Uint8Array) { - const chunk = new Uint8Array(data.buffer, data.byteOffset, data.byteLength) - if (!chunk.length) { - return - } - this._debugChunks.push(chunk) - this._debugBufferedBytes += chunk.length - } - - private peekDebugByte() { - const firstChunk = this._debugChunks[0] - if (!firstChunk) { - return undefined - } - return firstChunk[this._debugChunkOffset] - } - - private copyDebugBufferedBytes(length: number) { - if (length > this._debugBufferedBytes) { - return undefined - } - - const firstChunk = this._debugChunks[0] - if (firstChunk) { - const available = firstChunk.length - this._debugChunkOffset - if (available >= length) { - return firstChunk.slice(this._debugChunkOffset, this._debugChunkOffset + length) - } - } - - const out = new Uint8Array(length) - let outOffset = 0 - let remaining = length - let chunkIndex = 0 - let chunkOffset = this._debugChunkOffset - - while (remaining > 0) { - const chunk = this._debugChunks[chunkIndex] - if (!chunk) { - return undefined - } - - const available = chunk.length - chunkOffset - const toCopy = Math.min(remaining, available) - out.set(chunk.subarray(chunkOffset, chunkOffset + toCopy), outOffset) - outOffset += toCopy - remaining -= toCopy - chunkIndex += 1 - chunkOffset = 0 - } - - return out - } - - private consumeDebugBufferedBytes(length: number) { - let remaining = length - while (remaining > 0) { - const chunk = this._debugChunks[0] - if (!chunk) { - this._debugChunkOffset = 0 - this._debugBufferedBytes = 0 - return - } - - const available = chunk.length - this._debugChunkOffset - if (remaining < available) { - this._debugChunkOffset += remaining - this._debugBufferedBytes -= length - return - } - - remaining -= available - this._debugChunks.shift() - this._debugChunkOffset = 0 - } - this._debugBufferedBytes -= length - } } class ProxyNativeTransport extends LocalTransport { protected writeMessage(message: RPCMessage) { - const [type, seqid, methodOrError] = message - if (type === 1) { - tempRPCBridgeLog('renderer write response', {message, seqid}) - } else if (type === 0 && methodOrError === 'chat.1.local.requestInboxUnbox') { - tempRPCBridgeLog('renderer write invoke', {message, seqid}) - } - engineSend?.(this.encodeMessage(message)) + engineSend?.(message) } } @@ -558,41 +150,11 @@ function createClient( const client = sharedCreateClient( new ProxyNativeTransport(incomingRPCCallback, connectCallback, disconnectCallback) ) - const bridgeFromNodeLogger = createBridgeFrameLogger('renderer received response chunk from node') // plumb back data from the node side ipcRendererOn?.('engineIncoming', (_e: unknown, data: unknown) => { try { - const chunk = normalizeIncomingChunk(data) - if (!chunk) { - logger.error('>>>> rpcOnJs invalid engineIncoming chunk', { - constructorName: data && typeof data === 'object' ? data.constructor?.name : undefined, - type: typeof data, - }) - return - } - if (__DEV__ && TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX) { - try { - const framed = decode(chunk.slice(5)) as RPCMessage - const [type, seqid] = framed - if (type === 1 && typeof seqid === 'number') { - const meta = ( - client.transport as unknown as { - _invocationMeta?: Map<number, {method: string; sessionID?: number}> - } - )._invocationMeta?.get(seqid) - if (meta?.method === 'chat.1.local.requestInboxUnbox') { - tempRPCBridgeLog('renderer received response chunk from node', { - method: meta.method, - seqid, - sessionID: meta.sessionID, - }) - } - } - } catch {} - } - bridgeFromNodeLogger(chunk) - client.transport.packetizeData(chunk) + client.transport.packetizeData(data as Uint8Array) } catch (e) { logger.error('>>>> rpcOnJs JS thrown!', e) } diff --git a/shared/engine/rpc-transport.ts b/shared/engine/rpc-transport.ts index d19636e2e9df..8bad614fc886 100644 --- a/shared/engine/rpc-transport.ts +++ b/shared/engine/rpc-transport.ts @@ -75,22 +75,9 @@ export type RPCMessage = [number, ...Array<unknown>] type PendingItem = | {type: 'invoke'; method: string; args: [object]; cb: InvocationCallback} | {type: 'message'; message: RPCMessage} -type InvocationMeta = { - method: string - sessionID?: number -} const queueMax = 1000 const maxFrameSize = 64 * 1024 * 1024 // 64 MB; rejects oversized frames before buffering payload bytes -const TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX = true -const tempRPCDebugProcessType = typeof process !== 'undefined' ? process.type || 'unknown' : 'unknown' - -const tempRPCDebugLog = (event: string, details: object) => { - if (!TEMP_RPC_DEBUG_REQUEST_INBOX_UNBOX || !__DEV__) { - return - } - console.warn(`[TEMP requestInboxUnbox rpc debug][${tempRPCDebugProcessType}] ${event}`, details) -} const makeTransportError = (name: ErrorName): ErrorType => ({ code: errors[name], @@ -143,7 +130,6 @@ export abstract class RPCTransport { private _connectCallback?: ConnectDisconnectCB private _disconnectCallback?: ConnectDisconnectCB private _invocations = new Map<number, InvocationCallback>() - private _invocationMeta = new Map<number, InvocationMeta>() private _pending = new Array<PendingItem>() private _seqid = 1 @@ -219,14 +205,7 @@ export abstract class RPCTransport { protected failOutstanding(err: unknown, data: unknown) { const invocations = this._invocations - const invocationMeta = this._invocationMeta this._invocations = new Map() - this._invocationMeta = new Map() - invocationMeta.forEach(({method, sessionID}, seqid) => { - if (method === 'chat.1.local.requestInboxUnbox') { - tempRPCDebugLog('failOutstanding', {data, err, seqid, sessionID}) - } - }) invocations.forEach(cb => cb(err, data)) } @@ -293,14 +272,6 @@ export abstract class RPCTransport { console.warn('Invalid invoke packet received') return } - if (method === 'chat.1.chatUi.chatInboxConversation') { - const firstParam = param[0] as {sessionID?: number} | undefined - tempRPCDebugLog('incoming invoke chatInboxConversation', { - param0: param[0], - sessionID: firstParam?.sessionID, - seqid, - }) - } const payload = { method, param: param as Array<{sessionID?: number}>, @@ -331,22 +302,11 @@ export abstract class RPCTransport { console.warn('Invalid response packet received') return } - const meta = this._invocationMeta.get(seqid) const cb = this._invocations.get(seqid) if (!cb) { - tempRPCDebugLog('response with no invocation', {error, meta, result, seqid}) return } this._invocations.delete(seqid) - this._invocationMeta.delete(seqid) - if (meta?.method === 'chat.1.local.requestInboxUnbox') { - tempRPCDebugLog('response matched invocation', { - error, - result, - seqid, - sessionID: meta.sessionID, - }) - } cb(this.unwrapIncomingError(error), result) return } @@ -356,10 +316,6 @@ export abstract class RPCTransport { console.warn('Invalid cancel packet received') return } - const meta = this._invocationMeta.get(seqid) - if (meta?.method === 'chat.1.local.requestInboxUnbox') { - tempRPCDebugLog('incoming cancel', {seqid, sessionID: meta.sessionID}) - } this._incomingRPCCallback?.({ method: '', param: [], @@ -431,11 +387,6 @@ export abstract class RPCTransport { const seqid = this._seqid this._seqid += 1 this._invocations.set(seqid, cb) - const firstArg = args[0] as {sessionID?: number} | undefined - this._invocationMeta.set(seqid, {method, sessionID: firstArg?.sessionID}) - if (method === 'chat.1.local.requestInboxUnbox') { - tempRPCDebugLog('invokeNow', {args: args[0], seqid, sessionID: firstArg?.sessionID}) - } this.writeMessage([MESSAGE_TYPE_INVOKE, seqid, method, args]) } @@ -443,12 +394,10 @@ export abstract class RPCTransport { return { cancelled: false, error: err => { - tempRPCDebugLog('send response error', {err, seqid}) - this.send([MESSAGE_TYPE_RESPONSE, seqid, err ?? null, null]) + this.send([MESSAGE_TYPE_RESPONSE, seqid, err, null]) }, result: result => { - tempRPCDebugLog('send response result', {result, seqid}) - this.send([MESSAGE_TYPE_RESPONSE, seqid, null, result === undefined ? null : result]) + this.send([MESSAGE_TYPE_RESPONSE, seqid, null, result]) }, seqid, } diff --git a/shared/util/electron.desktop.tsx b/shared/util/electron.desktop.tsx index 0f85e63fdce6..fe4bde44a8fd 100644 --- a/shared/util/electron.desktop.tsx +++ b/shared/util/electron.desktop.tsx @@ -23,6 +23,9 @@ export type SaveDialogOptions = { message?: string } +import type {RPCMessage as EngineRPCMessage} from '@/engine/rpc-transport' +export type {EngineRPCMessage} + export type KB2 = { constants: { assetRoot: string @@ -63,7 +66,7 @@ export type KB2 = { windowsBinPath: string } functions: { - engineSend?: (message: Uint8Array) => void + engineSend?: (message: EngineRPCMessage) => void appStartedUp?: () => Promise<void> isDirectory?: (path: string) => Promise<boolean> getPathForFile?: (file: File) => string From 6de065aa78a629d2f0d9a15d0bc162ea9fb64647 Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Wed, 8 Apr 2026 22:22:48 -0400 Subject: [PATCH 41/55] WIP --- INVESTIGATION.md | 585 ++++++++++++++++++++++++++++++++++++++ go/chat/localizer.go | 37 ++- go/chat/server.go | 19 +- go/chat/uithreadloader.go | 26 +- 4 files changed, 648 insertions(+), 19 deletions(-) create mode 100644 INVESTIGATION.md diff --git a/INVESTIGATION.md b/INVESTIGATION.md new file mode 100644 index 000000000000..e3d57be327fd --- /dev/null +++ b/INVESTIGATION.md @@ -0,0 +1,585 @@ +# requestInboxUnbox Outstanding Session Investigation + +## Context + +Issue observed in Electron: + +- `outstandingSessionDebugger` repeatedly logged `chat.1.local.requestInboxUnbox` +- the logs appeared in the renderer +- the problem showed up after startup / after entering Chat + +The issue stopped reproducing after restarting the Go daemon. + +## Repro Flow Used + +1. Start the app clean. +2. Let startup settle. +3. Clear renderer and node logs. +4. Click into Chat. +5. Wait for `outstandingSessionDebugger`. +6. Pick the reported renderer `sessionID`. +7. Correlate that session to the transport `seqid` from renderer logs. + +Example stuck pairs we tracked: + +- `sessionID: 180` -> `seqid: 57` +- `sessionID: 181` -> `seqid: 58` +- `sessionID: 183` -> `seqid: 60` + +## What We Confirmed + +### 1. The outstanding sessions were real renderer engine sessions + +For a stuck case we saw: + +- renderer `session start {sessionID: ...}` +- renderer `invokeNow {seqid: ..., sessionID: ...}` +- no matching renderer `session end {sessionID: ...}` + +So this was not just noisy transport logging. The renderer engine session really remained open. + +### 2. The inner UI callback path was working + +For `chat.1.chatUi.chatInboxConversation` we saw: + +- incoming invoke in renderer +- renderer sending `response.result()` +- node receiving that response + +So the nested UI callback ack path was functioning during investigation. + +### 3. The generic JS request path was functioning + +For successful `requestInboxUnbox` calls we saw the full path: + +- renderer started engine session +- renderer invoked outer RPC with a `seqid` +- main received invoke +- node wrote invoke to daemon +- node received outer response from daemon +- renderer matched the response +- renderer ended the engine session + +So the JS RPC path was capable of closing these sessions normally. + +### 4. Some specific outer requests never produced a closing response during the bad runs + +For stuck cases like `181/58` and `183/60`, the important pattern was: + +- renderer started session +- renderer sent invoke +- main received invoke +- node wrote invoke to daemon +- no corresponding renderer session end + +During some bad runs, the decisive missing line for the stuck `seqid` was: + +- no `node received response from daemon ... seqid: <stuck seqid>` + +That means the stuck session was not explained by renderer bookkeeping before send. + +### 5. The issue disappeared after restarting the Go daemon + +After restarting the daemon, the problem stopped reproducing. + +That materially weakens the hypothesis that the root cause is a deterministic bug in the new JS RPC implementation alone. + +## What We Tried On The JS Side + +These were investigated and then removed: + +- temp renderer/session logs in the engine layer +- temp transport logs in renderer and node +- temp bridge logs across Electron IPC +- temporary response normalization changes (`undefined` -> `null`) +- temporary bridge framing / chunk normalization experiments +- temporary `requestInboxUnbox` special-casing + +None of those produced a confirmed fix. + +The temporary instrumentation has been cleaned up. + +## Strongest Current Interpretation + +The best-supported explanation from the investigation is: + +- the renderer session stayed outstanding because the outer `requestInboxUnbox` RPC did not complete for some requests +- in bad runs, the request had already crossed renderer -> main -> daemon write +- the missing completion signal was at or after daemon handling, not before renderer send +- restarting the daemon made the issue disappear + +That makes a daemon-side or daemon-state issue the strongest next lead. + +## Service Log Follow-Up + +We checked: + +- `/Users/ChrisNojima/Library/Logs/keybase.service.log` + +Important caveat: + +- this log was later identified as coming from the wrong machine / wrong daemon instance for the bad repro +- so it should not be treated as evidence that the bad run itself completed normally +- it is still useful for understanding the structure of the Go path and the kinds of daemon-side interactions that can delay `RequestInboxUnbox` + +### What The Service Log Added + +The service log did **not** show a direct reproduction of: + +- `+ Server: RequestInboxUnbox` +- no matching `- Server: RequestInboxUnbox -> ok` + +In the ranges inspected, every `RequestInboxUnbox` entry returned. + +However, the service log did show a strong interaction between: + +- `Server: GetThreadNonblock(...)` +- inbox localizer suspension +- concurrent `RequestInboxUnbox` + +### Repeated Pattern In Service Logs + +In several runs, the sequence was: + +1. `GetThreadNonblock(...)` starts. +2. `SuspendComponent: canceled background task` appears. +3. `localizerPipeline: suspend` runs. +4. concurrent `RequestInboxUnbox` starts. +5. its job reaches: + - `localizerPipeline: localizeJobPulled: waiting for resume` +6. only after `GetThreadNonblock(...) -> ok` does the unbox localizer resume and the outer `RequestInboxUnbox` return. + +Representative examples: + +- around `2026-04-08 10:39:02 -04:00` +- around `2026-04-08 10:46:56 -04:00` +- around `2026-04-08 17:14:03 -04:00` + +### Concrete Example: Slow But Completing Unbox + +At `2026-04-08 10:39:02 -04:00`: + +- `GetThreadNonblock(000030...)` starts +- it suspends the localizer +- `RequestInboxUnbox` for the same conv starts +- the unbox job logs `waiting for resume` +- `GetThreadNonblock` returns after about `609ms` +- then `RequestInboxUnbox` returns after about `611ms` + +This is a daemon-side confirmation that `RequestInboxUnbox` can be blocked behind thread-load suspension. + +### Concrete Example: Cancellation / Re-enqueue Storm + +At `2026-04-08 10:46:56 -04:00`: + +- `GetThreadNonblock(000030...)` suspends the localizer +- multiple lines appear: + - `localizerPipeline: localizeJobPulled: canceled a live job` + - `localizerPipeline: localizeConversations: context is done, bailing` + - `localizerPipeline: localizeJobPulled: re-enqueuing canceled job` + - `localizerPipeline: localizeJobPulled: waiting for resume` +- a concurrent `RequestInboxUnbox` for the same conv also enters `waiting for resume` +- it eventually returns, but only after resume + +This did not wedge in the captured log, but it is the clearest daemon-side evidence of a fragile path. + +### Updated Go-Side Interpretation + +The best current Go-side explanation is now narrower: + +- `RequestInboxUnbox` is not independently blocked on UI callback ack in the daemon +- it is blocked on inbox localizer progress +- `GetThreadNonblock` can suspend and cancel localizer work +- concurrent thread loading and inbox unboxing share the same localizer machinery +- a daemon-side wedge in suspend/resume or cancel/re-enqueue handling remains a strong candidate for the runs where the outer response never came back + +### Specific Go Areas Now Most Suspicious + +1. `GetThreadNonblock` suspending the inbox source/localizer during thread load +2. `localizerPipeline.suspend/resume` +3. `localizerPipeline.localizeJobPulled` +4. cancel/re-enqueue behavior after `context canceled` +5. whether a resumed or retried job can be left waiting forever if resume signaling or pending/completed bookkeeping goes wrong + +## Correct-Machine Service Logs + +We later checked the actual machine logs: + +- `/Users/ChrisNojima/Downloads/logs/keybase.service.log` +- `/Users/ChrisNojima/Downloads/logs/keybase.service.log-20260408T180719-0400-20260408T181728-0400` + +These logs materially strengthen the same Go-side suspicion. + +### What These Logs Confirm + +They show that on the real machine: + +- `GetThreadNonblock(...)` does suspend the inbox source/localizer +- concurrent inbox-unbox jobs do enter `localizerPipeline: localizeJobPulled: waiting for resume` +- live localizer jobs can be canceled during that suspension +- canceled jobs can be re-enqueued and later resumed + +So the previously suspected thread-load/localizer interaction is not just theoretical. + +### Concrete Example: Thread Load Cancels Live Localizer Work + +Around `2026-04-08 17:34:59 -04:00` in the rotated log: + +1. `Server: GetThreadNonblock(...)` starts. +2. `SuspendComponent: canceled background task` appears. +3. `localizerPipeline: suspend` runs. +4. live localizer workers log: + - `localizerPipeline: localizeJobPulled: canceled a live job` + - `RemoteClient: chat.1.remote.getMessagesRemote -> ERROR: context canceled` + - `localizerPipeline: failed to transform message ... context canceled` +5. a concurrent inbox-unbox trace enters: + - `localizerPipeline: localizeJobPulled: waiting for resume` +6. only after `GetThreadNonblock(...) -> ok [time=205.738625ms]` do we see: + - `localizerPipeline: resume` + - `localizerPipeline: localizeJobPulled: resume, proceeding` + +That is direct daemon evidence that inbox unbox work can be paused behind thread loading and only released on resume. + +### Concrete Example: Slow RequestInboxUnbox Without Obvious Error + +Around `2026-04-08 18:21:46 -04:00` in the current log: + +- two `RequestInboxUnbox` traces (`tu-3DgB67sGd` and `WTZ2nhFPsZAr`) both enter: + - `localizerPipeline: localizeJobPulled: waiting for resume` +- both then resume and spend roughly `736ms` in `localizeConversations` +- both outer RPCs finally return in about `737ms` + +This shows two important things: + +- the outer RPC really is waiting on localizer progress +- the visible stall can be a combination of suspend/resume delay plus expensive per-conversation localization work + +### What We Have Not Yet Seen + +In the sampled correct-machine log windows, we have not yet isolated: + +- a `RequestInboxUnbox` that entered and never produced `-> ok` +- a `resume: spurious resume call without suspend` + +So the logs do not yet prove the exact permanent wedge. They do prove the fragile path that could plausibly cause it. + +## Narrowed Go Hypothesis + +Given the code and the correct-machine logs, the strongest daemon-side wedge theory is now: + +1. `RequestInboxUnbox` calls `UIInboxLoader.UpdateConvs` +2. `UpdateConvs` calls `LoadNonblock` +3. `LoadNonblock` does not return until the localizer callback channel closes +4. localizer jobs marked cancelable can block in `localizeJobPulled: waiting for resume` +5. `resume()` only releases those waiters when `suspendCount` drops all the way back to zero + +That means an unmatched or leaked suspend would strand every future waiting localizer job indefinitely. + +If that happens: + +- the localizer callback channel never finishes +- `LoadNonblock` never returns +- `RequestInboxUnbox` never returns an outer response +- because `RequestInboxUnbox` swallows normal `UpdateConvs` errors, the renderer would just observe an outstanding request rather than a useful failure + +### Most Specific Code-Level Candidate Now + +The cleanest permanent-hang mechanism is: + +- `GetThreadNonblock` enters `defer h.suspendInboxSource(ctx)()` +- the inbox localizer `suspendCount` is incremented +- cancelable localizer jobs queue up in `waiting for resume` +- for some path or nesting combination, the matching `resume()` does not drive `suspendCount` back to zero +- the waiters are never closed +- the outstanding `RequestInboxUnbox` stays open until daemon restart resets that state + +This is now the main Go-side hypothesis to verify against the next bad-run log. + +## Code Audit Follow-Up + +After digging further into the Go code, the strongest explanation shifted slightly: + +- the most likely wedge is no longer just a leaked `suspendCount` +- a stuck `GetThreadNonblock` path can itself keep the inbox localizer suspended forever + +### Why GetThreadNonblock Is So Dangerous Here + +`GetThreadNonblock` does: + +- `defer h.suspendInboxSource(ctx)()` + +That means inbox-source resume does **not** happen when thread payloads are first sent to the UI. +It only happens when `UIThreadLoader.LoadNonblock(...)` fully returns. + +So if `LoadNonblock(...)` wedges anywhere, the inbox localizer remains suspended the whole time. + +### Important Detail: Thread Load Continues After UI Delivery + +`UIThreadLoader.LoadNonblock(...)` does more work after sending the full thread to the UI: + +1. waits for the local/full goroutines with `wg.Wait()` +2. logs `thread payloads transferred, checking for resolve` +3. resolves skipped unboxeds / validation work +4. performs final status clearing +5. only then returns + +This matters because: + +- the UI may already look "done" +- but the inbox source is still suspended +- so concurrent `RequestInboxUnbox` jobs can still sit in `waiting for resume` + +### Conversation Lock Coupling + +The thread loader also grabs a conversation lock for the full lifetime of `LoadNonblock(...)`: + +- `ConvSource.AcquireConversationLock(...)` +- defer release only when `LoadNonblock(...)` returns + +The same underlying lock is used by conversation-source operations that the inbox localizer calls, including: + +- `ConvSource.GetMessages(...)` +- `ConvSource.GetMessagesWithRemotes(...)` +- other pull/push/expunge paths + +So thread loading and inbox localization are contending on the same per-conversation lock. + +### Lock Behavior That Makes This Risky + +`ConversationLockTab.Acquire(...)` ultimately blocks on a plain `sync.Mutex`: + +- it does not select on `ctx.Done()` +- there is no timeout once it is waiting on the mutex itself + +So if one trace grabs the conversation lock and wedges before release: + +- later thread work for that conv can block forever +- later inbox-localizer work for that conv can block forever +- and if the wedged holder is `GetThreadNonblock`, inbox-source resume never fires + +That is a very strong match for: + +- issue appears after entering Chat +- some `requestInboxUnbox` requests never complete +- daemon restart clears the problem + +## Revised Leading Hypotheses + +From code inspection, the leading Go-side possibilities are now: + +1. `GetThreadNonblock` hangs before its deferred inbox-source resume runs +2. a conversation lock is held indefinitely by thread-loading or message-fetch code +3. a localizer worker hangs inside `localizeConversation(...)` after the initial inbox callback +4. a true suspend-count / waiter-state leak in the localizer pipeline + +### Strongest Current Candidate + +The strongest candidate is now: + +- `GetThreadNonblock` suspends the inbox source +- `UIThreadLoader.LoadNonblock(...)` acquires the conversation lock +- thread loading wedges somewhere under that lock or in its post-send validation phase +- deferred inbox-source resume never runs +- `RequestInboxUnbox` jobs remain stuck in `waiting for resume` + +This is a simpler explanation than a pure `suspendCount` imbalance and fits both the logs and the code structure better. + +## New Strong Code-Only Candidate: Blocking Thread-Status UI RPC + +There is an even more specific way `GetThreadNonblock` can wedge: + +- `UIThreadLoader.LoadNonblock(...)` uses `setUIStatus(...)` +- `setUIStatus(...)` eventually calls `chatUI.ChatThreadStatus(context.Background(), status)` +- `cancelStatusFn()` then waits for that goroutine to report whether the status was displayed + +This is important because: + +- `ChatThreadStatus(...)` is not fire-and-forget; it is a blocking RPC call through `ChatUiClient` +- it is invoked with `context.Background()`, not the request context +- if that UI RPC blocks, the goroutine never reports back on `resCh` +- `cancelStatusFn()` blocks forever waiting on `resCh` +- `UIThreadLoader.LoadNonblock(...)` then never returns +- `GetThreadNonblock` never reaches its deferred inbox-source resume +- `RequestInboxUnbox` jobs can remain stuck in `waiting for resume` + +### Why This Fits The User-Visible Symptom + +This is especially plausible because `LoadNonblock(...)` can already have sent the thread to the UI before the wedge happens. + +So the UI can look mostly usable while the daemon is still: + +- inside `GetThreadNonblock` +- holding the inbox source suspended +- blocking later inbox-unbox RPCs + +### Related Risk In Final Status Clearing + +At the end of `LoadNonblock(...)`, final status clearing also calls: + +- `chatUI.ChatThreadStatus(context.Background(), validated)` +- or `chatUI.ChatThreadStatus(context.Background(), none)` + +Those are also blocking UI RPCs with an uncancelable background context. + +So even after successful thread delivery and validation work, a wedged status callback could still keep `GetThreadNonblock` open indefinitely. + +### Relative Strength Of This Hypothesis + +This now looks at least as strong as the pure lock/suspend-count theories because: + +- it directly explains why entering Chat could trigger the stuck state +- it directly explains how the daemon can stay stuck even after visible UI progress +- it uses an explicit uncancelable blocking RPC call in the exact thread-load path that suspends inbox unboxing + +## Further Code Audit Narrowing + +After tracing the desktop/UI callback plumbing more carefully, the thread-status theory became narrower. + +### Important Weakening: JS Auto-Acks Thread Callbacks + +On the JS side, listener-backed incoming calls are acknowledged immediately: + +- the listener sends `response.result()` before it schedules the actual handler body +- `chatThreadStatus` in the desktop store is only a synchronous state update +- `chatThreadFull` / `chatThreadCached` are also listener callbacks on the same path + +So a slow or expensive JS status handler is **not** enough by itself to wedge the Go call. + +For the status-RPC theory to be the root cause, the failure has to be lower level: + +- the service-side RPC transport never gets the callback delivered to the frontend session +- or the frontend session is not actually able to dispatch the callback at all + +That keeps this as a real possibility, but weaker than it first looked from Go alone. + +### Another Candidate We Can Mostly De-Prioritize: waitForOnline + +`UIThreadLoader.waitForOnline(...)` only waits about one second total: + +- it loops 40 times +- each loop waits `25ms` +- then it proceeds anyway + +So `GetThreadNonblock` is not likely to wedge forever just because the loader was waiting to come online. + +### Stronger Code-Only Candidate: Post-Send Validation Before Resume + +There is a more convincing path inside `UIThreadLoader.LoadNonblock(...)`: + +1. the full thread is sent to the UI +2. `wg.Wait()` completes +3. the loader enters `thread payloads transferred, checking for resolve` +4. it resolves skipped unboxeds / validation work +5. only after that does `LoadNonblock(...)` return +6. only then does `GetThreadNonblock` run its deferred inbox-source resume + +This is important because it matches a user-visible state where: + +- the thread already appears loaded +- but the inbox localizer is still suspended +- and concurrent `RequestInboxUnbox` calls are still stuck in `waiting for resume` + +### Why The Post-Send Work Is Risky + +That post-send path does: + +- `NewBoxer(...).ResolveSkippedUnboxeds(...)` +- `ConvSource.TransformSupersedes(...)` +- notifier/update work on the resulting messages + +`ResolveSkippedUnboxeds(...)` re-validates sender keys for quick-unboxed messages through: + +- `ResolveSkippedUnboxed(...)` +- `ValidSenderKey(...)` +- `CtxUPAKFinder(ctx, ...).CheckKIDForUID(...)` + +So even after the thread is already visible in the UI, the loader can still be blocked doing sender-key validation and related follow-up work before resume happens. + +### Conversation Lock Scope Still Matters + +This is all still happening while `UIThreadLoader.LoadNonblock(...)` holds the per-conversation lock for its full lifetime. + +So the critical section is not just: + +- pull local thread +- pull remote thread + +It also includes: + +- JSON presentation/send +- post-send skip-resolution +- final status-clearing path + +That makes the thread-loader critical section broader than it first appears. + +### Additional Thread-Loader Bug + +There is also a separate bug in `UIThreadLoader.singleFlightConv(...)`: + +- `activeConvLoads[convID] = cancel` is written +- but entries are never removed + +This is probably not the root cause of the outstanding `requestInboxUnbox` sessions, but it is real thread-loader state leakage and could make cancellation behavior harder to reason about over time. + +## Revised Ranking After More Code Reading + +From code alone, the current ranking is: + +1. `GetThreadNonblock` wedges in post-send work before deferred inbox-source resume +2. `GetThreadNonblock` wedges somewhere else while holding the conversation lock +3. UI callback transport wedges on `ChatThreadStatus` / `ChatThreadFull` / `ChatThreadCached` +4. localizer worker hangs after resume inside `localizeConversation(...)` +5. pure localizer suspend-count imbalance / waiter leak + +## Additional Code Smells Worth Remembering + +These are not yet proven root cause, but they increase risk: + +- `UIInboxLoader.LoadNonblock(...)` has a 1-minute timeout only for the first unverified inbox result; after that, draining `localizeCb` has no timeout. +- several localizer username lookups use `UIDMap.MapUIDsToUsernamePackages(..., networkTimeBudget=0, ...)`, which means no explicit timeout is applied at that layer. +- `UIDMap.MapUIDsToUsernamePackages(...)` holds the UID-map mutex across the server lookup path, so one slow miss can serialize later username lookups behind it. +- `UIThreadLoader.LoadNonblock(...)` appears to check `err != nil` instead of `fullErr != nil` after `ChatThreadFull(...)`, which is likely a bug, though not the main outstanding-session explanation. + +## Things That Were Noise / Not Root Cause + +- "Inbox asked for too much work" was not the root issue for the stuck sessions. +- The fact that unboxes happen on startup is expected and not itself the bug. +- `chatInboxConversation` lacking a useful `sessionID` in logs did not explain the stuck outer requests by itself. +- The generic Electron bridge was not universally broken; many requests completed normally through it. + +## Useful Facts For The Next Debug Session + +If the issue reproduces again, capture: + +1. renderer `sessionID` +2. matching renderer outer `seqid` +3. whether node logged: + - `main received invoke ... seqid` + - `node wrote invoke to daemon ... seqid` + - `node received response from daemon ... seqid` +4. whether renderer later logs: + - `response matched invocation ... seqid` + - `session end ... sessionID` + +The most valuable bad-run pattern is: + +- renderer session start +- renderer invoke +- node wrote invoke to daemon +- no daemon response for that same outer `seqid` +- no renderer session end + +## Next Go-Side Questions + +1. Why would `chat.1.local.requestInboxUnbox` sometimes not produce an outer response for a subset of requests? +2. Is there any daemon-side state that can wedge this path until daemon restart? +3. Is `RequestInboxUnbox` blocked on loader/UI state in a way that can fail to return? +4. Is there any batching / callback / channel drain behavior in the inbox loader that can prevent the outer RPC from finishing? +5. Are there daemon logs around the stuck outer request showing the handler entered but never returned? + +## Current Status + +- no confirmed root-cause fix +- JS-side debug instrumentation removed +- daemon restart stopped reproduction +- next investigation should focus on Go/daemon behavior for stuck outer `requestInboxUnbox` calls diff --git a/go/chat/localizer.go b/go/chat/localizer.go index 2993541a2e86..1deb6bde7bf3 100644 --- a/go/chat/localizer.go +++ b/go/chat/localizer.go @@ -369,7 +369,10 @@ func (s *localizerPipeline) suspend(ctx context.Context) bool { if !s.started { return false } + prevSuspendCount := s.suspendCount s.suspendCount++ + s.Debug(ctx, "suspend: count %d -> %d waiters: %d cancelChs: %d queued: %d", + prevSuspendCount, s.suspendCount, len(s.suspendWaiters), len(s.cancelChs), len(s.jobQueue)) if len(s.cancelChs) == 0 { return false } @@ -405,8 +408,12 @@ func (s *localizerPipeline) resume(ctx context.Context) bool { s.Debug(ctx, "resume: spurious resume call without suspend") return false } + prevSuspendCount := s.suspendCount s.suspendCount-- + s.Debug(ctx, "resume: count %d -> %d waiters: %d cancelChs: %d queued: %d", + prevSuspendCount, s.suspendCount, len(s.suspendWaiters), len(s.cancelChs), len(s.jobQueue)) if s.suspendCount == 0 { + s.Debug(ctx, "resume: releasing waiters: %d", len(s.suspendWaiters)) for _, cb := range s.suspendWaiters { close(cb) } @@ -415,6 +422,12 @@ func (s *localizerPipeline) resume(ctx context.Context) bool { return false } +func (s *localizerPipeline) suspendStats() (suspendCount, waiters, cancelChs, queued int) { + s.Lock() + defer s.Unlock() + return s.suspendCount, len(s.suspendWaiters), len(s.cancelChs), len(s.jobQueue) +} + func (s *localizerPipeline) registerWaiter() chan struct{} { s.Lock() defer s.Unlock() @@ -430,13 +443,15 @@ func (s *localizerPipeline) registerWaiter() chan struct{} { func (s *localizerPipeline) localizeJobPulled(job *localizerPipelineJob, stopCh chan struct{}) { id, cancelCh := s.registerJobPull(job.ctx) defer s.finishJobPull(id) - s.Debug(job.ctx, "localizeJobPulled: pulling job: pending: %d completed: %d", job.numPending(), - job.numCompleted()) + s.Debug(job.ctx, "localizeJobPulled[%s]: pulling job: pending: %d completed: %d", id, + job.numPending(), job.numCompleted()) waitCh := make(chan struct{}) if !globals.IsLocalizerCancelableCtx(job.ctx) { close(waitCh) } else { - s.Debug(job.ctx, "localizeJobPulled: waiting for resume") + suspendCount, waiters, cancelChs, queued := s.suspendStats() + s.Debug(job.ctx, "localizeJobPulled[%s]: waiting for resume suspendCount: %d waiters: %d cancelChs: %d queued: %d", + id, suspendCount, waiters, cancelChs, queued) go func() { <-s.registerWaiter() close(waitCh) @@ -444,9 +459,11 @@ func (s *localizerPipeline) localizeJobPulled(job *localizerPipelineJob, stopCh } select { case <-waitCh: - s.Debug(job.ctx, "localizeJobPulled: resume, proceeding") + suspendCount, waiters, cancelChs, queued := s.suspendStats() + s.Debug(job.ctx, "localizeJobPulled[%s]: resume, proceeding suspendCount: %d waiters: %d cancelChs: %d queued: %d", + id, suspendCount, waiters, cancelChs, queued) case <-stopCh: - s.Debug(job.ctx, "localizeJobPulled: shutting down") + s.Debug(job.ctx, "localizeJobPulled[%s]: shutting down", id) return } s.jobPulled(job.ctx, job) @@ -455,25 +472,25 @@ func (s *localizerPipeline) localizeJobPulled(job *localizerPipelineJob, stopCh defer close(doneCh) if err := s.localizeConversations(job); err == context.Canceled { // just put this right back if we canceled it - s.Debug(job.ctx, "localizeJobPulled: re-enqueuing canceled job") + s.Debug(job.ctx, "localizeJobPulled[%s]: re-enqueuing canceled job", id) s.jobQueue <- job.retry(s.G()) } if job.closeIfDone() { - s.Debug(job.ctx, "localizeJobPulled: all job tasks complete") + s.Debug(job.ctx, "localizeJobPulled[%s]: all job tasks complete", id) } }() select { case <-doneCh: job.cancelFn() case <-cancelCh: - s.Debug(job.ctx, "localizeJobPulled: canceled a live job") + s.Debug(job.ctx, "localizeJobPulled[%s]: canceled a live job", id) job.cancelFn() case <-stopCh: - s.Debug(job.ctx, "localizeJobPulled: shutting down") + s.Debug(job.ctx, "localizeJobPulled[%s]: shutting down", id) job.cancelFn() return } - s.Debug(job.ctx, "localizeJobPulled: job pass complete") + s.Debug(job.ctx, "localizeJobPulled[%s]: job pass complete", id) } func (s *localizerPipeline) localizeLoop(stopCh chan struct{}) { diff --git a/go/chat/server.go b/go/chat/server.go index ed1fe06430cb..8923379987b0 100644 --- a/go/chat/server.go +++ b/go/chat/server.go @@ -171,8 +171,13 @@ func (h *Server) RequestInboxLayout(ctx context.Context, reselectMode chat1.Inbo func (h *Server) RequestInboxUnbox(ctx context.Context, convIDs []chat1.ConversationID) (err error) { ctx = globals.ChatCtx(ctx, h.G(), keybase1.TLFIdentifyBehavior_CHAT_GUI, nil, nil) ctx = globals.CtxAddLocalizerCancelable(ctx) + reqID := libkb.RandStringB64(3) defer h.Trace(ctx, &err, "RequestInboxUnbox")() defer h.PerfTrace(ctx, &err, "RequestInboxUnbox")() + h.Debug(ctx, "RequestInboxUnbox[%s]: begin convs: %d", reqID, len(convIDs)) + defer func() { + h.Debug(ctx, "RequestInboxUnbox[%s]: return err: %v", reqID, err) + }() for _, convID := range convIDs { h.GetPerfLog().CDebugf(ctx, "RequestInboxUnbox: queuing unbox for: %s", convID) h.Debug(ctx, "RequestInboxUnbox: queuing unbox for: %s", convID) @@ -398,14 +403,26 @@ func (h *Server) GetUnreadline(ctx context.Context, arg chat1.GetUnreadlineArg) func (h *Server) GetThreadNonblock(ctx context.Context, arg chat1.GetThreadNonblockArg) (res chat1.NonblockFetchRes, err error) { var identBreaks []keybase1.TLFIdentifyFailure ctx = globals.ChatCtx(ctx, h.G(), arg.IdentifyBehavior, &identBreaks, h.identNotifier) + reqID := libkb.RandStringB64(3) defer h.Trace(ctx, &err, "GetThreadNonblock(%s,%v,%v)", arg.ConversationID, arg.CbMode, arg.Reason)() defer h.PerfTrace(ctx, &err, "GetThreadNonblock(%s,%v,%v)", arg.ConversationID, arg.CbMode, arg.Reason)() defer func() { h.setResultRateLimit(ctx, &res) }() defer func() { err = h.handleOfflineError(ctx, err, &res) }() + defer func() { + h.Debug(ctx, "GetThreadNonblock[%s]: return convID: %s err: %v", reqID, arg.ConversationID, err) + }() defer h.suspendBgConvLoads(ctx)() - defer h.suspendInboxSource(ctx)() + h.Debug(ctx, "GetThreadNonblock[%s]: suspend inbox source begin convID: %s", reqID, arg.ConversationID) + resumeInboxSource := h.suspendInboxSource(ctx) + h.Debug(ctx, "GetThreadNonblock[%s]: suspend inbox source done convID: %s", reqID, arg.ConversationID) + defer func() { + h.Debug(ctx, "GetThreadNonblock[%s]: resume inbox source begin convID: %s", reqID, arg.ConversationID) + resumeInboxSource() + h.Debug(ctx, "GetThreadNonblock[%s]: resume inbox source done convID: %s", reqID, arg.ConversationID) + }() + h.Debug(ctx, "GetThreadNonblock[%s]: begin convID: %s sessionID: %d", reqID, arg.ConversationID, arg.SessionID) uid, err := utils.AssertLoggedInUID(ctx, h.G()) if err != nil { return chat1.NonblockFetchRes{}, err diff --git a/go/chat/uithreadloader.go b/go/chat/uithreadloader.go index 3840ce497657..8a7a16f20b78 100644 --- a/go/chat/uithreadloader.go +++ b/go/chat/uithreadloader.go @@ -501,7 +501,14 @@ func (t *UIThreadLoader) LoadNonblock(ctx context.Context, chatUI libkb.ChatUI, ) (err error) { var pagination, resultPagination *chat1.Pagination var fullErr error + reqID := libkb.RandStringB64(3) + fullSent := false defer t.Trace(ctx, &err, "LoadNonblock")() + t.Debug(ctx, "LoadNonblock[%s]: begin convID: %s reason: %v", reqID, convID, reason) + defer func() { + t.Debug(ctx, "LoadNonblock[%s]: return convID: %s err: %v fullErr: %v fullSent: %v", + reqID, convID, err, fullErr, fullSent) + }() defer func() { // Detect any problem loading the thread, and queue it up in the retrier if there is a problem. // Otherwise, send notice that we successfully loaded the conversation. @@ -539,7 +546,7 @@ func (t *UIThreadLoader) LoadNonblock(ctx context.Context, chatUI libkb.ChatUI, return err } defer t.G().ConvSource.ReleaseConversationLock(ctx, uid, convID) - t.Debug(ctx, "LoadNonblock: conversation lock obtained") + t.Debug(ctx, "LoadNonblock[%s]: conversation lock obtained convID: %s", reqID, convID) // Enable delete placeholders for supersede transform if query == nil { @@ -648,11 +655,11 @@ func (t *UIThreadLoader) LoadNonblock(ctx context.Context, chatUI libkb.ChatUI, } else { t.Debug(ctx, "LoadNonblock: sending nil cached response") } - start := time.Now() + t.Debug(ctx, "LoadNonblock[%s]: cached send begin convID: %s", reqID, convID) if err := chatUI.ChatThreadCached(ctx, pthread); err != nil { t.Debug(ctx, "LoadNonblock: failed to send cached thread: %s", err) } - t.Debug(ctx, "LoadNonblock: cached response send time: %v", time.Since(start)) + t.Debug(ctx, "LoadNonblock[%s]: cached send done convID: %s", reqID, convID) }(localCtx) startTime := t.clock.Now() @@ -708,23 +715,25 @@ func (t *UIThreadLoader) LoadNonblock(ctx context.Context, chatUI libkb.ChatUI, } resultPagination = rthread.Pagination t.applyPagerModeOutgoing(ctx, convID, rthread.Pagination, pagination, pgmode) - start = time.Now() + t.Debug(ctx, "LoadNonblock[%s]: full send begin convID: %s", reqID, convID) if fullErr = chatUI.ChatThreadFull(ctx, string(jsonUIRes)); err != nil { t.Debug(ctx, "LoadNonblock: failed to send full result to UI: %s", err) return } - t.Debug(ctx, "LoadNonblock: full response send time: %v", time.Since(start)) + fullSent = true + t.Debug(ctx, "LoadNonblock[%s]: full send done convID: %s", reqID, convID) // This means we transmitted with success, so cancel local thread cancel() }() wg.Wait() - t.Debug(ctx, "LoadNonblock: thread payloads transferred, checking for resolve") + t.Debug(ctx, "LoadNonblock[%s]: payload transfer complete convID: %s fullSent: %v", reqID, convID, fullSent) // Resolve any messages we didn't cache and get full information about if fullErr == nil { fullErr = func() error { skips := globals.CtxMessageCacheSkips(ctx) + t.Debug(ctx, "LoadNonblock[%s]: post-send resolve begin convID: %s skips: %d", reqID, convID, len(skips)) cancelUIStatus := t.setUIStatus(ctx, chatUI, chat1.NewUIChatThreadStatusWithValidating(0), getDelay()) defer func() { @@ -797,13 +806,14 @@ func (t *UIThreadLoader) LoadNonblock(ctx context.Context, chatUI libkb.ChatUI, t.G().ActivityNotifier.Activity(ctx, uid, chat1.TopicType_CHAT, &act, chat1.ChatActivitySource_LOCAL) } + t.Debug(ctx, "LoadNonblock[%s]: post-send resolve done convID: %s", reqID, convID) return nil }() } // Clean up context and set final loading status if getDisplayedStatus() { - t.Debug(ctx, "LoadNonblock: status displayed, clearing") + t.Debug(ctx, "LoadNonblock[%s]: final status clear begin convID: %s", reqID, convID) t.clock.Sleep(t.validatedDelay) // use a background context here in case our context has been canceled, we don't want to not // get this banner off the screen. @@ -820,7 +830,7 @@ func (t *UIThreadLoader) LoadNonblock(ctx context.Context, chatUI libkb.ChatUI, t.Debug(ctx, "LoadNonblock: failed to set status: %s", err) } } - t.Debug(ctx, "LoadNonblock: clear complete") + t.Debug(ctx, "LoadNonblock[%s]: final status clear done convID: %s", reqID, convID) } else { t.Debug(ctx, "LoadNonblock: no status displayed, not clearing") } From 5832799a3e980ac002ad6be57177f68d6dca6902 Mon Sep 17 00:00:00 2001 From: chrisnojima <cnojima@keyba.se> Date: Wed, 8 Apr 2026 22:36:19 -0400 Subject: [PATCH 42/55] WIP --- shared/chat/conversation/messages/wrapper/wrapper.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index 612ecf99816a..09f42ab197b2 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -914,8 +914,8 @@ export function WrapperMessage(p: WrapperMessageProps) { hasReactions, hasUnfurlList, isHighlighted, - messageKey, messageDelete, + messageKey, messageRetry, ordinal, outboxID, From 0d9855b9d583c513723ec5f8b8c29081b527c771 Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Wed, 8 Apr 2026 22:49:13 -0400 Subject: [PATCH 43/55] WIP --- PLAN.md | 7 +- .../chat/conversation/messages/emoji-row.tsx | 21 +++- .../messages/message-popup/reactionitem.tsx | 16 ++- .../conversation/messages/react-button.tsx | 115 ++++++++++-------- .../messages/reaction-tooltip.tsx | 90 +++++++------- .../conversation/messages/reactions-rows.tsx | 15 ++- 6 files changed, 158 insertions(+), 106 deletions(-) diff --git a/PLAN.md b/PLAN.md index f3ce82fbd70a..da0baeaf2ab1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -52,7 +52,12 @@ Primary files: - [x] Move toward one main convo-store subscription per mounted row. - [x] Push row data down as props instead of reopening store subscriptions in reply, reactions, emoji, send-indicator, exploding-meta, and similar children. - [x] Audit attachment and unfurl helpers for repeated `messageMap.get(ordinal)` selectors. -- [ ] Keep selectors narrow and stable when a child still needs to subscribe directly. +- [x] Keep selectors narrow and stable when a child still needs to subscribe directly. + +Decision note: + +- Avoid override/fallback component modes when a parent can supply concrete row data. +- Prefer separate components for distinct behaviors, such as a real reaction chip versus an add-reaction button, rather than one component that mixes controlled, connected, and fallback paths. Primary files: diff --git a/shared/chat/conversation/messages/emoji-row.tsx b/shared/chat/conversation/messages/emoji-row.tsx index 3073e1bcdebc..3ebb10f9f3cc 100644 --- a/shared/chat/conversation/messages/emoji-row.tsx +++ b/shared/chat/conversation/messages/emoji-row.tsx @@ -16,12 +16,27 @@ type OwnProps = { style?: Kb.Styles.StylesCrossPlatform } +const useTopReacjis = () => + Chat.useChatState( + C.useShallow(s => [ + s.userReacjis.topReacjis[0], + s.userReacjis.topReacjis[1], + s.userReacjis.topReacjis[2], + s.userReacjis.topReacjis[3], + s.userReacjis.topReacjis[4], + ]) + ).filter((reacji): reacji is T.RPCGen.UserReacji => !!reacji) + function EmojiRowContainer(p: OwnProps) { const {className, hasUnfurls, messageType, onReact: onReactProp, onReply: onReplyProp, onShowingEmojiPicker, style} = p const ordinal = useOrdinal() - const setReplyTo = Chat.useChatContext(s => s.dispatch.setReplyTo) - const toggleMessageReaction = Chat.useChatContext(s => s.dispatch.toggleMessageReaction) - const emojis = Chat.useChatState(C.useShallow(s => s.userReacjis.topReacjis.slice(0, 5))) + const {setReplyTo, toggleMessageReaction} = Chat.useChatContext( + C.useShallow(s => ({ + setReplyTo: s.dispatch.setReplyTo, + toggleMessageReaction: s.dispatch.toggleMessageReaction, + })) + ) + const emojis = useTopReacjis() const navigateAppend = Chat.useChatNavigateAppend() const _onForward = () => { navigateAppend(conversationIDKey => ({ diff --git a/shared/chat/conversation/messages/message-popup/reactionitem.tsx b/shared/chat/conversation/messages/message-popup/reactionitem.tsx index 3771eea7b50d..014c61e87d5c 100644 --- a/shared/chat/conversation/messages/message-popup/reactionitem.tsx +++ b/shared/chat/conversation/messages/message-popup/reactionitem.tsx @@ -1,5 +1,7 @@ +import * as C from '@/constants' import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' +import type * as T from '@/constants/types' type Props = { onHidden: () => void @@ -7,8 +9,19 @@ type Props = { showPicker: () => void } +const useTopReacjis = () => + Chat.useChatState( + C.useShallow(s => [ + s.userReacjis.topReacjis[0], + s.userReacjis.topReacjis[1], + s.userReacjis.topReacjis[2], + s.userReacjis.topReacjis[3], + s.userReacjis.topReacjis[4], + ]) + ).filter((reacji): reacji is T.RPCGen.UserReacji => !!reacji) + const ReactionItem = (props: Props) => { - const _topReacjis = Chat.useChatState(s => s.userReacjis.topReacjis) + const topReacjis = useTopReacjis() const onReact = (emoji: string) => { props.onReact(emoji) props.onHidden() @@ -20,7 +33,6 @@ const ReactionItem = (props: Props) => { props.showPicker() }, 100) } - const topReacjis = _topReacjis.slice(0, 5) return ( <Kb.Box2 direction="horizontal" fullWidth={true} flex={1} style={styles.container} justifyContent="space-between"> {topReacjis.map((r, idx) => ( diff --git a/shared/chat/conversation/messages/react-button.tsx b/shared/chat/conversation/messages/react-button.tsx index e6f7070c99c0..3fbcd7766bc3 100644 --- a/shared/chat/conversation/messages/react-button.tsx +++ b/shared/chat/conversation/messages/react-button.tsx @@ -11,57 +11,33 @@ import type * as T from '@/constants/types' export type OwnProps = { className?: string - emoji?: string + emoji: string onLongPress?: () => void - reaction?: T.Chat.ReactionDesc - showBorder?: boolean + reaction: T.Chat.ReactionDesc style?: StylesCrossPlatform toggleReaction?: (emoji: string) => void } -function ReactButtonContainer(p: OwnProps) { - const ordinal = useOrdinal() - const {onLongPress, style, emoji, className, reaction} = p - const me = useCurrentUserState(s => s.username) - const isDarkMode = useColorScheme() === 'dark' - const {active: subscriptionActive, count: subscriptionCount, decorated: subscriptionDecorated} = Chat.useChatContext( - C.useShallow(s => { - if (reaction || !emoji) { - return {active: false, count: 0, decorated: ''} - } - const message = s.messageMap.get(ordinal) - const reactionDesc = message?.reactions?.get(emoji || '') - const active = (reactionDesc?.users ?? []).some(r => r.username === me) - return { - active, - count: reactionDesc?.users.length ?? 0, - decorated: reactionDesc?.decorated ?? '', - } - }) - ) - - const toggleMessageReaction = Chat.useChatContext(s => s.dispatch.toggleMessageReaction) - const active = reaction ? reaction.users.some(r => r.username === me) : subscriptionActive - const count = reaction?.users.length ?? subscriptionCount - const decorated = reaction?.decorated ?? subscriptionDecorated - const onClick = () => { - if (!emoji) return - if (p.toggleReaction) { - p.toggleReaction(emoji) - return - } - toggleMessageReaction(ordinal, emoji) - } - const navigateAppend = Chat.useChatNavigateAppend() - const onOpenEmojiPicker = () => { - navigateAppend(conversationIDKey => ({ - name: 'chatChooseEmoji', - params: {conversationIDKey, onPickAddToMessageOrdinal: ordinal, pickKey: 'reaction'}, - })) - } - - const text = decorated.length ? decorated : emoji - return emoji ? ( +function ReactionButton({ + active, + className, + count, + isDarkMode, + onClick, + onLongPress, + style, + text, +}: { + active: boolean + className?: string + count: number + isDarkMode: boolean + onClick: () => void + onLongPress?: () => void + style?: StylesCrossPlatform + text: string +}) { + return ( <Kb.ClickableBox2 className={Kb.Styles.classNames('react-button', className, {noShadow: active})} onLongPress={onLongPress} @@ -96,7 +72,50 @@ function ReactButtonContainer(p: OwnProps) { </Kb.Text> </Kb.Box2> </Kb.ClickableBox2> - ) : ( + ) +} + +function ReactButtonContainer(p: OwnProps) { + const {emoji, reaction} = p + const me = useCurrentUserState(s => s.username) + const isDarkMode = useColorScheme() === 'dark' + const onClick = () => { + p.toggleReaction?.(emoji) + } + const active = reaction.users.some(r => r.username === me) + const count = reaction.users.length + const text = reaction.decorated || emoji + + return ( + <ReactionButton + active={active} + className={p.className} + count={count} + isDarkMode={isDarkMode} + onClick={onClick} + onLongPress={p.onLongPress} + style={p.style} + text={text} + /> + ) +} + +type NewReactionButtonProps = { + style?: StylesCrossPlatform +} + +export function NewReactionButton(p: NewReactionButtonProps) { + const ordinal = useOrdinal() + const isDarkMode = useColorScheme() === 'dark' + const navigateAppend = Chat.useChatNavigateAppend() + const onOpenEmojiPicker = () => { + navigateAppend(conversationIDKey => ({ + name: 'chatChooseEmoji', + params: {conversationIDKey, onPickAddToMessageOrdinal: ordinal, pickKey: 'reaction'}, + })) + } + + return ( <Kb.ClickableBox2 onClick={onOpenEmojiPicker} style={Kb.Styles.collapseStyles([ @@ -104,7 +123,7 @@ function ReactButtonContainer(p: OwnProps) { {borderColor: isDarkMode ? darkColors.black_10 : colors.black_10}, styles.newReactionButtonBox, styles.buttonBox, - style, + p.style, ])} > <Kb.Box2 centerChildren={true} fullHeight={true} direction="horizontal"> diff --git a/shared/chat/conversation/messages/reaction-tooltip.tsx b/shared/chat/conversation/messages/reaction-tooltip.tsx index edd409c0d8eb..d6ce86a588fc 100644 --- a/shared/chat/conversation/messages/reaction-tooltip.tsx +++ b/shared/chat/conversation/messages/reaction-tooltip.tsx @@ -19,26 +19,25 @@ type OwnProps = { visible: boolean } -const emptyStateProps = { - _reactions: new Map<string, T.Chat.ReactionDesc>(), - _usersInfo: new Map<string, T.Users.UserInfo>(), +const emptyReactions = new Map<string, T.Chat.ReactionDesc>() +const emptyUsersInfo = new Map<string, T.Users.UserInfo>() + +type Section = { + data: Array<ListItem> + ordinal: T.Chat.Ordinal + reaction: T.Chat.ReactionDesc + title: string } const ReactionTooltip = (p: OwnProps) => { const {ordinal, onHidden, attachmentRef, onMouseLeave, onMouseOver, visible, emoji} = p - const infoMap = useUsersState(s => s.infoMap) - const {_reactions, good} = Chat.useChatContext( - C.useShallow(s => { - const message = s.messageMap.get(ordinal) - if (message && Chat.isMessageWithReactions(message)) { - const _reactions = message.reactions - return {_reactions, good: true} - } - return {...emptyStateProps, good: false} - }) - ) - const _usersInfo = good ? infoMap : emptyStateProps._usersInfo + const reactions = Chat.useChatContext(s => { + const message = s.messageMap.get(ordinal) + return message && Chat.isMessageWithReactions(message) ? message.reactions : undefined + }) + const usersInfo = useUsersState(s => (reactions ? s.infoMap : emptyUsersInfo)) + const toggleMessageReaction = Chat.useChatContext(s => s.dispatch.toggleMessageReaction) const navigateAppend = Chat.useChatNavigateAppend() const onAddReaction = () => { @@ -49,23 +48,26 @@ const ReactionTooltip = (p: OwnProps) => { })) } - let reactions = [...(_reactions?.keys() ?? [])] + let reactionsToShow = [...(reactions?.keys() ?? emptyReactions.keys())] .map(emoji => { - const reactionUsers = _reactions?.get(emoji)?.users ?? [] + const reaction = reactions?.get(emoji) + const reactionUsers = reactions?.get(emoji)?.users ?? [] const sortedUsers = [...reactionUsers].sort((a, b) => a.timestamp - b.timestamp) return { earliestTimestamp: sortedUsers[0]?.timestamp ?? 0, emoji, + reaction, users: sortedUsers.map(r => ({ - fullName: (_usersInfo.get(r.username) || {fullname: ''}).fullname || '', + fullName: (usersInfo.get(r.username) || {fullname: ''}).fullname || '', username: r.username, })), } }) + .filter((r): r is {earliestTimestamp: number; emoji: string; reaction: T.Chat.ReactionDesc; users: Array<ListItem>} => !!r.reaction) .sort((a, b) => a.earliestTimestamp - b.earliestTimestamp) - .map(({emoji, users}) => ({emoji, users})) + .map(({emoji, reaction, users}) => ({emoji, reaction, users})) if (!C.isMobile && emoji) { - reactions = reactions.filter(r => r.emoji === emoji) + reactionsToShow = reactionsToShow.filter(r => r.emoji === emoji) } const insets = Kb.useSafeAreaInsets() const conversationIDKey = Chat.useChatContext(s => s.id) @@ -74,12 +76,33 @@ const ReactionTooltip = (p: OwnProps) => { return null } - const sections = reactions.map(r => ({ + const sections = reactionsToShow.map(r => ({ data: r.users.map(u => ({...u, key: `${u.username}:${r.emoji}`})), key: r.emoji, ordinal: ordinal, + reaction: r.reaction, title: r.emoji, })) + const renderSectionHeader = ({section}: {section: Section}) => ( + <Kb.Box2 + key={section.title} + direction="horizontal" + gap="tiny" + gapStart={true} + gapEnd={true} + fullWidth={true} + style={styles.buttonContainer} + > + <ReactButton + emoji={section.title} + reaction={section.reaction} + toggleReaction={emoji => toggleMessageReaction(section.ordinal, emoji)} + /> + <Kb.Text type="Terminal" lineClamp={1} style={styles.emojiText}> + {section.title} + </Kb.Text> + </Kb.Box2> + ) return ( <Kb.Popup @@ -154,31 +177,6 @@ const renderItem = ({item}: {item: ListItem}) => { ) } -const renderSectionHeader = ({ - section, -}: { - section: { - data: Array<ListItem> - ordinal: T.Chat.Ordinal - title: string - } -}) => ( - <Kb.Box2 - key={section.title} - direction="horizontal" - gap="tiny" - gapStart={true} - gapEnd={true} - fullWidth={true} - style={styles.buttonContainer} - > - <ReactButton emoji={section.title} /> - <Kb.Text type="Terminal" lineClamp={1} style={styles.emojiText}> - {section.title} - </Kb.Text> - </Kb.Box2> -) - const styles = Kb.Styles.styleSheetCreate( () => ({ diff --git a/shared/chat/conversation/messages/reactions-rows.tsx b/shared/chat/conversation/messages/reactions-rows.tsx index 2ccd4e68b193..667750eb3c84 100644 --- a/shared/chat/conversation/messages/reactions-rows.tsx +++ b/shared/chat/conversation/messages/reactions-rows.tsx @@ -2,7 +2,7 @@ import * as Message from '@/constants/chat/message' import * as Kb from '@/common-adapters' import * as React from 'react' import EmojiRow from './emoji-row' -import ReactButton from './react-button' +import ReactButton, {NewReactionButton} from './react-button' import ReactionTooltip from './reaction-tooltip' import type * as T from '@/constants/types' import {useOrdinal} from './ids-context' @@ -24,11 +24,14 @@ function ReactionsRowContainer(p: OwnProps) { return emojis.length === 0 ? null : ( <Kb.Box2 direction="horizontal" gap="xtiny" fullWidth={true} style={styles.container}> - {emojis.map((emoji, idx) => ( - <RowItem key={emoji || String(idx)} emoji={emoji} onReact={onReact} reaction={reactions?.get(emoji)} /> - ))} + {emojis.map((emoji, idx) => { + const reaction = reactions?.get(emoji) + return reaction ? ( + <RowItem key={emoji || String(idx)} emoji={emoji} onReact={onReact} reaction={reaction} /> + ) : null + })} {Kb.Styles.isMobile ? ( - <ReactButton showBorder={true} style={styles.button} /> + <NewReactionButton style={styles.button} /> ) : ( <EmojiRow className={Kb.Styles.classNames([btnClassName, newBtnClassName])} @@ -49,7 +52,7 @@ const newBtnClassName = 'WrapperMessage-newEmojiButton' type IProps = { emoji: string onReact: (emoji: string) => void - reaction?: T.Chat.ReactionDesc + reaction: T.Chat.ReactionDesc } function RowItem(p: IProps) { const ordinal = useOrdinal() From 505416f00a16ba8521e773b26755cef1f427baf4 Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Thu, 9 Apr 2026 08:21:17 -0400 Subject: [PATCH 44/55] WIP --- PLAN.md | 1 + shared/chat/conversation/container.tsx | 3 +- .../conversation/input-area/container.tsx | 3 +- .../conversation/normal/index.desktop.tsx | 3 +- shared/chat/conversation/search.tsx | 193 +++++++++++++++--- shared/chat/inbox-and-conversation-header.tsx | 3 +- shared/chat/inbox-and-conversation.tsx | 3 +- shared/constants/router.tsx | 14 +- shared/stores/chat.tsx | 12 +- shared/stores/convostate.tsx | 163 +++------------ shared/stores/tests/convostate.test.ts | 14 +- 11 files changed, 218 insertions(+), 194 deletions(-) diff --git a/PLAN.md b/PLAN.md index da0baeaf2ab1..903e82e4b651 100644 --- a/PLAN.md +++ b/PLAN.md @@ -72,6 +72,7 @@ Primary files: ### 4. Split Volatile UI State From Message Data - [ ] Inventory convo-store fields that are transient UI state rather than message graph state. +- [x] Move thread-search visibility and search request/results state out of `convostate` into route params plus screen-local UI state. - [ ] Move route-local or composer-local state out of the main convo message store. - [ ] Keep dispatch call sites readable and avoid direct component store mutation. - [ ] Minimize unrelated selector recalculation when typing/search/composer state changes. diff --git a/shared/chat/conversation/container.tsx b/shared/chat/conversation/container.tsx index 9986ab9385db..f27584c978ed 100644 --- a/shared/chat/conversation/container.tsx +++ b/shared/chat/conversation/container.tsx @@ -5,8 +5,9 @@ import NoConversation from './no-conversation' import Error from './error' import YouAreReset from './you-are-reset' import Rekey from './rekey/container' +import type {ThreadSearchRouteProps} from './thread-search-route' -const Conversation = function Conversation() { +const Conversation = function Conversation(_: ThreadSearchRouteProps) { const type = Chat.useChatContext(s => { const meta = s.meta switch (s.id) { diff --git a/shared/chat/conversation/input-area/container.tsx b/shared/chat/conversation/input-area/container.tsx index b1ed00f120ef..4482b2915fb3 100644 --- a/shared/chat/conversation/input-area/container.tsx +++ b/shared/chat/conversation/input-area/container.tsx @@ -4,10 +4,11 @@ import {PerfProfiler} from '@/perf/react-profiler' import Normal from './normal' import Preview from './preview' import ThreadSearch from '../search' +import {useThreadSearchRoute} from '../thread-search-route' const InputAreaContainer = () => { const conversationIDKey = Chat.useChatContext(s => s.id) - const showThreadSearch = Chat.useChatContext(s => s.threadSearchInfo.visible) + const showThreadSearch = !!useThreadSearchRoute() const {membershipType, resetParticipants, wasFinalizedBy} = Chat.useChatContext( C.useShallow(s => { const {membershipType, resetParticipants, wasFinalizedBy} = s.meta diff --git a/shared/chat/conversation/normal/index.desktop.tsx b/shared/chat/conversation/normal/index.desktop.tsx index 4edf77c7dfaf..1c92e11b8d3b 100644 --- a/shared/chat/conversation/normal/index.desktop.tsx +++ b/shared/chat/conversation/normal/index.desktop.tsx @@ -10,6 +10,7 @@ import ListArea from '../list-area' import PinnedMessage from '../pinned-message' import ThreadLoadStatus from '../load-status' import ThreadSearch from '../search' +import {useThreadSearchRoute} from '../thread-search-route' import {readImageFromClipboard} from '@/util/clipboard.desktop' import '../conversation.css' import {indefiniteArticle} from '@/util/string' @@ -39,7 +40,7 @@ const Conversation = function Conversation() { params: {conversationIDKey, pathAndOutboxIDs}, })) } - const showThreadSearch = Chat.useChatContext(s => s.threadSearchInfo.visible) + const showThreadSearch = !!useThreadSearchRoute() const cannotWrite = Chat.useChatContext(s => s.meta.cannotWrite) const threadLoadedOffline = Chat.useChatContext(s => s.meta.offline) const dragAndDropRejectReason = Chat.useChatContext(s => { diff --git a/shared/chat/conversation/search.tsx b/shared/chat/conversation/search.tsx index 0d11d8c13635..74172fe103cd 100644 --- a/shared/chat/conversation/search.tsx +++ b/shared/chat/conversation/search.tsx @@ -1,69 +1,171 @@ import * as C from '@/constants' +import * as Message from '@/constants/chat/message' import * as Chat from '@/stores/chat' import type * as Styles from '@/styles' +import * as T from '@/constants/types' import * as React from 'react' import * as Kb from '@/common-adapters' +import {RPCError} from '@/util/errors' import {formatTimeForMessages} from '@/util/timestamp' +import {useCurrentUserState} from '@/stores/current-user' +import {useThreadSearchRoute} from './thread-search-route' type OwnProps = {style?: Styles.StylesCrossPlatform} +type SearchState = { + hits: Array<T.Chat.Message> + status: T.Chat.ThreadSearchInfo['status'] +} + const useCommon = (ownProps: OwnProps) => { const {style} = ownProps - - const data = Chat.useChatContext( - C.useShallow(s => { - const {id: conversationIDKey, threadSearchInfo, threadSearchQuery: initialText, dispatch} = s - const {hits: _hits, status} = threadSearchInfo - const {loadMessagesCentered, setThreadSearchQuery, toggleThreadSearch, threadSearch} = dispatch - return { - _hits, - conversationIDKey, - initialText, - loadMessagesCentered, - setThreadSearchQuery, - status, - threadSearch, - toggleThreadSearch, - } - }) + const initialQuery = useThreadSearchRoute()?.query ?? '' + const {conversationIDKey, loadMessagesCentered, toggleThreadSearch} = Chat.useChatContext( + C.useShallow(s => ({ + conversationIDKey: s.id, + loadMessagesCentered: s.dispatch.loadMessagesCentered, + toggleThreadSearch: s.dispatch.toggleThreadSearch, + })) ) - - const {conversationIDKey, _hits, status, initialText} = data - const {loadMessagesCentered, setThreadSearchQuery, toggleThreadSearch, threadSearch} = data const onToggleThreadSearch = () => { toggleThreadSearch() } - const numHits = _hits.length - const hits = _hits.map(h => ({ + const [searchState, setSearchState] = React.useState<SearchState>({hits: [], status: 'initial'}) + const {hits: messageHits, status} = searchState + const numHits = messageHits.length + const hits = messageHits.map(h => ({ author: h.author, summary: h.bodySummary.stringValue(), timestamp: h.timestamp, })) - const [selectedIndex, setSelectedIndex] = React.useState(0) const [text, setText] = React.useState('') const [lastSearch, setLastSearch] = React.useState('') + const searchOrdinalRef = React.useRef(0) + const hitsRef = React.useRef(messageHits) + React.useEffect(() => { + hitsRef.current = messageHits + }, [messageHits]) + + const runThreadSearch = React.useEffectEvent((query: string) => { + const requestOrdinal = searchOrdinalRef.current + 1 + searchOrdinalRef.current = requestOrdinal + setSearchState({hits: [], status: query ? 'inprogress' : 'done'}) + if (!query) { + return + } + + const {devicename, username} = useCurrentUserState.getState() + const getLastOrdinal = () => + Chat.getConvoState(conversationIDKey).messageOrdinals?.at(-1) ?? T.Chat.numberToOrdinal(0) + const updateIfCurrent = (updater: (state: SearchState) => SearchState) => { + if (searchOrdinalRef.current !== requestOrdinal) { + return + } + setSearchState(state => (searchOrdinalRef.current === requestOrdinal ? updater(state) : state)) + } + const onDone = () => { + updateIfCurrent(state => ({...state, status: 'done'})) + } + + const f = async () => { + try { + await T.RPCChat.localSearchInboxRpcListener({ + incomingCallMap: { + 'chat.1.chatUi.chatSearchDone': onDone, + 'chat.1.chatUi.chatSearchHit': hit => { + const message = Message.uiMessageToMessage( + conversationIDKey, + hit.searchHit.hitMessage, + username, + getLastOrdinal, + devicename + ) + if (!message) { + return + } + updateIfCurrent(state => + state.hits.find(existing => existing.id === message.id) + ? state + : {...state, hits: [...state.hits, message]} + ) + }, + 'chat.1.chatUi.chatSearchInboxDone': onDone, + 'chat.1.chatUi.chatSearchInboxHit': resp => { + const messages = (resp.searchHit.hits || []).reduce<Array<T.Chat.Message>>((result, hit) => { + const message = Message.uiMessageToMessage( + conversationIDKey, + hit.hitMessage, + username, + getLastOrdinal, + devicename + ) + if (message) { + result.push(message) + } + return result + }, []) + updateIfCurrent(state => ({...state, hits: messages})) + }, + 'chat.1.chatUi.chatSearchInboxStart': () => { + updateIfCurrent(state => ({...state, status: 'inprogress'})) + }, + }, + params: { + identifyBehavior: T.RPCGen.TLFIdentifyBehavior.chatGui, + namesOnly: false, + opts: { + afterContext: 0, + beforeContext: 0, + convID: Chat.getConvoState(conversationIDKey).getConvID(), + isRegex: false, + matchMentions: false, + maxBots: 0, + maxConvsHit: 0, + maxConvsSearched: 0, + maxHits: 1000, + maxMessages: -1, + maxNameConvs: 0, + maxTeams: 0, + reindexMode: T.RPCChat.ReIndexingMode.postsearchSync, + sentAfter: 0, + sentBefore: 0, + sentBy: '', + sentTo: '', + skipBotCache: false, + }, + query, + }, + }) + } catch (error) { + if (error instanceof RPCError) { + updateIfCurrent(state => ({...state, status: 'done'})) + } + } + } + C.ignorePromise(f()) + }) + const submitSearch = () => { setLastSearch(text) setSelectedIndex(0) - threadSearch(text) + runThreadSearch(text) } - const hitsRef = React.useRef(_hits) - React.useEffect(() => { - hitsRef.current = _hits - }, [_hits]) const [selectResult] = React.useState(() => (index: number) => { - const message = hitsRef.current[index] || Chat.makeMessageText() - if (message.id > 0) { + const message = hitsRef.current[index] + if (message?.id) { loadMessagesCentered(message.id, 'always') } setSelectedIndex(index) }) const onUp = () => { + if (!numHits) { + return + } if (selectedIndex >= numHits - 1) { selectResult(0) return @@ -80,6 +182,9 @@ const useCommon = (ownProps: OwnProps) => { } const onDown = () => { + if (!numHits) { + return + } if (selectedIndex <= 0) { selectResult(numHits - 1) return @@ -95,11 +200,31 @@ const useCommon = (ownProps: OwnProps) => { const hasResults = status === 'done' || numHits > 0 React.useEffect(() => { - if (initialText) { - setThreadSearchQuery('') - setText(initialText) + searchOrdinalRef.current += 1 + setSearchState({hits: [], status: 'initial'}) + setLastSearch('') + setSelectedIndex(0) + setText('') + }, [conversationIDKey]) + + React.useEffect(() => { + if (!initialQuery) { + return + } + setText(initialQuery) + setLastSearch(initialQuery) + setSelectedIndex(0) + runThreadSearch(initialQuery) + }, [conversationIDKey, initialQuery]) + + React.useEffect(() => { + return () => { + searchOrdinalRef.current += 1 + C.ignorePromise( + T.RPCChat.localCancelActiveSearchRpcPromise().catch(() => {}) + ) } - }, [initialText, setThreadSearchQuery]) + }, []) const hasHits = numHits > 0 const hadHitsRef = React.useRef(false) diff --git a/shared/chat/inbox-and-conversation-header.tsx b/shared/chat/inbox-and-conversation-header.tsx index 955b3fa7206c..4ca66af4ca3b 100644 --- a/shared/chat/inbox-and-conversation-header.tsx +++ b/shared/chat/inbox-and-conversation-header.tsx @@ -8,8 +8,9 @@ import {useRoute, type RouteProp} from '@react-navigation/native' import {useUsersState} from '@/stores/users' import {useCurrentUserState} from '@/stores/current-user' import * as Teams from '@/stores/teams' +import type {ThreadSearchRouteProps} from './conversation/thread-search-route' -type ChatRootParams = { +type ChatRootParams = ThreadSearchRouteProps & { conversationIDKey?: string infoPanel?: object } diff --git a/shared/chat/inbox-and-conversation.tsx b/shared/chat/inbox-and-conversation.tsx index e57b86e7bca4..9ecd83505100 100644 --- a/shared/chat/inbox-and-conversation.tsx +++ b/shared/chat/inbox-and-conversation.tsx @@ -8,8 +8,9 @@ import Conversation from './conversation/container' import Inbox from './inbox' import InboxSearch from './inbox-search' import InfoPanel, {type Panel} from './conversation/info-panel' +import type {ThreadSearchRouteProps} from './conversation/thread-search-route' -type Props = { +type Props = ThreadSearchRouteProps & { conversationIDKey?: T.Chat.ConversationIDKey infoPanel?: {tab?: Panel} } diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index a023d089e168..d026321b2354 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -389,19 +389,27 @@ export const setChatRootParams = (params: Partial<NonNullable<KBRootParamList['c }) } -export const navToThread = (conversationIDKey: T.Chat.ConversationIDKey) => { +type ThreadSearchNavParams = { + threadSearch?: {query?: string} +} + +export const navToThread = ( + conversationIDKey: T.Chat.ConversationIDKey, + navParams?: ThreadSearchNavParams +) => { DEBUG_NAV && console.log('[Nav] navToThread', conversationIDKey) const n = _getNavigator() if (!n) return const rs = getRootState() if (!rs?.key) return + const params = {conversationIDKey, threadSearch: navParams?.threadSearch} if (isSplit) { // Desktop/tablet: reset the tab navigator state to switch to chatTab with chatRoot params. // All tab stacks share the same screen config, so navigate('chatRoot') would target the // current tab. Separate switchTab + navigateAppend has a race (stale state between dispatches). // A single reset on the tab navigator atomically switches tabs and sets params. - setChatRootParams({conversationIDKey}) + setChatRootParams(params) } else { // Phone: switch to the chat tab, then push the conversation above the tabs. const nextState = { @@ -413,7 +421,7 @@ export const navToThread = (conversationIDKey: T.Chat.ConversationIDKey) => { routes: [{name: Tabs.chatTab, state: {index: 0, routes: [{name: 'chatRoot', params: {}}]}}], }, }, - {name: 'chatConversation', params: {conversationIDKey}}, + {name: 'chatConversation', params}, ], } n.dispatch({ diff --git a/shared/stores/chat.tsx b/shared/stores/chat.tsx index df2eff96649b..1a342b3cf5ba 100644 --- a/shared/stores/chat.tsx +++ b/shared/stores/chat.tsx @@ -890,13 +890,15 @@ export const useChatState = Z.createZustand<State>('chat', (set, get) => { query = selected?.query } - storeRegistry.getConvoState(conversationIDKey).dispatch.navigateToThread('inboxSearch') if (query) { - const cs = storeRegistry.getConvoState(conversationIDKey) - cs.dispatch.setThreadSearchQuery(query) - cs.dispatch.toggleThreadSearch(false) - cs.dispatch.threadSearch(query) + storeRegistry.getConvoState(conversationIDKey).dispatch.navigateToThread( + 'inboxSearch', + undefined, + undefined, + query + ) } else { + storeRegistry.getConvoState(conversationIDKey).dispatch.navigateToThread('inboxSearch') get().dispatch.toggleInboxSearch(false) } }, diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 4d020d1afcb5..5f1239524304 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -54,12 +54,6 @@ import type {useChatState, RefreshReason} from '@/stores/chat' const {darwinCopyToChatTempUploadFile} = KB2.functions -const makeThreadSearchInfo = (): T.Chat.ThreadSearchInfo => ({ - hits: [], - status: 'initial', - visible: false, -}) - const noParticipantInfo: T.Chat.ParticipantInfo = { all: [], contactName: new Map(), @@ -141,8 +135,6 @@ type ConvoStore = T.Immutable<{ separatorMap: Map<T.Chat.Ordinal, T.Chat.Ordinal> showUsernameMap: Map<T.Chat.Ordinal, string> threadLoadStatus: T.RPCChat.UIChatThreadStatusTyp - threadSearchInfo: T.Chat.ThreadSearchInfo - threadSearchQuery: string typing: ReadonlySet<string> unfurlPrompt: Map<T.Chat.MessageID, Set<string>> unread: number @@ -184,8 +176,6 @@ const initialConvoStore: ConvoStore = { separatorMap: new Map(), showUsernameMap: new Map(), threadLoadStatus: T.RPCChat.UIChatThreadStatusTyp.none, - threadSearchInfo: makeThreadSearchInfo(), - threadSearchQuery: '', typing: new Set(), unfurlPrompt: new Map(), unread: 0, @@ -291,7 +281,12 @@ export interface ConvoState extends ConvoStore { ordinals?: ReadonlyArray<T.Chat.Ordinal> }) => void mute: (m: boolean) => void - navigateToThread: (reason: NavReason, highlightMessageID?: T.Chat.MessageID, pushBody?: string) => void + navigateToThread: ( + reason: NavReason, + highlightMessageID?: T.Chat.MessageID, + pushBody?: string, + threadSearchQuery?: string + ) => void openFolder: () => void onEngineIncoming: (action: EngineGen.Actions) => void onIncomingMessage: (incoming: T.RPCChat.IncomingMessage) => void @@ -321,15 +316,13 @@ export interface ConvoState extends ConvoStore { setMinWriterRole: (role: T.Teams.TeamRoleType) => void setParticipants: (p: ConvoState['participants']) => void setReplyTo: (o: T.Chat.Ordinal) => void - setThreadSearchQuery: (query: string) => void setTyping: DebouncedFunc<(t: Set<string>) => void> showInfoPanel: (show: boolean, tab: 'settings' | 'members' | 'attachments' | 'bots' | undefined) => void tabSelected: () => void - threadSearch: (query: string) => void toggleGiphyPrefill: () => void toggleMessageCollapse: (messageID: T.Chat.MessageID, ordinal: T.Chat.Ordinal) => void toggleMessageReaction: (ordinal: T.Chat.Ordinal, emoji: string) => void - toggleThreadSearch: (hide?: boolean) => void + toggleThreadSearch: (hide?: boolean, query?: string) => void unfurlResolvePrompt: ( messageID: T.Chat.MessageID, domain: string, @@ -2477,9 +2470,8 @@ const createSlice = } ignorePromise(f()) }, - navigateToThread: (_reason, highlightMessageID, _pushBody) => { + navigateToThread: (_reason, highlightMessageID, _pushBody, threadSearchQuery) => { set(s => { - s.threadSearchInfo.visible = false // force loaded if we're an error if (s.id === T.Chat.pendingErrorConversationIDKey) { s.loaded = true @@ -2504,11 +2496,12 @@ const createSlice = } // we select the chat tab and change the params + const threadSearch = threadSearchQuery ? {query: threadSearchQuery} : undefined if (Common.isSplit) { - navToThread(conversationIDKey) + navToThread(conversationIDKey, {threadSearch}) // immediately switch stack to an inbox | thread stack } else if (reason === 'push' || reason === 'savedLastState') { - navToThread(conversationIDKey) + navToThread(conversationIDKey, {threadSearch}) return } else { // replace if looking at the pending / waiting screen @@ -2521,7 +2514,7 @@ const createSlice = clearModals() } - navigateAppend({name: Common.threadRouteName, params: {conversationIDKey}}, replace) + navigateAppend({name: Common.threadRouteName, params: {conversationIDKey, threadSearch}}, replace) } } updateNav() @@ -3193,11 +3186,6 @@ const createSlice = s.replyTo = o }) }, - setThreadSearchQuery: query => { - set(s => { - s.threadSearchQuery = query - }) - }, setTyping: throttle((t: Set<string>) => { set(s => { if (!isEqual(s.typing, t)) { @@ -3230,105 +3218,6 @@ const createSlice = get().dispatch.loadMoreMessages({reason: 'tab selected'}) get().dispatch.markThreadAsRead() }, - threadSearch: query => { - set(s => { - s.threadSearchInfo.hits = [] - }) - const f = async () => { - const conversationIDKey = get().id - const {username, devicename} = getCurrentUser() - const onDone = () => { - set(s => { - s.threadSearchInfo.status = 'done' - }) - } - try { - await T.RPCChat.localSearchInboxRpcListener({ - incomingCallMap: { - 'chat.1.chatUi.chatSearchDone': onDone, - 'chat.1.chatUi.chatSearchHit': hit => { - const message = Message.uiMessageToMessage( - conversationIDKey, - hit.searchHit.hitMessage, - username, - getLastOrdinal, - devicename - ) - - if (message) { - set(s => { - // Only add if not already present (idempotent - safe for out-of-order callbacks) - if (!s.threadSearchInfo.hits.find(h => h.id === message.id)) { - s.threadSearchInfo.hits.push(T.castDraft(message)) - } - }) - } - }, - 'chat.1.chatUi.chatSearchInboxDone': onDone, - 'chat.1.chatUi.chatSearchInboxHit': resp => { - const messages = (resp.searchHit.hits || []).reduce<Array<T.Chat.Message>>((l, h) => { - const uiMsg = Message.uiMessageToMessage( - conversationIDKey, - h.hitMessage, - username, - getLastOrdinal, - devicename - ) - if (uiMsg) { - l.push(uiMsg) - } - return l - }, []) - set(s => { - if (messages.length > 0) { - // entirely replace - s.threadSearchInfo.hits = T.castDraft(messages) - } - }) - }, - 'chat.1.chatUi.chatSearchInboxStart': () => { - set(s => { - s.threadSearchInfo.status = 'inprogress' - }) - }, - }, - params: { - identifyBehavior: T.RPCGen.TLFIdentifyBehavior.chatGui, - namesOnly: false, - opts: { - afterContext: 0, - beforeContext: 0, - convID: get().getConvID(), - isRegex: false, - matchMentions: false, - maxBots: 0, - maxConvsHit: 0, - maxConvsSearched: 0, - maxHits: 1000, - maxMessages: -1, - maxNameConvs: 0, - maxTeams: 0, - reindexMode: T.RPCChat.ReIndexingMode.postsearchSync, - sentAfter: 0, - sentBefore: 0, - sentBy: '', - sentTo: '', - skipBotCache: false, - }, - query, - }, - }) - } catch (error) { - if (error instanceof RPCError) { - logger.error('search failed: ' + error.message) - set(s => { - s.threadSearchInfo.status = 'done' - }) - } - } - } - ignorePromise(f()) - }, toggleGiphyPrefill: () => { // if the window is up, just blow it away get().dispatch.injectIntoInput(get().giphyWindow ? '' : '/giphy ') @@ -3392,26 +3281,30 @@ const createSlice = } ignorePromise(f()) }, - toggleThreadSearch: hide => { + toggleThreadSearch: (hide, query) => { + const conversationIDKey = get().id + const visible = getVisibleScreen() + const params = visible?.params as + | {conversationIDKey?: T.Chat.ConversationIDKey; threadSearch?: {query?: string}} + | undefined + const nextVisible = hide !== undefined ? !hide : !params?.threadSearch set(s => { - const {threadSearchInfo} = s - threadSearchInfo.hits = [] - threadSearchInfo.status = 'initial' - if (hide !== undefined) { - threadSearchInfo.visible = !hide - } else { - threadSearchInfo.visible = !threadSearchInfo.visible - } - - if (!threadSearchInfo.visible) { + if (!nextVisible) { s.messageCenterOrdinal = undefined } else if (s.messageCenterOrdinal) { s.messageCenterOrdinal.highlightMode = 'none' } }) + const threadSearch = nextVisible ? (query ? {query} : {}) : undefined + if (Common.isSplit) { + setChatRootParams({conversationIDKey, threadSearch}) + } else { + navigateAppend({name: Common.threadRouteName, params: {conversationIDKey, threadSearch}}, true) + } + const f = async () => { - if (!get().threadSearchInfo.visible) { + if (!nextVisible) { await T.RPCChat.localCancelActiveSearchRpcPromise() } } diff --git a/shared/stores/tests/convostate.test.ts b/shared/stores/tests/convostate.test.ts index 546c0a06e23c..125b4974630a 100644 --- a/shared/stores/tests/convostate.test.ts +++ b/shared/stores/tests/convostate.test.ts @@ -631,7 +631,7 @@ test('setMeta adopts the server draft once when the meta becomes good', () => { expect(store.getState().unsentText).toBe('local draft') }) -test('local setters update participants, reply target, search query, and badge', () => { +test('local setters update participants, reply target, and badge', () => { const store = createStore() const participants: ConvoState['participants'] = { all: ['alice', 'bob'], @@ -641,30 +641,20 @@ test('local setters update participants, reply target, search query, and badge', store.getState().dispatch.setParticipants(participants) store.getState().dispatch.setReplyTo(ordinal) - store.getState().dispatch.setThreadSearchQuery('hello world') store.getState().dispatch.badgesUpdated(3) expect(store.getState().participants).toEqual(participants) expect(store.getState().replyTo).toBe(ordinal) - expect(store.getState().threadSearchQuery).toBe('hello world') expect(store.getState().badge).toBe(3) }) -test('toggleThreadSearch resets hits and removes center highlight when opening search', () => { +test('toggleThreadSearch removes center highlight when opening search', () => { const store = createStore() applyState(store, { messageCenterOrdinal: {highlightMode: 'always', ordinal}, - threadSearchInfo: { - hits: [makeTextMessage()], - status: 'done', - visible: false, - }, }) store.getState().dispatch.toggleThreadSearch() - expect(store.getState().threadSearchInfo.visible).toBe(true) - expect(store.getState().threadSearchInfo.hits).toEqual([]) - expect(store.getState().threadSearchInfo.status).toBe('initial') expect(store.getState().messageCenterOrdinal?.highlightMode).toBe('none') }) From 0e4cf0db2367b2ded238924313c72730470bf947 Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Thu, 9 Apr 2026 08:21:44 -0400 Subject: [PATCH 45/55] WIP --- shared/chat/conversation/thread-search-route.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 shared/chat/conversation/thread-search-route.ts diff --git a/shared/chat/conversation/thread-search-route.ts b/shared/chat/conversation/thread-search-route.ts new file mode 100644 index 000000000000..da3d5fea68e2 --- /dev/null +++ b/shared/chat/conversation/thread-search-route.ts @@ -0,0 +1,15 @@ +import type {RootRouteProps} from '@/router-v2/route-params' +import {useRoute} from '@react-navigation/native' + +export type ThreadSearchRoute = { + query?: string +} + +export type ThreadSearchRouteProps = { + threadSearch?: ThreadSearchRoute +} + +export const useThreadSearchRoute = () => { + const route = useRoute<RootRouteProps<'chatConversation'> | RootRouteProps<'chatRoot'>>() + return route.params?.threadSearch +} From 697e33e0e1fc890efc4491f974f47d152be3a435 Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Thu, 9 Apr 2026 08:22:41 -0400 Subject: [PATCH 46/55] WIP --- shared/chat/conversation/search.tsx | 6 +++--- shared/chat/conversation/thread-search-route.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/shared/chat/conversation/search.tsx b/shared/chat/conversation/search.tsx index 74172fe103cd..59c1991d9fbd 100644 --- a/shared/chat/conversation/search.tsx +++ b/shared/chat/conversation/search.tsx @@ -57,7 +57,7 @@ const useCommon = (ownProps: OwnProps) => { return } - const {devicename, username} = useCurrentUserState.getState() + const {deviceName, username} = useCurrentUserState.getState() const getLastOrdinal = () => Chat.getConvoState(conversationIDKey).messageOrdinals?.at(-1) ?? T.Chat.numberToOrdinal(0) const updateIfCurrent = (updater: (state: SearchState) => SearchState) => { @@ -81,7 +81,7 @@ const useCommon = (ownProps: OwnProps) => { hit.searchHit.hitMessage, username, getLastOrdinal, - devicename + deviceName ) if (!message) { return @@ -100,7 +100,7 @@ const useCommon = (ownProps: OwnProps) => { hit.hitMessage, username, getLastOrdinal, - devicename + deviceName ) if (message) { result.push(message) diff --git a/shared/chat/conversation/thread-search-route.ts b/shared/chat/conversation/thread-search-route.ts index da3d5fea68e2..b0a70a4d8cf3 100644 --- a/shared/chat/conversation/thread-search-route.ts +++ b/shared/chat/conversation/thread-search-route.ts @@ -11,5 +11,5 @@ export type ThreadSearchRouteProps = { export const useThreadSearchRoute = () => { const route = useRoute<RootRouteProps<'chatConversation'> | RootRouteProps<'chatRoot'>>() - return route.params?.threadSearch + return route.params.threadSearch } From f4be1fa1f7f8d5478990e656fd465a726a6ec2aa Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Thu, 9 Apr 2026 08:28:35 -0400 Subject: [PATCH 47/55] WIP --- shared/chat/conversation/search.tsx | 65 +++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/shared/chat/conversation/search.tsx b/shared/chat/conversation/search.tsx index 59c1991d9fbd..b49aff433aab 100644 --- a/shared/chat/conversation/search.tsx +++ b/shared/chat/conversation/search.tsx @@ -45,13 +45,26 @@ const useCommon = (ownProps: OwnProps) => { const searchOrdinalRef = React.useRef(0) const hitsRef = React.useRef(messageHits) + const flushTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined) + const pendingHitsRef = React.useRef<Array<T.Chat.Message>>([]) + const pendingReplaceHitsRef = React.useRef<Array<T.Chat.Message> | undefined>(undefined) React.useEffect(() => { hitsRef.current = messageHits }, [messageHits]) + const clearPendingFlush = React.useEffectEvent(() => { + if (flushTimeoutRef.current) { + clearTimeout(flushTimeoutRef.current) + flushTimeoutRef.current = undefined + } + pendingHitsRef.current = [] + pendingReplaceHitsRef.current = undefined + }) + const runThreadSearch = React.useEffectEvent((query: string) => { const requestOrdinal = searchOrdinalRef.current + 1 searchOrdinalRef.current = requestOrdinal + clearPendingFlush() setSearchState({hits: [], status: query ? 'inprogress' : 'done'}) if (!query) { return @@ -66,8 +79,45 @@ const useCommon = (ownProps: OwnProps) => { } setSearchState(state => (searchOrdinalRef.current === requestOrdinal ? updater(state) : state)) } + const flushPendingHits = (statusOverride?: SearchState['status']) => { + if (flushTimeoutRef.current) { + clearTimeout(flushTimeoutRef.current) + flushTimeoutRef.current = undefined + } + const pendingReplaceHits = pendingReplaceHitsRef.current + const pendingHits = pendingHitsRef.current + pendingReplaceHitsRef.current = undefined + pendingHitsRef.current = [] + if (!pendingReplaceHits && !pendingHits.length && statusOverride === undefined) { + return + } + updateIfCurrent(state => { + let nextHits = state.hits + if (pendingReplaceHits) { + nextHits = pendingReplaceHits + } else if (pendingHits.length) { + const seen = new Set(nextHits.map(hit => hit.id)) + nextHits = [...nextHits] + pendingHits.forEach(hit => { + if (!seen.has(hit.id)) { + seen.add(hit.id) + nextHits.push(hit) + } + }) + } + return {hits: nextHits, status: statusOverride ?? state.status} + }) + } + const scheduleFlush = () => { + if (flushTimeoutRef.current) { + return + } + flushTimeoutRef.current = setTimeout(() => { + flushPendingHits() + }, 16) + } const onDone = () => { - updateIfCurrent(state => ({...state, status: 'done'})) + flushPendingHits('done') } const f = async () => { @@ -86,11 +136,8 @@ const useCommon = (ownProps: OwnProps) => { if (!message) { return } - updateIfCurrent(state => - state.hits.find(existing => existing.id === message.id) - ? state - : {...state, hits: [...state.hits, message]} - ) + pendingHitsRef.current.push(message) + scheduleFlush() }, 'chat.1.chatUi.chatSearchInboxDone': onDone, 'chat.1.chatUi.chatSearchInboxHit': resp => { @@ -107,7 +154,9 @@ const useCommon = (ownProps: OwnProps) => { } return result }, []) - updateIfCurrent(state => ({...state, hits: messages})) + pendingHitsRef.current = [] + pendingReplaceHitsRef.current = messages + scheduleFlush() }, 'chat.1.chatUi.chatSearchInboxStart': () => { updateIfCurrent(state => ({...state, status: 'inprogress'})) @@ -201,6 +250,7 @@ const useCommon = (ownProps: OwnProps) => { React.useEffect(() => { searchOrdinalRef.current += 1 + clearPendingFlush() setSearchState({hits: [], status: 'initial'}) setLastSearch('') setSelectedIndex(0) @@ -220,6 +270,7 @@ const useCommon = (ownProps: OwnProps) => { React.useEffect(() => { return () => { searchOrdinalRef.current += 1 + clearPendingFlush() C.ignorePromise( T.RPCChat.localCancelActiveSearchRpcPromise().catch(() => {}) ) From 7ed5ad9401b97babb97d7544d3a8d545a6975229 Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Thu, 9 Apr 2026 09:00:09 -0400 Subject: [PATCH 48/55] WIP --- PLAN.md | 9 +- shared/chat/audio/audio-recorder.native.tsx | 2 +- shared/chat/conversation/command-status.tsx | 4 +- shared/chat/conversation/giphy/hooks.tsx | 2 +- .../input-area/location-popup.native.tsx | 4 +- .../conversation/input-area/normal/index.tsx | 37 ++- .../input-area/normal/input.desktop.tsx | 6 +- .../normal/moremenu-popup.native.tsx | 2 +- .../conversation/input-area/normal/typing.tsx | 2 +- .../input-area/suggestors/commands.tsx | 2 +- .../conversation/list-area/index.desktop.tsx | 8 +- .../chat/conversation/messages/emoji-row.tsx | 8 +- .../messages/message-popup/hooks.tsx | 9 +- .../wrapper/long-pressable/index.native.tsx | 5 +- .../conversation/messages/wrapper/wrapper.tsx | 33 ++- shared/chat/conversation/reply-preview.tsx | 4 +- shared/chat/send-to-chat/index.tsx | 2 +- shared/stores/convostate.tsx | 276 ++++++++++-------- 18 files changed, 242 insertions(+), 173 deletions(-) diff --git a/PLAN.md b/PLAN.md index 903e82e4b651..520d728bdc54 100644 --- a/PLAN.md +++ b/PLAN.md @@ -16,6 +16,7 @@ Reduce chat conversation mount cost, cut per-row Zustand subscription fan-out, a - Do not mix store-shape changes and row rendering changes in the same patch unless one directly unblocks the other. - Keep desktop and native paths aligned unless there is a platform-specific reason not to. - Treat each workstream as independently landable where possible. +- Do not preserve proxy dispatch APIs solely to avoid touching callers when state ownership changes; migrate callers to the new owner in the same workstream. - When a checklist item is implemented, update this plan in the same change and mark that item done. ## Workstreams @@ -71,11 +72,11 @@ Primary files: ### 4. Split Volatile UI State From Message Data -- [ ] Inventory convo-store fields that are transient UI state rather than message graph state. +- [x] Inventory convo-store fields that are transient UI state rather than message graph state. - [x] Move thread-search visibility and search request/results state out of `convostate` into route params plus screen-local UI state. -- [ ] Move route-local or composer-local state out of the main convo message store. -- [ ] Keep dispatch call sites readable and avoid direct component store mutation. -- [ ] Minimize unrelated selector recalculation when typing/search/composer state changes. +- [x] Move route-local or composer-local state out of the main convo message store. +- [x] Keep dispatch call sites readable and avoid direct component store mutation. +- [x] Minimize unrelated selector recalculation when typing/search/composer state changes. Primary files: diff --git a/shared/chat/audio/audio-recorder.native.tsx b/shared/chat/audio/audio-recorder.native.tsx index 76f5d2021128..726ee55ccec9 100644 --- a/shared/chat/audio/audio-recorder.native.tsx +++ b/shared/chat/audio/audio-recorder.native.tsx @@ -364,7 +364,7 @@ const useRecorder = (p: {ampSV: SVN; setShowAudioSend: (s: boolean) => void; sho setStaged(false) setShowAudioSend(false) } - const setCommandStatusInfo = Chat.useChatContext(s => s.dispatch.setCommandStatusInfo) + const setCommandStatusInfo = Chat.useChatUIContext(s => s.dispatch.setCommandStatusInfo) const startRecording = () => { const checkPerms = async () => { diff --git a/shared/chat/conversation/command-status.tsx b/shared/chat/conversation/command-status.tsx index 50b9f2c61ccb..83f56446412d 100644 --- a/shared/chat/conversation/command-status.tsx +++ b/shared/chat/conversation/command-status.tsx @@ -10,11 +10,11 @@ const empty = { } const Container = () => { - const info = Chat.useChatContext(s => s.commandStatus) + const info = Chat.useChatUIContext(s => s.commandStatus) const _info = info || empty const onOpenAppSettings = useConfigState(s => s.dispatch.defer.openAppSettings) - const setCommandStatusInfo = Chat.useChatContext(s => s.dispatch.setCommandStatusInfo) + const setCommandStatusInfo = Chat.useChatUIContext(s => s.dispatch.setCommandStatusInfo) const onCancel = () => { setCommandStatusInfo() } diff --git a/shared/chat/conversation/giphy/hooks.tsx b/shared/chat/conversation/giphy/hooks.tsx index 2b3389b66fe8..128cab51bb5e 100644 --- a/shared/chat/conversation/giphy/hooks.tsx +++ b/shared/chat/conversation/giphy/hooks.tsx @@ -1,7 +1,7 @@ import * as Chat from '@/stores/chat' export const useHooks = () => { - const giphy = Chat.useChatContext(s => s.giphyResult) + const giphy = Chat.useChatUIContext(s => s.giphyResult) const onClick = Chat.useChatContext(s => s.dispatch.giphySend) return { galleryURL: giphy?.galleryUrl ?? '', diff --git a/shared/chat/conversation/input-area/location-popup.native.tsx b/shared/chat/conversation/input-area/location-popup.native.tsx index afe3c8ae9439..b6d6273a1adc 100644 --- a/shared/chat/conversation/input-area/location-popup.native.tsx +++ b/shared/chat/conversation/input-area/location-popup.native.tsx @@ -28,7 +28,7 @@ const LocationButton = (props: {disabled: boolean; label: string; onClick: () => const useWatchPosition = (conversationIDKey: T.Chat.ConversationIDKey) => { const updateLastCoord = Chat.useChatState(s => s.dispatch.updateLastCoord) - const setCommandStatusInfo = Chat.useChatContext(s => s.dispatch.setCommandStatusInfo) + const setCommandStatusInfo = Chat.useChatUIContext(s => s.dispatch.setCommandStatusInfo) React.useEffect(() => { let unsub = () => {} logger.info('[location] perms check due to map') @@ -70,7 +70,7 @@ const LocationPopup = () => { const username = useCurrentUserState(s => s.username) const httpSrv = useConfigState(s => s.httpSrv) const location = Chat.useChatState(s => s.lastCoord) - const locationDenied = Chat.useChatContext( + const locationDenied = Chat.useChatUIContext( s => s.commandStatus?.displayType === T.RPCChat.UICommandStatusDisplayTyp.error ) const [mapLoaded, setMapLoaded] = React.useState(false) diff --git a/shared/chat/conversation/input-area/normal/index.tsx b/shared/chat/conversation/input-area/normal/index.tsx index 29ce889c81a8..3c17dbc095e4 100644 --- a/shared/chat/conversation/input-area/normal/index.tsx +++ b/shared/chat/conversation/input-area/normal/index.tsx @@ -69,10 +69,11 @@ const useHintText = (p: { } const Input = function Input() { - const showGiphySearch = Chat.useChatContext(s => s.giphyWindow) + const showGiphySearch = Chat.useChatUIContext(s => s.giphyWindow) const showCommandMarkdown = Chat.useChatContext(s => !!s.commandMarkdown) - const showCommandStatus = Chat.useChatContext(s => !!s.commandStatus) - const showReplyTo = Chat.useChatContext(s => !!s.messageMap.get(s.replyTo)?.id) + const showCommandStatus = Chat.useChatUIContext(s => !!s.commandStatus) + const replyTo = Chat.useChatUIContext(s => s.replyTo) + const showReplyTo = Chat.useChatContext(s => !!s.messageMap.get(replyTo)?.id) return ( <Kb.Box2 style={styles.container} direction="vertical" fullWidth={true}> {showReplyTo && <ReplyPreview />} @@ -112,15 +113,20 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() { const route = useRoute<RootRouteProps<'chatConversation'> | RootRouteProps<'chatRoot'>>() const infoPanelShowing = route.name === 'chatRoot' && 'infoPanel' in route.params ? !!route.params.infoPanel : false + const uiData = Chat.useChatUIContext( + C.useShallow(s => ({ + editOrdinal: s.editing, + replyTo: s.replyTo, + unsentText: s.unsentText, + })) + ) const data = Chat.useChatContext( C.useShallow(s => { - const {meta, id: conversationIDKey, editing: editOrdinal, messageMap, unsentText} = s - const {sendMessage, setEditing, jumpToRecent, setExplodingMode} = s.dispatch - const {injectIntoInput: updateUnsentText} = s.dispatch + const {meta, id: conversationIDKey, messageMap} = s + const {sendMessage, jumpToRecent, setExplodingMode} = s.dispatch const {cannotWrite, minWriterRole, tlfname} = meta - const showReplyPreview = !!messageMap.get(s.replyTo)?.id + const showReplyPreview = !!messageMap.get(uiData.replyTo)?.id const suggestBotCommandsUpdateStatus = s.botCommandsUpdateStatus - const isEditing = !!editOrdinal const convoID = s.getConvID() const metaGood = s.isMetaGood() const storeDraft = metaGood ? meta.draft : undefined @@ -132,16 +138,19 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() { : explodingMode // prettier-ignore return {cannotWrite, conversationIDKey, convoID, explodingMode, explodingModeSeconds, - isEditing, jumpToRecent, minWriterRole, sendMessage, setEditing, setExplodingMode, - showReplyPreview, storeDraft, suggestBotCommandsUpdateStatus, tlfname, unsentText, - updateUnsentText} + jumpToRecent, minWriterRole, sendMessage, setExplodingMode, showReplyPreview, + storeDraft, suggestBotCommandsUpdateStatus, tlfname} }) ) const {cannotWrite, conversationIDKey, setExplodingMode: setExplodingModeRaw} = data - const {isEditing, jumpToRecent, minWriterRole, sendMessage} = data - const {explodingModeSeconds: explodingModeSecondsRaw, setEditing, convoID, tlfname, storeDraft} = data - const {suggestBotCommandsUpdateStatus, unsentText, showReplyPreview, updateUnsentText} = data + const {jumpToRecent, minWriterRole, sendMessage} = data + const {explodingModeSeconds: explodingModeSecondsRaw, convoID, tlfname, storeDraft} = data + const {suggestBotCommandsUpdateStatus, showReplyPreview} = data + const {editOrdinal, unsentText} = uiData + const isEditing = !!editOrdinal + const setEditing = Chat.useChatUIContext(s => s.dispatch.setEditing) + const updateUnsentText = Chat.useChatUIContext(s => s.dispatch.injectIntoInput) const [explodingModeSeconds, setExplodingModeSeconds] = React.useState(explodingModeSecondsRaw) const isExploding = explodingModeSeconds !== 0 diff --git a/shared/chat/conversation/input-area/normal/input.desktop.tsx b/shared/chat/conversation/input-area/normal/input.desktop.tsx index 86f07404d177..79fa47cea087 100644 --- a/shared/chat/conversation/input-area/normal/input.desktop.tsx +++ b/shared/chat/conversation/input-area/normal/input.desktop.tsx @@ -312,7 +312,7 @@ const EmojiButton = function EmojiButton(p: EmojiButtonProps) { } const GiphyButton = function GiphyButton() { - const toggleGiphyPrefill = Chat.useChatContext(s => s.dispatch.toggleGiphyPrefill) + const toggleGiphyPrefill = Chat.useChatUIContext(s => s.dispatch.toggleGiphyPrefill) const onGiphyToggle = toggleGiphyPrefill return ( @@ -389,7 +389,7 @@ const useKeyboard = (p: UseKeyboardProps) => { const {htmlInputRef, focusInput, isEditing, onKeyDown, onCancelEditing} = p const {onChangeText, onEditLastMessage, showReplyPreview} = p const lastText = React.useRef('') - const setReplyTo = Chat.useChatContext(s => s.dispatch.setReplyTo) + const setReplyTo = Chat.useChatUIContext(s => s.dispatch.setReplyTo) const {scrollDown, scrollUp} = React.useContext(ScrollContext) const onCancelReply = () => { setReplyTo(T.Chat.numberToOrdinal(0)) @@ -518,7 +518,7 @@ const PlatformInput = function PlatformInput(p: Props) { const focusInput = () => { inputRef.current?.focus() } - const setEditing = Chat.useChatContext(s => s.dispatch.setEditing) + const setEditing = Chat.useChatUIContext(s => s.dispatch.setEditing) const onEditLastMessage = () => { setEditing('last') } diff --git a/shared/chat/conversation/input-area/normal/moremenu-popup.native.tsx b/shared/chat/conversation/input-area/normal/moremenu-popup.native.tsx index 11a68ef3af76..5081785b30b5 100644 --- a/shared/chat/conversation/input-area/normal/moremenu-popup.native.tsx +++ b/shared/chat/conversation/input-area/normal/moremenu-popup.native.tsx @@ -8,7 +8,7 @@ type Props = { const MoreMenuPopup = (props: Props) => { const {onHidden, visible} = props - const injectIntoInput = Chat.useChatContext(s => s.dispatch.injectIntoInput) + const injectIntoInput = Chat.useChatUIContext(s => s.dispatch.injectIntoInput) const navigateAppend = Chat.useChatNavigateAppend() const onLocationShare = () => { navigateAppend(conversationIDKey => ({name: 'chatLocationPreview', params: {conversationIDKey}})) diff --git a/shared/chat/conversation/input-area/normal/typing.tsx b/shared/chat/conversation/input-area/normal/typing.tsx index 7afa301260b1..d1031d8ee82d 100644 --- a/shared/chat/conversation/input-area/normal/typing.tsx +++ b/shared/chat/conversation/input-area/normal/typing.tsx @@ -44,12 +44,12 @@ const Names = (props: {names?: ReadonlySet<string>}) => { const emptySet = new Set<string>() const Typing = function Typing() { + const showGiphySearch = Chat.useChatUIContext(s => s.giphyWindow) const names = Chat.useChatContext( C.useShallow(s => { const names = s.typing if (!C.isMobile) return names const showCommandMarkdown = !!s.commandMarkdown - const showGiphySearch = s.giphyWindow const showTypingStatus = !showGiphySearch && !showCommandMarkdown return showTypingStatus ? names : emptySet }) diff --git a/shared/chat/conversation/input-area/suggestors/commands.tsx b/shared/chat/conversation/input-area/suggestors/commands.tsx index de22029457d3..7b583f0ef240 100644 --- a/shared/chat/conversation/input-area/suggestors/commands.tsx +++ b/shared/chat/conversation/input-area/suggestors/commands.tsx @@ -106,7 +106,7 @@ type UseDataSourceProps = { const useDataSource = (p: UseDataSourceProps) => { const {filter, inputRef, lastTextRef} = p const staticConfig = Chat.useChatState(s => s.staticConfig) - const showGiphySearch = Chat.useChatContext(s => s.giphyWindow) + const showGiphySearch = Chat.useChatUIContext(s => s.giphyWindow) const showCommandMarkdown = Chat.useChatContext(s => !!s.commandMarkdown) return Chat.useChatContext( C.useShallow(s => { diff --git a/shared/chat/conversation/list-area/index.desktop.tsx b/shared/chat/conversation/list-area/index.desktop.tsx index 0f376a7146ad..ed591fb61c97 100644 --- a/shared/chat/conversation/list-area/index.desktop.tsx +++ b/shared/chat/conversation/list-area/index.desktop.tsx @@ -338,7 +338,7 @@ const useScrolling = (p: { }, [scrollDown, scrollToBottom, scrollUp, setScrollRef]) // go to editing message - const editingOrdinal = Chat.useChatContext(s => s.editing) + const editingOrdinal = Chat.useChatUIContext(s => s.editing) const lastEditingOrdinalRef = React.useRef(0) React.useEffect(() => { if (lastEditingOrdinalRef.current !== editingOrdinal) return @@ -475,9 +475,10 @@ const useItems = (p: { const noOrdinals = new Array<T.Chat.Ordinal>() const ThreadWrapper = function ThreadWrapper() { + const editingOrdinal = Chat.useChatUIContext(s => s.editing) const data = Chat.useChatContext( C.useShallow(s => { - const {editing: editingOrdinal, id: conversationIDKey} = s + const {id: conversationIDKey} = s const {messageCenterOrdinal: mco, messageOrdinals = noOrdinals, loaded} = s const centeredHighlightOrdinal = mco && mco.highlightMode !== 'none' ? mco.ordinal : undefined const centeredOrdinal = mco?.ordinal @@ -487,13 +488,12 @@ const ThreadWrapper = function ThreadWrapper() { centeredOrdinal, containsLatestMessage, conversationIDKey, - editingOrdinal, loaded, messageOrdinals, } }) ) - const {conversationIDKey, editingOrdinal, centeredHighlightOrdinal, centeredOrdinal} = data + const {conversationIDKey, centeredHighlightOrdinal, centeredOrdinal} = data const {containsLatestMessage, messageOrdinals, loaded} = data const copyToClipboard = useConfigState(s => s.dispatch.defer.copyToClipboard) const listRef = React.useRef<HTMLDivElement | null>(null) diff --git a/shared/chat/conversation/messages/emoji-row.tsx b/shared/chat/conversation/messages/emoji-row.tsx index 3ebb10f9f3cc..85fe9b007486 100644 --- a/shared/chat/conversation/messages/emoji-row.tsx +++ b/shared/chat/conversation/messages/emoji-row.tsx @@ -30,12 +30,8 @@ const useTopReacjis = () => function EmojiRowContainer(p: OwnProps) { const {className, hasUnfurls, messageType, onReact: onReactProp, onReply: onReplyProp, onShowingEmojiPicker, style} = p const ordinal = useOrdinal() - const {setReplyTo, toggleMessageReaction} = Chat.useChatContext( - C.useShallow(s => ({ - setReplyTo: s.dispatch.setReplyTo, - toggleMessageReaction: s.dispatch.toggleMessageReaction, - })) - ) + const setReplyTo = Chat.useChatUIContext(s => s.dispatch.setReplyTo) + const toggleMessageReaction = Chat.useChatContext(s => s.dispatch.toggleMessageReaction) const emojis = useTopReacjis() const navigateAppend = Chat.useChatNavigateAppend() const _onForward = () => { diff --git a/shared/chat/conversation/messages/message-popup/hooks.tsx b/shared/chat/conversation/messages/message-popup/hooks.tsx index ea30a132419c..1fef4cca632e 100644 --- a/shared/chat/conversation/messages/message-popup/hooks.tsx +++ b/shared/chat/conversation/messages/message-popup/hooks.tsx @@ -108,12 +108,15 @@ export const useItems = (ordinal: T.Chat.Ordinal, onHidden: () => void) => { {icon: 'iconfont-link', onClick: onCopyLink, title: 'Copy a link to this message'}, ] as const - const {messageDelete, pinMessage, setEditing, setMarkAsUnread, setReplyTo} = Chat.useChatContext( + const {messageDelete, pinMessage, setMarkAsUnread} = Chat.useChatContext( C.useShallow(s => { - const {messageDelete, pinMessage, setEditing, setMarkAsUnread, setReplyTo} = s.dispatch - return {messageDelete, pinMessage, setEditing, setMarkAsUnread, setReplyTo} + const {messageDelete, pinMessage, setMarkAsUnread} = s.dispatch + return {messageDelete, pinMessage, setMarkAsUnread} }) ) + const {setEditing, setReplyTo} = Chat.useChatUIContext( + C.useShallow(s => ({setEditing: s.dispatch.setEditing, setReplyTo: s.dispatch.setReplyTo})) + ) const onReply = () => { setReplyTo(ordinal) diff --git a/shared/chat/conversation/messages/wrapper/long-pressable/index.native.tsx b/shared/chat/conversation/messages/wrapper/long-pressable/index.native.tsx index 5eb53f07a084..5bafbd632e23 100644 --- a/shared/chat/conversation/messages/wrapper/long-pressable/index.native.tsx +++ b/shared/chat/conversation/messages/wrapper/long-pressable/index.native.tsx @@ -40,9 +40,8 @@ function LongPressable(props: Props) { <ReplyIcon progress={translation} /> ) - const {toggleThreadSearch, setReplyTo} = Chat.useChatContext( - C.useShallow(s => ({setReplyTo: s.dispatch.setReplyTo, toggleThreadSearch: s.dispatch.toggleThreadSearch})) - ) + const toggleThreadSearch = Chat.useChatContext(s => s.dispatch.toggleThreadSearch) + const setReplyTo = Chat.useChatUIContext(s => s.dispatch.setReplyTo) const ordinal = useOrdinal() const {focusInput} = React.useContext(FocusContext) const onSwipeLeft = () => { diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index 09f42ab197b2..ca08b7e65aa5 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -18,7 +18,7 @@ import {useTeamsState} from '@/stores/teams' import {useTrackerState} from '@/stores/tracker' import {navToProfile} from '@/constants/router' import {formatTimeForChat} from '@/util/timestamp' -import type {ConvoState} from '@/stores/convostate' +import type {ConvoState, ConvoUIState} from '@/stores/convostate' export type Props = { isCenteredHighlight?: boolean @@ -64,8 +64,9 @@ type AuthorProps = { type RowActions = Pick< ConvoState['dispatch'], - 'messageDelete' | 'messageRetry' | 'replyJump' | 'setEditing' | 'setReplyTo' | 'toggleMessageReaction' -> + 'messageDelete' | 'messageRetry' | 'replyJump' | 'toggleMessageReaction' +> & + Pick<ConvoUIState['dispatch'], 'setEditing' | 'setReplyTo'> type EditCancelRetryData = { failureDescription: string @@ -94,8 +95,12 @@ const emptyAuthorData: FlatAuthorData = { timestamp: 0, } -const getRowActions = (dispatch: ConvoState['dispatch']): RowActions => { - const {messageDelete, messageRetry, replyJump, setEditing, setReplyTo, toggleMessageReaction} = dispatch +const getRowActions = ( + dispatch: ConvoState['dispatch'], + uiDispatch: ConvoUIState['dispatch'] +): RowActions => { + const {messageDelete, messageRetry, replyJump, toggleMessageReaction} = dispatch + const {setEditing, setReplyTo} = uiDispatch return {messageDelete, messageRetry, replyJump, setEditing, setReplyTo, toggleMessageReaction} } @@ -250,7 +255,7 @@ const getCommonMessageData = ({ you, }: { accountsInfoMap: ConvoState['accountsInfoMap'] - editing: ConvoState['editing'] + editing: T.Chat.Ordinal isCenteredHighlight?: boolean message: T.Chat.Message messageCenterOrdinal: ConvoState['messageCenterOrdinal'] @@ -355,13 +360,17 @@ const getEditCancelRetryData = ( // Combined selector hook that fetches all common wrapper data in a single subscription. export const useMessageData = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: boolean) => { const you = useCurrentUserState(s => s.username) + const editing = Chat.useChatUIContext(s => s.editing) + const uiDispatch = Chat.useChatUIContext( + C.useShallow(s => ({setEditing: s.dispatch.setEditing, setReplyTo: s.dispatch.setReplyTo})) + ) return Chat.useChatContext( C.useShallow(s => { const message = s.messageMap.get(ordinal) ?? missingMessage const commonData = getCommonMessageData({ accountsInfoMap: s.accountsInfoMap, - editing: s.editing, + editing, isCenteredHighlight, message, messageCenterOrdinal: s.messageCenterOrdinal, @@ -373,7 +382,7 @@ export const useMessageData = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: bo return { ...commonData, ...getEditCancelRetryData(commonData.ecrType, message), - ...getRowActions(s.dispatch), + ...getRowActions(s.dispatch, uiDispatch), ...getAuthorData(message, s.meta, s.participants, s.showUsernameMap.get(ordinal) ?? ''), } }) @@ -382,13 +391,17 @@ export const useMessageData = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: bo const useMessageDataWithMessage = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: boolean) => { const you = useCurrentUserState(s => s.username) + const editing = Chat.useChatUIContext(s => s.editing) + const uiDispatch = Chat.useChatUIContext( + C.useShallow(s => ({setEditing: s.dispatch.setEditing, setReplyTo: s.dispatch.setReplyTo})) + ) return Chat.useChatContext( C.useShallow(s => { const message = s.messageMap.get(ordinal) ?? missingMessage const commonData = getCommonMessageData({ accountsInfoMap: s.accountsInfoMap, - editing: s.editing, + editing, isCenteredHighlight, message, messageCenterOrdinal: s.messageCenterOrdinal, @@ -400,7 +413,7 @@ const useMessageDataWithMessage = (ordinal: T.Chat.Ordinal, isCenteredHighlight? return { ...commonData, ...getEditCancelRetryData(commonData.ecrType, message), - ...getRowActions(s.dispatch), + ...getRowActions(s.dispatch, uiDispatch), ...getAuthorData(message, s.meta, s.participants, s.showUsernameMap.get(ordinal) ?? ''), message, } diff --git a/shared/chat/conversation/reply-preview.tsx b/shared/chat/conversation/reply-preview.tsx index 5a177afba7a3..6dad6fdb7169 100644 --- a/shared/chat/conversation/reply-preview.tsx +++ b/shared/chat/conversation/reply-preview.tsx @@ -3,7 +3,7 @@ import * as Kb from '@/common-adapters' import * as T from '@/constants/types' const ReplyPreview = () => { - const rordinal = Chat.useChatContext(s => s.replyTo) + const rordinal = Chat.useChatUIContext(s => s.replyTo) const message = Chat.useChatContext(s => { return rordinal ? s.messageMap.get(rordinal) : null }) @@ -30,7 +30,7 @@ const ReplyPreview = () => { const imageWidth = attachment?.previewWidth const username = message?.author ?? '' const sizing = imageWidth && imageHeight ? Chat.zoomImage(imageWidth, imageHeight, 80) : null - const setReplyTo = Chat.useChatContext(s => s.dispatch.setReplyTo) + const setReplyTo = Chat.useChatUIContext(s => s.dispatch.setReplyTo) const onCancel = () => { setReplyTo(T.Chat.numberToOrdinal(0)) } diff --git a/shared/chat/send-to-chat/index.tsx b/shared/chat/send-to-chat/index.tsx index fae63e6dd300..6d7a1eb2499d 100644 --- a/shared/chat/send-to-chat/index.tsx +++ b/shared/chat/send-to-chat/index.tsx @@ -36,7 +36,7 @@ export const MobileSendToChat = (props: Props) => { const fileContext = useFSState(s => s.fileContext) const onSelect = (conversationIDKey: T.Chat.ConversationIDKey, tlfName: string) => { const {dispatch} = Chat.getConvoState(conversationIDKey) - text && dispatch.injectIntoInput(text) + text && Chat.getConvoUIState(conversationIDKey).dispatch.injectIntoInput(text) if (sendPaths?.length) { navigateAppend({ name: 'chatAttachmentGetTitles', diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 5f1239524304..5dfd0e853ef8 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -110,12 +110,8 @@ type ConvoStore = T.Immutable<{ botSettings: Map<string, T.RPCGen.TeamBotSettings | undefined> botTeamRoleMap: Map<string, T.Teams.TeamRoleType | undefined> commandMarkdown?: T.RPCChat.UICommandMarkdown - commandStatus?: T.Chat.CommandStatusInfo dismissedInviteBanners: boolean - editing: T.Chat.Ordinal // current message being edited, explodingMode: number // seconds to exploding message expiration, - giphyResult?: T.RPCChat.GiphySearchResults - giphyWindow: boolean loaded: boolean // did we ever load this thread yet markedAsUnread: T.Chat.Ordinal messageCenterOrdinal?: T.Chat.CenterOrdinal // ordinals to center threads on, @@ -130,7 +126,6 @@ type ConvoStore = T.Immutable<{ participants: T.Chat.ParticipantInfo pendingJumpMessageID?: T.Chat.MessageID pendingOutboxToOrdinal: Map<T.Chat.OutboxID, T.Chat.Ordinal> // messages waiting to be sent, - replyTo: T.Chat.Ordinal rowRecycleTypeMap: Map<T.Chat.Ordinal, string> separatorMap: Map<T.Chat.Ordinal, T.Chat.Ordinal> showUsernameMap: Map<T.Chat.Ordinal, string> @@ -138,10 +133,31 @@ type ConvoStore = T.Immutable<{ typing: ReadonlySet<string> unfurlPrompt: Map<T.Chat.MessageID, Set<string>> unread: number - unsentText?: string validatedOrdinalRange?: {from: T.Chat.Ordinal; to: T.Chat.Ordinal} }> +export type ConvoUIStore = T.Immutable<{ + commandStatus?: T.Chat.CommandStatusInfo + editing: T.Chat.Ordinal + giphyResult?: T.RPCChat.GiphySearchResults + giphyWindow: boolean + replyTo: T.Chat.Ordinal + unsentText?: string +}> + +export interface ConvoUIState extends ConvoUIStore { + dispatch: { + injectIntoInput: (text?: string) => void + resetState: () => void + setCommandStatusInfo: (info?: T.Chat.CommandStatusInfo) => void + setEditing: (ordinal: T.Chat.Ordinal | 'last' | 'clear') => void + setGiphyResult: (result?: T.RPCChat.GiphySearchResults) => void + setGiphyWindow: (show: boolean) => void + setReplyTo: (ordinal: T.Chat.Ordinal) => void + toggleGiphyPrefill: () => void + } +} + const initialConvoStore: ConvoStore = { accountsInfoMap: new Map(), attachmentViewMap: new Map(), @@ -150,12 +166,8 @@ const initialConvoStore: ConvoStore = { botSettings: new Map(), botTeamRoleMap: new Map(), commandMarkdown: undefined, - commandStatus: undefined, dismissedInviteBanners: false, - editing: T.Chat.numberToOrdinal(0), explodingMode: 0, - giphyResult: undefined, - giphyWindow: false, id: noConversationIDKey, loaded: false, markedAsUnread: T.Chat.numberToOrdinal(0), @@ -171,7 +183,6 @@ const initialConvoStore: ConvoStore = { participants: noParticipantInfo, pendingJumpMessageID: undefined, pendingOutboxToOrdinal: new Map(), - replyTo: T.Chat.numberToOrdinal(0), rowRecycleTypeMap: new Map(), separatorMap: new Map(), showUsernameMap: new Map(), @@ -179,10 +190,18 @@ const initialConvoStore: ConvoStore = { typing: new Set(), unfurlPrompt: new Map(), unread: 0, - unsentText: undefined, validatedOrdinalRange: undefined, } +const initialConvoUIStore: ConvoUIStore = { + commandStatus: undefined, + editing: T.Chat.numberToOrdinal(0), + giphyResult: undefined, + giphyWindow: false, + replyTo: T.Chat.numberToOrdinal(0), + unsentText: undefined, +} + type LoadMoreMessagesParams = { forceContainsLatestCalc?: boolean messageIDControl?: T.RPCChat.MessageIDControl @@ -251,7 +270,6 @@ export interface ConvoState extends ConvoStore { ) => void giphySend: (result: T.RPCChat.GiphySearchResult) => void hideConversation: (hide: boolean) => void - injectIntoInput: (text?: string) => void joinConversation: () => void jumpToRecent: () => void leaveConversation: (navToInbox?: boolean) => void @@ -307,19 +325,15 @@ export interface ConvoState extends ConvoStore { selectedConversation: () => void sendAudioRecording: (path: string, duration: number, amps: ReadonlyArray<number>) => Promise<void> sendMessage: (text: string) => void - setCommandStatusInfo: (info?: T.Chat.CommandStatusInfo) => void setConvRetentionPolicy: (policy: T.Retention.RetentionPolicy) => void - setEditing: (ordinal: T.Chat.Ordinal | 'last' | 'clear') => void setExplodingMode: (seconds: number, incoming?: boolean) => void setMarkAsUnread: (readMsgID?: T.Chat.MessageID | false) => void setMeta: (m?: T.Chat.ConversationMeta) => void setMinWriterRole: (role: T.Teams.TeamRoleType) => void setParticipants: (p: ConvoState['participants']) => void - setReplyTo: (o: T.Chat.Ordinal) => void setTyping: DebouncedFunc<(t: Set<string>) => void> showInfoPanel: (show: boolean, tab: 'settings' | 'members' | 'attachments' | 'bots' | undefined) => void tabSelected: () => void - toggleGiphyPrefill: () => void toggleMessageCollapse: (messageID: T.Chat.MessageID, ordinal: T.Chat.Ordinal) => void toggleMessageReaction: (ordinal: T.Chat.Ordinal, emoji: string) => void toggleThreadSearch: (hide?: boolean, query?: string) => void @@ -506,6 +520,7 @@ const createSlice = (id: T.Chat.ConversationIDKey = noConversationIDKey): Z.ImmerStateCreator<ConvoState> => (set, get) => { const defer = convoDeferImpl ?? stubDefer + const getUI = () => getConvoUIState(id) const getLastOrdinal = () => get().messageOrdinals?.at(-1) ?? T.Chat.numberToOrdinal(0) const getCurrentUser = () => { const s = useCurrentUserState.getState() @@ -982,11 +997,9 @@ const createSlice = ) => { const {show, clearInput} = action.payload.params if (clearInput) { - get().dispatch.injectIntoInput('') + getUI().dispatch.injectIntoInput('') } - set(s => { - s.giphyWindow = show - }) + getUI().dispatch.setGiphyWindow(show) } const refreshMutualTeamsInConv = () => { @@ -1317,7 +1330,7 @@ const createSlice = } const _messageEdit = (ordinal: T.Chat.Ordinal, text: string) => { - get().dispatch.injectIntoInput('') + getUI().dispatch.injectIntoInput('') const m = get().messageMap.get(ordinal) if (!m || !(m.type === 'text' || m.type === 'attachment')) { logger.warn("Can't find message to edit", ordinal) @@ -1325,10 +1338,10 @@ const createSlice = } // Skip if the content is the same if (m.type === 'text' && m.text.stringValue() === text) { - get().dispatch.setEditing('clear') + getUI().dispatch.setEditing('clear') return } else if (m.type === 'attachment' && m.title === text) { - get().dispatch.setEditing('clear') + getUI().dispatch.setEditing('clear') return } set(s => { @@ -1337,7 +1350,7 @@ const createSlice = m1.submitState = 'editing' } }) - get().dispatch.setEditing('clear') + getUI().dispatch.setEditing('clear') const f = async () => { await T.RPCChat.localPostEditNonblockRpcPromise({ @@ -1358,12 +1371,12 @@ const createSlice = } const _messageSend = (text: string, replyTo?: T.Chat.MessageID, waitingKey?: string) => { - get().dispatch.injectIntoInput('') - get().dispatch.setReplyTo(T.Chat.numberToOrdinal(0)) + getUI().dispatch.injectIntoInput('') + getUI().dispatch.setReplyTo(T.Chat.numberToOrdinal(0)) set(s => { s.commandMarkdown = undefined - s.giphyWindow = false }) + getUI().dispatch.setGiphyWindow(false) const f = async () => { const meta = get().meta const tlfName = meta.tlfname @@ -1386,7 +1399,7 @@ const createSlice = incomingCallMap: { 'chat.1.chatUi.chatStellarDone': ({canceled}) => { if (canceled) { - get().dispatch.injectIntoInput(text) + getUI().dispatch.injectIntoInput(text) } }, 'chat.1.chatUi.chatStellarShowConfirm': () => {}, @@ -1695,14 +1708,12 @@ const createSlice = ignorePromise(f()) }, giphySend: result => { - set(s => { - s.giphyWindow = false - }) + getUI().dispatch.setGiphyWindow(false) const f = async () => { try { await T.RPCChat.localTrackGiphySelectRpcPromise({result}) } catch {} - const replyTo = get().messageMap.get(get().replyTo)?.id + const replyTo = get().messageMap.get(getUI().replyTo)?.id _messageSend(result.targetUrl, replyTo) } ignorePromise(f()) @@ -1733,11 +1744,6 @@ const createSlice = } ignorePromise(f()) }, - injectIntoInput: text => { - set(s => { - s.unsentText = text - }) - }, joinConversation: () => { const f = async () => { await T.RPCChat.localJoinConversationByIDLocalRpcPromise({convID: get().getConvID()}) @@ -2361,7 +2367,7 @@ const createSlice = } const text = formatTextForQuoting(message.text.stringValue()) - getConvoState(newThreadCID).dispatch.injectIntoInput(text) + getConvoUIState(newThreadCID).dispatch.injectIntoInput(text) get().dispatch.defer.chatMetasReceived([meta]) getConvoState(newThreadCID).dispatch.navigateToThread('createdMessagePrivately') } @@ -2533,7 +2539,7 @@ const createSlice = } case 'chat.1.chatUi.chatCommandStatus': { const {displayText, typ, actions} = action.payload.params - get().dispatch.setCommandStatusInfo({ + getUI().dispatch.setCommandStatusInfo({ actions: T.castDraft(actions) || [], displayText, displayType: typ, @@ -2553,9 +2559,7 @@ const createSlice = break } case 'chat.1.chatUi.chatGiphySearchResults': - set(s => { - s.giphyResult = T.castDraft(action.payload.params.results) - }) + getUI().dispatch.setGiphyResult(action.payload.params.results ?? undefined) break case 'chat.1.NotifyChat.ChatRequestInfo': { @@ -2902,19 +2906,14 @@ const createSlice = } }, sendMessage: text => { - const editOrdinal = get().editing + const editOrdinal = getUI().editing if (editOrdinal) { _messageEdit(editOrdinal, text) } else { - const replyTo = get().messageMap.get(get().replyTo)?.id + const replyTo = get().messageMap.get(getUI().replyTo)?.id _messageSend(text, replyTo) } }, - setCommandStatusInfo: info => { - set(s => { - s.commandStatus = info - }) - }, setConvRetentionPolicy: _policy => { const f = async () => { const convID = get().getConvID() @@ -2932,56 +2931,6 @@ const createSlice = } ignorePromise(f()) }, - setEditing: e => { - // clearing - if (e === 'clear') { - set(s => { - s.editing = T.Chat.numberToOrdinal(0) - }) - get().dispatch.injectIntoInput('') - return - } - - const messageMap = get().messageMap - - let ordinal = T.Chat.numberToOrdinal(0) - // Editing last message - if (e === 'last') { - const editLastUser = useCurrentUserState.getState().username - // Editing your last message - const ordinals = get().messageOrdinals - const found = - !!ordinals && - findLast(ordinals, o => { - const message = messageMap.get(o) - return !!( - (message?.type === 'text' || message?.type === 'attachment') && - message.author === editLastUser && - !message.exploded && - message.isEditable - ) - }) - if (!found) return - ordinal = found - } else { - ordinal = e - } - - if (!ordinal) { - return - } - const message = messageMap.get(ordinal) - if (message?.type === 'text' || message?.type === 'attachment') { - set(s => { - s.editing = ordinal - }) - if (message.type === 'text') { - get().dispatch.injectIntoInput(message.text.stringValue()) - } else { - get().dispatch.injectIntoInput(message.title) - } - } - }, setExplodingMode: (seconds, incoming) => { set(s => { s.explodingMode = seconds @@ -3150,11 +3099,10 @@ const createSlice = const isGood = get().isMetaGood() if (!wasGood && isGood) { // got a good meta, adopt the draft once - set(s => { - // bail on if there is something - if (s.unsentText !== undefined) return - s.unsentText = s.meta.draft.length ? s.meta.draft : undefined - }) + const ui = getUI() + if (ui.unsentText === undefined) { + ui.dispatch.injectIntoInput(get().meta.draft.length ? get().meta.draft : undefined) + } } }, setMinWriterRole: role => { @@ -3181,11 +3129,6 @@ const createSlice = }) queueInboxRowUpdate(get().id) }, - setReplyTo: o => { - set(s => { - s.replyTo = o - }) - }, setTyping: throttle((t: Set<string>) => { set(s => { if (!isEqual(s.typing, t)) { @@ -3218,10 +3161,6 @@ const createSlice = get().dispatch.loadMoreMessages({reason: 'tab selected'}) get().dispatch.markThreadAsRead() }, - toggleGiphyPrefill: () => { - // if the window is up, just blow it away - get().dispatch.injectIntoInput(get().giphyWindow ? '' : '/giphy ') - }, toggleMessageCollapse: (messageID, ordinal) => { const f = async () => { const m = get().messageMap.get(ordinal) @@ -3507,13 +3446,99 @@ const createSlice = } type MadeStore = UseBoundStore<StoreApi<ConvoState>> +type MadeUIStore = UseBoundStore<StoreApi<ConvoUIState>> + +const createConvoUISlice = + (id: T.Chat.ConversationIDKey): Z.ImmerStateCreator<ConvoUIState> => + (set, get) => ({ + ...initialConvoUIStore, + dispatch: { + injectIntoInput: text => { + set(s => { + s.unsentText = text + }) + }, + resetState: Z.defaultReset, + setCommandStatusInfo: info => { + set(s => { + s.commandStatus = info ? T.castDraft(info) : undefined + }) + }, + setEditing: e => { + if (e === 'clear') { + set(s => { + s.editing = T.Chat.numberToOrdinal(0) + s.unsentText = '' + }) + return + } + + const messageMap = getConvoState(id).messageMap + let ordinal = T.Chat.numberToOrdinal(0) + if (e === 'last') { + const editLastUser = useCurrentUserState.getState().username + const ordinals = getConvoState(id).messageOrdinals + const found = + !!ordinals && + findLast(ordinals, o => { + const message = messageMap.get(o) + return !!( + (message?.type === 'text' || message?.type === 'attachment') && + message.author === editLastUser && + !message.exploded && + message.isEditable + ) + }) + if (!found) return + ordinal = found + } else { + ordinal = e + } + + if (!ordinal) return + const message = messageMap.get(ordinal) + if (message?.type === 'text' || message?.type === 'attachment') { + set(s => { + s.editing = ordinal + s.unsentText = message.type === 'text' ? message.text.stringValue() : message.title + }) + } + }, + setGiphyResult: result => { + set(s => { + s.giphyResult = result ? T.castDraft(result) : undefined + }) + }, + setGiphyWindow: show => { + set(s => { + s.giphyWindow = show + }) + }, + setReplyTo: ordinal => { + set(s => { + s.replyTo = ordinal + }) + }, + toggleGiphyPrefill: () => { + const shouldClear = get().giphyWindow + set(s => { + s.unsentText = shouldClear ? '' : '/giphy ' + }) + }, + }, + }) export const chatStores: Map<T.Chat.ConversationIDKey, MadeStore> = __DEV__ ? ((globalThis.__hmr_chatStores ??= new Map()) as Map<T.Chat.ConversationIDKey, MadeStore>) : new Map() +export const convoUIStores: Map<T.Chat.ConversationIDKey, MadeUIStore> = __DEV__ + ? ((globalThis.__hmr_convoUIStores ??= new Map()) as Map<T.Chat.ConversationIDKey, MadeUIStore>) + : new Map() + export const clearChatStores = () => { chatStores.clear() + convoUIStores.clear() } registerDebugClear(() => { @@ -3528,6 +3553,14 @@ const createConvoStore = (id: T.Chat.ConversationIDKey) => { return next } +const createConvoUIStore = (id: T.Chat.ConversationIDKey) => { + const existing = convoUIStores.get(id) + if (existing) return existing + const next = Z.createZustand<ConvoUIState>(createConvoUISlice(id)) + convoUIStores.set(id, next) + return next +} + export const createConvoStoreForTesting = (id: T.Chat.ConversationIDKey) => { return Z.createZustand<ConvoState>(createSlice(id)) } @@ -3543,6 +3576,11 @@ export function getConvoState(id: T.Chat.ConversationIDKey) { return store.getState() } +export function getConvoUIState(id: T.Chat.ConversationIDKey) { + const store = createConvoUIStore(id) + return store.getState() +} + const Context = React.createContext<MadeStore | null>(null) type ConvoProviderProps = React.PropsWithChildren<{ @@ -3574,12 +3612,22 @@ export function useChatContext<T>(selector: (state: ConvoState) => T): T { return useStore(store, selector) } +export function useChatUIContext<T>(selector: (state: ConvoUIState) => T): T { + const id = useChatContext(s => s.id) + return useConvoUIState(id, selector) +} + // unusual, usually you useContext, but maybe in teams export function useConvoState<T>(id: T.Chat.ConversationIDKey, selector: (state: ConvoState) => T): T { const store = createConvoStore(id) return useStore(store, selector) } +export function useConvoUIState<T>(id: T.Chat.ConversationIDKey, selector: (state: ConvoUIState) => T): T { + const store = createConvoUIStore(id) + return useStore(store, selector) +} + type ChatRouteParams = {conversationIDKey?: T.Chat.ConversationIDKey} type RouteParams = { From b8cdba36509620d6dbff424b8fe12af611834858 Mon Sep 17 00:00:00 2001 From: chrisnojima <cnojima@keyba.se> Date: Thu, 9 Apr 2026 09:01:26 -0400 Subject: [PATCH 49/55] WIP --- .../wrapper/long-pressable/index.native.tsx | 19 +++++++++---------- shared/stores/convostate.tsx | 2 +- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/shared/chat/conversation/messages/wrapper/long-pressable/index.native.tsx b/shared/chat/conversation/messages/wrapper/long-pressable/index.native.tsx index 5bafbd632e23..af0405059589 100644 --- a/shared/chat/conversation/messages/wrapper/long-pressable/index.native.tsx +++ b/shared/chat/conversation/messages/wrapper/long-pressable/index.native.tsx @@ -1,10 +1,12 @@ -import * as C from '@/constants' import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' import * as React from 'react' import type {Props} from '.' import {useOrdinal} from '../../ids-context' -import Swipeable, {type SwipeableMethods, SwipeDirection} from 'react-native-gesture-handler/ReanimatedSwipeable' +import Swipeable, { + type SwipeableMethods, + SwipeDirection, +} from 'react-native-gesture-handler/ReanimatedSwipeable' import {Pressable, Keyboard} from 'react-native' import {FocusContext} from '@/chat/conversation/normal/context' import * as Reanimated from 'react-native-reanimated' @@ -27,18 +29,15 @@ function LongPressable(props: Props) { const onPress = () => Keyboard.dismiss() const inner = ( - <Pressable - style={[styles.pressable, style]} - onLongPress={onLongPress} - onPress={onPress} - > + <Pressable style={[styles.pressable, style]} onLongPress={onLongPress} onPress={onPress}> {children} </Pressable> ) - const makeAction = (_progress: Reanimated.SharedValue<number>, translation: Reanimated.SharedValue<number>) => ( - <ReplyIcon progress={translation} /> - ) + const makeAction = ( + _progress: Reanimated.SharedValue<number>, + translation: Reanimated.SharedValue<number> + ) => <ReplyIcon progress={translation} /> const toggleThreadSearch = Chat.useChatContext(s => s.dispatch.toggleThreadSearch) const setReplyTo = Chat.useChatUIContext(s => s.dispatch.setReplyTo) diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 5dfd0e853ef8..64c05bb4e15f 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -2559,7 +2559,7 @@ const createSlice = break } case 'chat.1.chatUi.chatGiphySearchResults': - getUI().dispatch.setGiphyResult(action.payload.params.results ?? undefined) + getUI().dispatch.setGiphyResult(action.payload.params.results) break case 'chat.1.NotifyChat.ChatRequestInfo': { From b3b0d2073d43d627fd24f64d4882615c55fdb764 Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Thu, 9 Apr 2026 09:06:42 -0400 Subject: [PATCH 50/55] WIP --- .../conversation/messages/wrapper/wrapper.tsx | 2 +- shared/constants/init/index.native.tsx | 2 +- shared/incoming-share/index.tsx | 2 +- shared/stores/convostate.tsx | 32 ++++++++--- shared/stores/tests/convostate.test.ts | 54 ++++++++++++------- 5 files changed, 64 insertions(+), 28 deletions(-) diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index ca08b7e65aa5..732b4e2dcd06 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -97,7 +97,7 @@ const emptyAuthorData: FlatAuthorData = { const getRowActions = ( dispatch: ConvoState['dispatch'], - uiDispatch: ConvoUIState['dispatch'] + uiDispatch: Pick<ConvoUIState['dispatch'], 'setEditing' | 'setReplyTo'> ): RowActions => { const {messageDelete, messageRetry, replyJump, toggleMessageReaction} = dispatch const {setEditing, setReplyTo} = uiDispatch diff --git a/shared/constants/init/index.native.tsx b/shared/constants/init/index.native.tsx index ee6149d76aa2..5ccada701fb7 100644 --- a/shared/constants/init/index.native.tsx +++ b/shared/constants/init/index.native.tsx @@ -168,7 +168,7 @@ const ensureBackgroundTask = () => { } const setPermissionDeniedCommandStatus = (conversationIDKey: T.Chat.ConversationIDKey, text: string) => { - getConvoState(conversationIDKey).dispatch.setCommandStatusInfo({ + getConvoUIState(conversationIDKey).dispatch.setCommandStatusInfo({ actions: [T.RPCChat.UICommandStatusActionTyp.appsettings], displayText: text, displayType: T.RPCChat.UICommandStatusDisplayTyp.error, diff --git a/shared/incoming-share/index.tsx b/shared/incoming-share/index.tsx index 2ed4eae5ba72..f767e1a0c0a7 100644 --- a/shared/incoming-share/index.tsx +++ b/shared/incoming-share/index.tsx @@ -184,7 +184,7 @@ const IncomingShare = (props: IncomingShareWithSelectionProps) => { if (!canDirectNav || hasNavigatedRef.current) return hasNavigatedRef.current = true const {dispatch} = Chat.getConvoState(selectedConversationIDKey) - text && dispatch.injectIntoInput(text) + text && Chat.getConvoUIState(selectedConversationIDKey).dispatch.injectIntoInput(text) dispatch.navigateToThread('extension') if (sendPaths.length > 0) { const meta = Chat.getConvoState(selectedConversationIDKey).meta diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 64c05bb4e15f..9071513d82a3 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -517,10 +517,13 @@ const loadThreadMessageTypes = enumKeys(T.RPCChat.MessageType).reduce<Array<T.RP ) const createSlice = - (id: T.Chat.ConversationIDKey = noConversationIDKey): Z.ImmerStateCreator<ConvoState> => + ( + id: T.Chat.ConversationIDKey = noConversationIDKey, + getLinkedUIState: () => ConvoUIState = () => getConvoUIState(id) + ): Z.ImmerStateCreator<ConvoState> => (set, get) => { const defer = convoDeferImpl ?? stubDefer - const getUI = () => getConvoUIState(id) + const getUI = getLinkedUIState const getLastOrdinal = () => get().messageOrdinals?.at(-1) ?? T.Chat.numberToOrdinal(0) const getCurrentUser = () => { const s = useCurrentUserState.getState() @@ -3449,7 +3452,10 @@ type MadeStore = UseBoundStore<StoreApi<ConvoState>> type MadeUIStore = UseBoundStore<StoreApi<ConvoUIState>> const createConvoUISlice = - (id: T.Chat.ConversationIDKey): Z.ImmerStateCreator<ConvoUIState> => + ( + id: T.Chat.ConversationIDKey, + getLinkedConvoState: () => ConvoState = () => getConvoState(id) + ): Z.ImmerStateCreator<ConvoUIState> => (set, get) => ({ ...initialConvoUIStore, dispatch: { @@ -3473,11 +3479,11 @@ const createConvoUISlice = return } - const messageMap = getConvoState(id).messageMap + const messageMap = getLinkedConvoState().messageMap let ordinal = T.Chat.numberToOrdinal(0) if (e === 'last') { const editLastUser = useCurrentUserState.getState().username - const ordinals = getConvoState(id).messageOrdinals + const ordinals = getLinkedConvoState().messageOrdinals const found = !!ordinals && findLast(ordinals, o => { @@ -3533,7 +3539,7 @@ export const chatStores: Map<T.Chat.ConversationIDKey, MadeStore> = __DEV__ : new Map() export const convoUIStores: Map<T.Chat.ConversationIDKey, MadeUIStore> = __DEV__ - ? ((globalThis.__hmr_convoUIStores ??= new Map()) as Map<T.Chat.ConversationIDKey, MadeUIStore>) + ? (((globalThis as any).__hmr_convoUIStores ??= new Map()) as Map<T.Chat.ConversationIDKey, MadeUIStore>) : new Map() export const clearChatStores = () => { @@ -3562,7 +3568,19 @@ const createConvoUIStore = (id: T.Chat.ConversationIDKey) => { } export const createConvoStoreForTesting = (id: T.Chat.ConversationIDKey) => { - return Z.createZustand<ConvoState>(createSlice(id)) + return createConvoStoresForTesting(id).convoStore +} + +export const createConvoStoresForTesting = (id: T.Chat.ConversationIDKey) => { + let convoStore!: UseBoundStore<StoreApi<ConvoState>> + let uiStore!: UseBoundStore<StoreApi<ConvoUIState>> + convoStore = Z.createZustand<ConvoState>(createSlice(id, () => uiStore.getState())) + uiStore = Z.createZustand<ConvoUIState>(createConvoUISlice(id, () => convoStore.getState())) + return {convoStore, uiStore} +} + +export const createConvoUIStoreForTesting = (id: T.Chat.ConversationIDKey) => { + return createConvoStoresForTesting(id).uiStore } // debug only diff --git a/shared/stores/tests/convostate.test.ts b/shared/stores/tests/convostate.test.ts index 125b4974630a..a2c0e674b491 100644 --- a/shared/stores/tests/convostate.test.ts +++ b/shared/stores/tests/convostate.test.ts @@ -4,7 +4,12 @@ import * as Message from '../../constants/chat/message' import * as T from '../../constants/types' import HiddenString from '../../util/hidden-string' import {useCurrentUserState} from '../current-user' -import {createConvoStoreForTesting, type ConvoState} from '../convostate' +import { + createConvoStoreForTesting, + createConvoStoresForTesting, + type ConvoState, + type ConvoUIState, +} from '../convostate' jest.mock('../inbox-rows', () => ({ queueInboxRowUpdate: jest.fn(), @@ -169,6 +174,18 @@ const applyState = ( }) } +const applyUIState = ( + store: {getState: () => any; setState: (state: any) => void}, + partial: Partial<ConvoUIState> +) => { + const current = store.getState() + store.setState({ + ...current, + ...partial, + dispatch: current.dispatch, + }) +} + const createStore = () => createConvoStoreForTesting(convID) const seedStore = ( @@ -577,7 +594,8 @@ test('onMessageErrored marks the pending message as failed and leaves unknown ou test('setEditing last picks the latest editable local message and injects its content', () => { const attachmentOrdinal = T.Chat.numberToOrdinal(703) - const store = seedStore([ + const {convoStore: store, uiStore} = createConvoStoresForTesting(convID) + applyState(store, seedStore([ makeTextMessage({ author: 'bob', id: T.Chat.numberToMessageID(701), @@ -597,42 +615,42 @@ test('setEditing last picks the latest editable local message and injects its co outboxID: T.Chat.stringToOutboxID('editable-attachment'), title: 'picked attachment title', }), - ]) + ]).getState()) - store.getState().dispatch.setEditing('last') + uiStore.getState().dispatch.setEditing('last') - expect(store.getState().editing).toBe(attachmentOrdinal) - expect(store.getState().unsentText).toBe('picked attachment title') + expect(uiStore.getState().editing).toBe(attachmentOrdinal) + expect(uiStore.getState().unsentText).toBe('picked attachment title') }) test('setEditing clear resets editing state and clears unsent text', () => { - const store = createStore() - applyState(store, { + const {uiStore} = createConvoStoresForTesting(convID) + applyUIState(uiStore, { editing: ordinal, unsentText: 'draft text', }) - store.getState().dispatch.setEditing('clear') + uiStore.getState().dispatch.setEditing('clear') - expect(store.getState().editing).toBe(T.Chat.numberToOrdinal(0)) - expect(store.getState().unsentText).toBe('') + expect(uiStore.getState().editing).toBe(T.Chat.numberToOrdinal(0)) + expect(uiStore.getState().unsentText).toBe('') }) test('setMeta adopts the server draft once when the meta becomes good', () => { - const store = createStore() + const {convoStore: store, uiStore} = createConvoStoresForTesting(convID) store.getState().dispatch.setMeta(makeMeta({draft: 'server draft'})) expect(store.getState().isMetaGood()).toBe(true) - expect(store.getState().unsentText).toBe('server draft') + expect(uiStore.getState().unsentText).toBe('server draft') - store.getState().dispatch.injectIntoInput('local draft') + uiStore.getState().dispatch.injectIntoInput('local draft') store.getState().dispatch.setMeta(makeMeta({draft: 'new server draft'})) - expect(store.getState().unsentText).toBe('local draft') + expect(uiStore.getState().unsentText).toBe('local draft') }) test('local setters update participants, reply target, and badge', () => { - const store = createStore() + const {convoStore: store, uiStore} = createConvoStoresForTesting(convID) const participants: ConvoState['participants'] = { all: ['alice', 'bob'], contactName: new Map([['bob', 'Bobby']]), @@ -640,11 +658,11 @@ test('local setters update participants, reply target, and badge', () => { } store.getState().dispatch.setParticipants(participants) - store.getState().dispatch.setReplyTo(ordinal) + uiStore.getState().dispatch.setReplyTo(ordinal) store.getState().dispatch.badgesUpdated(3) expect(store.getState().participants).toEqual(participants) - expect(store.getState().replyTo).toBe(ordinal) + expect(uiStore.getState().replyTo).toBe(ordinal) expect(store.getState().badge).toBe(3) }) From a5dfc214f592b92a75417fad7ae609db921f3e4f Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Thu, 9 Apr 2026 09:08:42 -0400 Subject: [PATCH 51/55] WIP --- shared/constants/init/index.native.tsx | 2 +- shared/stores/convostate.tsx | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/shared/constants/init/index.native.tsx b/shared/constants/init/index.native.tsx index 5ccada701fb7..c52c23f5332d 100644 --- a/shared/constants/init/index.native.tsx +++ b/shared/constants/init/index.native.tsx @@ -35,7 +35,7 @@ import {initPushListener, getStartupDetailsFromInitialPush} from './push-listene import {initSharedSubscriptions, _onEngineIncoming} from './shared' import {noConversationIDKey} from '../types/chat/common' import {getSelectedConversation} from '../chat/common' -import {getConvoState} from '@/stores/convostate' +import {getConvoState, getConvoUIState} from '@/stores/convostate' import { requestLocationPermission, saveAttachmentToCameraRoll, diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 9071513d82a3..4a501fc47370 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -3572,10 +3572,14 @@ export const createConvoStoreForTesting = (id: T.Chat.ConversationIDKey) => { } export const createConvoStoresForTesting = (id: T.Chat.ConversationIDKey) => { - let convoStore!: UseBoundStore<StoreApi<ConvoState>> - let uiStore!: UseBoundStore<StoreApi<ConvoUIState>> - convoStore = Z.createZustand<ConvoState>(createSlice(id, () => uiStore.getState())) - uiStore = Z.createZustand<ConvoUIState>(createConvoUISlice(id, () => convoStore.getState())) + const pair = {} as { + convoStore: UseBoundStore<StoreApi<ConvoState>> + uiStore: UseBoundStore<StoreApi<ConvoUIState>> + } + const convoStore = Z.createZustand<ConvoState>(createSlice(id, () => pair.uiStore.getState())) + const uiStore = Z.createZustand<ConvoUIState>(createConvoUISlice(id, () => pair.convoStore.getState())) + pair.convoStore = convoStore + pair.uiStore = uiStore return {convoStore, uiStore} } From 6e6b92d43a3a3452ce9819e4fffe75589e09e2e1 Mon Sep 17 00:00:00 2001 From: chrisnojima <cnojima@keyba.se> Date: Thu, 9 Apr 2026 09:14:07 -0400 Subject: [PATCH 52/55] WIP --- PLAN.md => plans/chat-refactor.md | 0 INVESTIGATION.md => plans/inbox-load-fail.md | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename PLAN.md => plans/chat-refactor.md (100%) rename INVESTIGATION.md => plans/inbox-load-fail.md (100%) diff --git a/PLAN.md b/plans/chat-refactor.md similarity index 100% rename from PLAN.md rename to plans/chat-refactor.md diff --git a/INVESTIGATION.md b/plans/inbox-load-fail.md similarity index 100% rename from INVESTIGATION.md rename to plans/inbox-load-fail.md From bca7921664b291f2fcfc099601285734e5cdb499 Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Thu, 9 Apr 2026 09:24:05 -0400 Subject: [PATCH 53/55] WIP --- go/chat/uithreadloader.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/go/chat/uithreadloader.go b/go/chat/uithreadloader.go index 8a7a16f20b78..f808950d4803 100644 --- a/go/chat/uithreadloader.go +++ b/go/chat/uithreadloader.go @@ -716,8 +716,8 @@ func (t *UIThreadLoader) LoadNonblock(ctx context.Context, chatUI libkb.ChatUI, resultPagination = rthread.Pagination t.applyPagerModeOutgoing(ctx, convID, rthread.Pagination, pagination, pgmode) t.Debug(ctx, "LoadNonblock[%s]: full send begin convID: %s", reqID, convID) - if fullErr = chatUI.ChatThreadFull(ctx, string(jsonUIRes)); err != nil { - t.Debug(ctx, "LoadNonblock: failed to send full result to UI: %s", err) + if fullErr = chatUI.ChatThreadFull(ctx, string(jsonUIRes)); fullErr != nil { + t.Debug(ctx, "LoadNonblock: failed to send full result to UI: %s", fullErr) return } fullSent = true From 4907c024008960b2ce64fae75fcd6fc6c88ec1ef Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Thu, 9 Apr 2026 10:26:35 -0400 Subject: [PATCH 54/55] WIP --- plans/chat-refactor.md | 1 + shared/chat/conversation/thread-search-route.ts | 4 ++-- .../row/small-team/swipe-conv-actions/index.native.tsx | 2 +- shared/constants/router.tsx | 6 +----- shared/router-v2/route-params.tsx | 9 +-------- 5 files changed, 6 insertions(+), 16 deletions(-) diff --git a/plans/chat-refactor.md b/plans/chat-refactor.md index 520d728bdc54..f331fa551655 100644 --- a/plans/chat-refactor.md +++ b/plans/chat-refactor.md @@ -41,6 +41,7 @@ Primary files: - [x] Update separator, username-grouping, and reaction-order metadata only for changed ordinals and any affected neighbors. - [x] Avoid rebuilding and resorting `messageOrdinals` unless thread membership actually changed. - [x] Re-evaluate whether some derived metadata should live in store state at all. +- [ ] Audit per-message render-time computation and decide whether values that are only consumed by one caller should be stored in derived message state instead of recomputed during render. Primary files: diff --git a/shared/chat/conversation/thread-search-route.ts b/shared/chat/conversation/thread-search-route.ts index b0a70a4d8cf3..d36dc0e84353 100644 --- a/shared/chat/conversation/thread-search-route.ts +++ b/shared/chat/conversation/thread-search-route.ts @@ -1,4 +1,4 @@ -import type {RootRouteProps} from '@/router-v2/route-params' +import {getRouteParamsFromRoute, type RootRouteProps} from '@/router-v2/route-params' import {useRoute} from '@react-navigation/native' export type ThreadSearchRoute = { @@ -11,5 +11,5 @@ export type ThreadSearchRouteProps = { export const useThreadSearchRoute = () => { const route = useRoute<RootRouteProps<'chatConversation'> | RootRouteProps<'chatRoot'>>() - return route.params.threadSearch + return getRouteParamsFromRoute<'chatConversation' | 'chatRoot'>(route)?.threadSearch } diff --git a/shared/chat/inbox/row/small-team/swipe-conv-actions/index.native.tsx b/shared/chat/inbox/row/small-team/swipe-conv-actions/index.native.tsx index c9668af271ca..cc5dd4c2fa6f 100644 --- a/shared/chat/inbox/row/small-team/swipe-conv-actions/index.native.tsx +++ b/shared/chat/inbox/row/small-team/swipe-conv-actions/index.native.tsx @@ -127,7 +127,7 @@ function SwipeConvActions(p: Props) { const inner = onPress ? ( <RectButton onPress={onPress} style={styles.touchable} testID="inboxRow"> - <View accessibilityRole="button" style={styles.touchable}> + <View accessible={false} style={styles.touchable}> {children} </View> </RectButton> diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index d026321b2354..54f3914f1743 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -12,7 +12,6 @@ import { } from '@react-navigation/core' import type {StaticScreenProps} from '@react-navigation/core' import type { - AllOptionalParamRouteKeys, NoParamRouteKeys, ParamRouteKeys, RouteKeys, @@ -255,10 +254,7 @@ export const navUpToScreen = (name: RouteKeys) => { n.dispatch(StackActions.popTo(typeof name === 'string' ? name : String(name))) } -export function navigateAppend<RouteName extends NoParamRouteKeys | AllOptionalParamRouteKeys>( - path: RouteName, - replace?: boolean -): void +export function navigateAppend<RouteName extends NoParamRouteKeys>(path: RouteName, replace?: boolean): void export function navigateAppend<RouteName extends ParamRouteKeys>( path: {name: RouteName; params: KBRootParamList[RouteName]}, replace?: boolean diff --git a/shared/router-v2/route-params.tsx b/shared/router-v2/route-params.tsx index ae7573dd636f..a6706d17be2b 100644 --- a/shared/router-v2/route-params.tsx +++ b/shared/router-v2/route-params.tsx @@ -46,17 +46,10 @@ export type NoParamRouteKeys = { [K in RouteKeys]: RootParamList[K] extends undefined ? K : never }[RouteKeys] export type ParamRouteKeys = Exclude<RouteKeys, NoParamRouteKeys> -// Routes with required params would break if navigated to without params. -// Routes where all params are optional can be safely navigated to with just a name string. -export type AllOptionalParamRouteKeys = { - [K in ParamRouteKeys]: {} extends NonNullable<RootParamList[K]> ? K : never -}[ParamRouteKeys] export type NavigateAppendArg<RouteName extends RouteKeys> = RouteName extends RouteName ? RootParamList[RouteName] extends undefined ? RouteName - : {} extends NonNullable<RootParamList[RouteName]> - ? RouteName | {name: RouteName; params: RootParamList[RouteName]} - : {name: RouteName; params: RootParamList[RouteName]} + : {name: RouteName; params: RootParamList[RouteName]} : never export type NavigateAppendType = NavigateAppendArg<RouteKeys> export type RootRouteProps<RouteName extends keyof RootParamList> = RouteProp<RootParamList, RouteName> From 09a4fe931ce7810a8e9fb0c9494dd1bce35d071a Mon Sep 17 00:00:00 2001 From: Chris Nojima <christopher.nojima@zoom.us> Date: Thu, 9 Apr 2026 10:30:19 -0400 Subject: [PATCH 55/55] WIP --- shared/constants/router.tsx | 13 ++----------- shared/people/announcement.tsx | 2 +- shared/people/todo.tsx | 2 +- shared/settings/root-phone.tsx | 4 ++-- shared/teams/add-members-wizard/add-from-where.tsx | 2 +- shared/teams/add-members-wizard/confirm.tsx | 2 +- 6 files changed, 8 insertions(+), 17 deletions(-) diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index 54f3914f1743..f0dcd797e579 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -12,8 +12,7 @@ import { } from '@react-navigation/core' import type {StaticScreenProps} from '@react-navigation/core' import type { - NoParamRouteKeys, - ParamRouteKeys, + NavigateAppendType, RouteKeys, RootParamList as KBRootParamList, } from '@/router-v2/route-params' @@ -254,15 +253,7 @@ export const navUpToScreen = (name: RouteKeys) => { n.dispatch(StackActions.popTo(typeof name === 'string' ? name : String(name))) } -export function navigateAppend<RouteName extends NoParamRouteKeys>(path: RouteName, replace?: boolean): void -export function navigateAppend<RouteName extends ParamRouteKeys>( - path: {name: RouteName; params: KBRootParamList[RouteName]}, - replace?: boolean -): void -export function navigateAppend( - path: RouteKeys | {name: RouteKeys; params: object | undefined}, - replace?: boolean -) { +export function navigateAppend(path: NavigateAppendType, replace?: boolean) { DEBUG_NAV && console.log('[Nav] navigateAppend', {path}) const n = _getNavigator() if (!n) { diff --git a/shared/people/announcement.tsx b/shared/people/announcement.tsx index 97ed1c660659..4c48835ae581 100644 --- a/shared/people/announcement.tsx +++ b/shared/people/announcement.tsx @@ -50,7 +50,7 @@ const Container = (ownProps: OwnProps) => { case T.RPCGen.AppLinkType.git: switchTab(C.isMobile ? C.Tabs.settingsTab : C.Tabs.gitTab) if (C.isMobile) { - navigateAppend(Settings.settingsGitTab) + navigateAppend({name: Settings.settingsGitTab, params: {}}) } break case T.RPCGen.AppLinkType.devices: diff --git a/shared/people/todo.tsx b/shared/people/todo.tsx index b390fec71405..6b3a2e98819f 100644 --- a/shared/people/todo.tsx +++ b/shared/people/todo.tsx @@ -189,7 +189,7 @@ const GitRepoTask = (props: TodoOwnProps) => { const {navigateAppend, switchTab} = useRouterNavigation() const onConfirm = (isTeam: boolean) => { if (C.isMobile) { - navigateAppend(settingsGitTab) + navigateAppend({name: settingsGitTab, params: {}}) } else { switchTab(C.Tabs.gitTab) } diff --git a/shared/settings/root-phone.tsx b/shared/settings/root-phone.tsx index f098cebfdd14..aefb55cf3fc7 100644 --- a/shared/settings/root-phone.tsx +++ b/shared/settings/root-phone.tsx @@ -87,7 +87,7 @@ function SettingsNav() { badgeNumber: badgeNumbers.get(C.Tabs.gitTab), icon: 'iconfont-nav-2-git', onClick: () => { - navigateAppend(Settings.settingsGitTab) + navigateAppend({name: Settings.settingsGitTab, params: {}}) }, text: 'Git', }, @@ -136,7 +136,7 @@ function SettingsNav() { }, { onClick: () => { - navigateAppend(Settings.settingsFeedbackTab) + navigateAppend({name: Settings.settingsFeedbackTab, params: {}}) }, text: 'Feedback', }, diff --git a/shared/teams/add-members-wizard/add-from-where.tsx b/shared/teams/add-members-wizard/add-from-where.tsx index 47144f3b3443..01717569d547 100644 --- a/shared/teams/add-members-wizard/add-from-where.tsx +++ b/shared/teams/add-members-wizard/add-from-where.tsx @@ -14,7 +14,7 @@ const AddFromWhere = () => { const onContinueKeybase = () => appendNewTeamBuilder(teamID) const onContinuePhone = () => nav.safeNavigateAppend('teamAddToTeamPhone') const onContinueContacts = () => nav.safeNavigateAppend('teamAddToTeamContacts') - const onContinueEmail = () => nav.safeNavigateAppend('teamAddToTeamEmail') + const onContinueEmail = () => nav.safeNavigateAppend({name: 'teamAddToTeamEmail', params: {}}) return ( <> diff --git a/shared/teams/add-members-wizard/confirm.tsx b/shared/teams/add-members-wizard/confirm.tsx index 2563c8245547..1f9f33d722ad 100644 --- a/shared/teams/add-members-wizard/confirm.tsx +++ b/shared/teams/add-members-wizard/confirm.tsx @@ -202,7 +202,7 @@ const AddMoreMembers = () => { const onAddKeybase = () => appendNewTeamBuilder(teamID) const onAddContacts = () => nav.safeNavigateAppend('teamAddToTeamContacts') const onAddPhone = () => nav.safeNavigateAppend('teamAddToTeamPhone') - const onAddEmail = () => nav.safeNavigateAppend('teamAddToTeamEmail') + const onAddEmail = () => nav.safeNavigateAppend({name: 'teamAddToTeamEmail', params: {}}) return ( <Kb.FloatingMenu attachTo={attachTo}