From 11ae23ed9a6b601d03aa7040b98c98092edf39ad Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 9 Apr 2026 10:47:37 -0400 Subject: [PATCH 01/59] WIP --- plans/chat-refactor.md | 4 +-- .../conversation/list-area/index.native.tsx | 34 ++++++++++++------- shared/stores/convostate.tsx | 26 ++++++++++---- 3 files changed, 43 insertions(+), 21 deletions(-) diff --git a/plans/chat-refactor.md b/plans/chat-refactor.md index f331fa551655..cb84b8c91795 100644 --- a/plans/chat-refactor.md +++ b/plans/chat-refactor.md @@ -86,9 +86,9 @@ Primary files: ### 5. List Data Stability And Recycling -- [ ] Remove avoidable array cloning / reversing in the hottest list path. +- [x] Remove avoidable array cloning / reversing in the hottest list path. - [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. +- [x] Re-check list item type stability after workstreams 1 and 3 land. - [ ] Keep scroll position and centered-message behavior unchanged. Primary files: diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 3c52761c8627..82494e6a4866 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -20,6 +20,7 @@ import noop from 'lodash/noop' // TODO if we bring flashlist back bring back the patch const List = /*usingFlashList ? FlashList :*/ FlatList +const noOrdinals: ReadonlyArray = [] // We load the first thread automatically so in order to mark it read // we send an action on the first mount once @@ -27,9 +28,24 @@ let markedInitiallyLoaded = false export const DEBUGDump = () => {} +const useInvertedMessageOrdinals = (messageOrdinals?: ReadonlyArray) => { + const cacheRef = React.useRef<{ + inverted: ReadonlyArray + source: ReadonlyArray + }>({inverted: noOrdinals, source: noOrdinals}) + const source = messageOrdinals ?? noOrdinals + if (cacheRef.current.source !== source) { + cacheRef.current = { + inverted: source.length > 1 ? [...source].reverse() : source, + source, + } + } + return cacheRef.current.inverted +} + const useScrolling = (p: { centeredOrdinal: T.Chat.Ordinal - messageOrdinals: Array + messageOrdinals: ReadonlyArray conversationIDKey: T.Chat.ConversationIDKey listRef: React.RefObject |*/ FlatList | null> }) => { @@ -108,7 +124,7 @@ const ConversationList = function ConversationList() { const _messageOrdinals = Chat.useChatContext(s => s.messageOrdinals) const rowRecycleTypeMap = Chat.useChatContext(s => s.rowRecycleTypeMap) - const messageOrdinals = [...(_messageOrdinals ?? [])].reverse() + const messageOrdinals = useInvertedMessageOrdinals(_messageOrdinals) const listRef = React.useRef |*/ FlatList | null>(null) const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) @@ -116,9 +132,8 @@ const ConversationList = function ConversationList() { return String(ordinal) } - const renderItem = (info?: /*ListRenderItemInfo*/ {index?: number}) => { - const index: number = info?.index ?? 0 - const ordinal = messageOrdinals[index] + const renderItem = (info?: /*ListRenderItemInfo*/ {item?: ItemType}) => { + const ordinal = info?.item if (!ordinal) { return null } @@ -132,18 +147,13 @@ const ConversationList = function ConversationList() { const numOrdinals = messageOrdinals.length - const getItemType = (ordinal: T.Chat.Ordinal, idx: number) => { + const getItemType = (ordinal: T.Chat.Ordinal) => { if (!ordinal) { return 'null' } 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 - if (numOrdinals - 1 === idx && (baseType === 'text' || baseType === 'attachment')) { - return `${baseType}:pending` - } - return baseType + return messageTypeMap.get(ordinal) ?? 'text' } const {scrollToCentered, scrollToBottom, onEndReached} = useScrolling({ diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 4a501fc47370..d3af476071df 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -683,20 +683,31 @@ const createSlice = } } - const getRowRecycleType = (message: T.Chat.Message): string | undefined => { - if (message.type !== 'text') { - return undefined + const getRowRecycleType = ( + message: T.Chat.Message, + renderType: T.Chat.RenderMessageType + ): string | undefined => { + let rowRecycleType = renderType + let needsSpecificRecycleType = false + + if ( + (message.type === 'text' || message.type === 'attachment') && + (message.submitState === 'pending' || message.submitState === 'failed') + ) { + rowRecycleType += ':pending' + needsSpecificRecycleType = true } - let rowRecycleType = 'text' - if (message.replyTo) { + if (message.type === 'text' && message.replyTo) { rowRecycleType += ':reply' + needsSpecificRecycleType = true } if (message.reactions?.size) { rowRecycleType += ':reactions' + needsSpecificRecycleType = true } - return rowRecycleType === 'text' ? undefined : rowRecycleType + return needsSpecificRecycleType ? rowRecycleType : undefined } const setRowRenderDerivedMetadata = ( @@ -704,7 +715,8 @@ const createSlice = ordinal: T.Chat.Ordinal, message: T.Chat.Message ) => { - const rowRecycleType = getRowRecycleType(message) + const renderType = s.messageTypeMap.get(ordinal) ?? Message.getMessageRenderType(message) + const rowRecycleType = getRowRecycleType(message, renderType) if (rowRecycleType) { s.rowRecycleTypeMap.set(ordinal, rowRecycleType) } else { From 7862b92dbe96e2dbaa60d2a414ab7bf04fda45f3 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 9 Apr 2026 10:52:15 -0400 Subject: [PATCH 02/59] WIP --- shared/chat/conversation/list-area/index.native.tsx | 12 +----------- .../chat/conversation/messages/wrapper/wrapper.tsx | 5 ++++- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 82494e6a4866..43e20999aa97 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -29,18 +29,8 @@ let markedInitiallyLoaded = false export const DEBUGDump = () => {} const useInvertedMessageOrdinals = (messageOrdinals?: ReadonlyArray) => { - const cacheRef = React.useRef<{ - inverted: ReadonlyArray - source: ReadonlyArray - }>({inverted: noOrdinals, source: noOrdinals}) const source = messageOrdinals ?? noOrdinals - if (cacheRef.current.source !== source) { - cacheRef.current = { - inverted: source.length > 1 ? [...source].reverse() : source, - source, - } - } - return cacheRef.current.inverted + return React.useMemo(() => (source.length > 1 ? [...source].reverse() : source), [source]) } const useScrolling = (p: { diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index 732b4e2dcd06..71b35b04ba24 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -997,7 +997,10 @@ const styles = Kb.Styles.styleSheetCreate( top: 4, zIndex: 2, }, - isMobile: {left: Kb.Styles.globalMargins.tiny}, + isMobile: { + left: Kb.Styles.globalMargins.tiny, + zIndex: 2, + }, }), background: { alignSelf: 'stretch', From 3e6de5bd7e1bf986b39dd770039452b821cbee78 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 9 Apr 2026 11:11:40 -0400 Subject: [PATCH 03/59] WIP --- plans/chat-refactor.md | 17 ++++++++- .../conversation/messages/reactions-rows.tsx | 6 ++-- .../conversation/messages/wrapper/wrapper.tsx | 19 +++++++--- shared/chat/conversation/pinned-message.tsx | 3 +- shared/constants/chat/message.tsx | 35 +++++++++++-------- shared/constants/types/chat/message.tsx | 1 + shared/stores/convostate.tsx | 10 ++++++ 7 files changed, 67 insertions(+), 24 deletions(-) diff --git a/plans/chat-refactor.md b/plans/chat-refactor.md index cb84b8c91795..4f67ae4e8d09 100644 --- a/plans/chat-refactor.md +++ b/plans/chat-refactor.md @@ -41,7 +41,22 @@ 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. +- [x] 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. + +Decision note: + +- Cache per-row reaction order in convo-store derived metadata so reaction chips do not resort on every render. +- Keep separator orange-line timing/render decisions local for now because they still depend on live orange-line context and platform-specific presentation. + +Audit outcome: + +- If a field is message-local, deterministic, and the raw form has no semantic consumers, normalize it once when we build or merge the stored message instead of repeatedly massaging it during render. +- Do not add separate UI-derived duplicates to the stored message when the result depends on current user, convo meta, team permissions, platform, orange-line state, or other non-message context. +- Good row-derived metadata still belongs adjacent to the message, not inside the raw payload, when it depends on list position or neighboring messages. Today that includes render type, recycle type, separator linkage, username grouping, and reaction order. +- Current row-wrapper computations such as `showSendIndicator`, `showExplodingCountdown`, `hasUnfurlList`, `hasCoinFlip`, `textType`, `messageKey`, and popup eligibility should stay out of the stored message because they are not pure payload normalization. +- Popup-only work should stay local. Message popup item assembly, reaction tooltip user sorting, popup header formatting, git-push popup text generation, and journey-card action construction are transient and do not affect steady-state row mount cost. +- Presentation-dependent work should stay local. Timestamp formatting, orange-line time labels, image sizing, reply preview display text, and platform/team-role specific label decisions depend on UI context. +- The next normalization pass should focus on message fields that are effectively always consumed through one canonical display form, rather than widening the message shape with additional derived UI booleans. Primary files: diff --git a/shared/chat/conversation/messages/reactions-rows.tsx b/shared/chat/conversation/messages/reactions-rows.tsx index 667750eb3c84..89f32d5f4b32 100644 --- a/shared/chat/conversation/messages/reactions-rows.tsx +++ b/shared/chat/conversation/messages/reactions-rows.tsx @@ -1,4 +1,3 @@ -import * as Message from '@/constants/chat/message' import * as Kb from '@/common-adapters' import * as React from 'react' import EmojiRow from './emoji-row' @@ -15,12 +14,13 @@ 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, reactions} = p - const emojis = reactions?.size ? Message.getReactionOrder(reactions) : emptyEmojis + const {hasUnfurls, messageType, onReact, onReply, reactionOrder, reactions} = p + const emojis = reactionOrder.length ? reactionOrder : 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 71b35b04ba24..1652f24d9e5e 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -50,6 +50,7 @@ const messageShowsPopup = (type?: T.Chat.Message['type']) => // If there is no matching message treat it like a deleted const missingMessage = Chat.makeMessageDeleted({}) +const noReactionOrder: ReadonlyArray = [] type AuthorProps = { author: string @@ -251,6 +252,7 @@ const getCommonMessageData = ({ messageCenterOrdinal, ordinal, paymentStatusMap, + reactionOrderMap, unfurlPrompt, you, }: { @@ -261,6 +263,7 @@ const getCommonMessageData = ({ messageCenterOrdinal: ConvoState['messageCenterOrdinal'] ordinal: T.Chat.Ordinal paymentStatusMap: ReturnType['paymentStatusMap'] + reactionOrderMap: ConvoState['reactionOrderMap'] unfurlPrompt: ConvoState['unfurlPrompt'] you: string }) => { @@ -293,10 +296,10 @@ const getCommonMessageData = ({ : 'pending' const replyTo = message.type === 'text' ? message.replyTo : undefined const reactions = message.reactions + const reactionOrder = hasReactions ? reactionOrderMap.get(ordinal) ?? noReactionOrder : noReactionOrder const isExplodingMessage = message.type === 'text' || message.type === 'attachment' const showReplyTo = !!replyTo - const text = - message.type === 'text' ? (message.decoratedText?.stringValue() ?? message.text.stringValue()) : '' + const text = message.type === 'text' ? message.decoratedText.stringValue() : '' const showCenteredHighlight = isCenteredHighlight ?? !!( @@ -323,6 +326,7 @@ const getCommonMessageData = ({ isEditing: editing === ordinal, messageKey: isExplodingMessage ? Chat.getMessageKey(message) : '', reactions, + reactionOrder, replyTo, sendIndicatorFailed: (message.type === 'text' || message.type === 'attachment') && message.submitState === 'failed', @@ -376,6 +380,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, }) @@ -407,6 +412,7 @@ const useMessageDataWithMessage = (ordinal: T.Chat.Ordinal, isCenteredHighlight? messageCenterOrdinal: s.messageCenterOrdinal, ordinal, paymentStatusMap: Chat.useChatState.getState().paymentStatusMap, + reactionOrderMap: s.reactionOrderMap, unfurlPrompt: s.unfurlPrompt, you, }) @@ -497,6 +503,7 @@ type TSProps = { ordinal: T.Chat.Ordinal outboxID?: T.Chat.OutboxID popupAnchor: React.RefObject + reactionOrder: ReadonlyArray reactions?: T.Chat.Reactions sendIndicatorFailed: boolean sendIndicatorID: number @@ -544,7 +551,7 @@ function TextAndSiblings(p: TSProps) { isHighlighted, } = p const {showingPopup, ecrType, exploding, exploded, explodedBy, explodesAt, forceExplodingRetainer} = p - const {hasReactions, popupAnchor, reactions, sendIndicatorFailed, sendIndicatorID} = 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 @@ -602,6 +609,7 @@ function TextAndSiblings(p: TSProps) { messageType={type} ordinal={p.ordinal} outboxID={p.outboxID} + reactionOrder={reactionOrder} reactions={reactions} setEditing={p.setEditing} setReplyTo={p.setReplyTo} @@ -720,6 +728,7 @@ type BProps = { messageRetry: RowActions['messageRetry'] ordinal: T.Chat.Ordinal outboxID?: T.Chat.OutboxID + reactionOrder: ReadonlyArray reactions?: T.Chat.Reactions setEditing: RowActions['setEditing'] setReplyTo: RowActions['setReplyTo'] @@ -729,7 +738,7 @@ type BProps = { // reactions function BottomSide(p: BProps) { const {showingPopup, setShowingPicker, bottomChildren, canShowReactionsPopup, ecrType, hasBeenEdited} = p - const {exploding, failureDescription, hasReactions, hasUnfurlList, messageType, ordinal, reactions} = p + const {exploding, failureDescription, hasReactions, hasUnfurlList, messageType, ordinal, reactionOrder, reactions} = p const {messageDelete, messageRetry, outboxID, setEditing, setReplyTo, toggleMessageReaction} = p const onReact = (emoji: string) => { @@ -745,6 +754,7 @@ function BottomSide(p: BProps) { messageType={messageType} onReact={onReact} onReply={onReply} + reactionOrder={reactionOrder} reactions={reactions} /> ) : null @@ -933,6 +943,7 @@ export function WrapperMessage(p: WrapperMessageProps) { ordinal, outboxID, popupAnchor, + reactionOrder: mdata.reactionOrder, reactions, sendIndicatorFailed, sendIndicatorID, diff --git a/shared/chat/conversation/pinned-message.tsx b/shared/chat/conversation/pinned-message.tsx index f50cfc452062..e3a543b740a6 100644 --- a/shared/chat/conversation/pinned-message.tsx +++ b/shared/chat/conversation/pinned-message.tsx @@ -25,8 +25,7 @@ const PinnedMessage = function PinnedMessage() { const attachment: T.Chat.MessageAttachment | undefined = message?.type === 'attachment' && message.attachmentType === 'image' ? message : undefined const {previewHeight: imageHeight, previewURL: imageURL, previewWidth: imageWidth} = attachment ?? {} - const text = - type === 'text' ? (message?.decoratedText?.stringValue() ?? '') : message?.title || message?.fileName + const text = type === 'text' ? message.decoratedText.stringValue() : message?.title || message?.fileName const yourMessage = pinnerUsername === you const dismissUnpins = yourMessage || canAdminDelete diff --git a/shared/constants/chat/message.tsx b/shared/constants/chat/message.tsx index 34e0284f9c22..f3da1b8a9f9b 100644 --- a/shared/constants/chat/message.tsx +++ b/shared/constants/chat/message.tsx @@ -221,20 +221,27 @@ export const makeMessageDeleted = ( ...m, }) -export const makeMessageText = (m?: Partial): MessageTypes.MessageText => ({ - ...makeMessageCommon, - ...makeMessageExplodable, - inlinePaymentSuccessful: false, - isDeleteable: true, - isEditable: true, - mentionsAt: undefined, - mentionsChannel: 'none', - reactions: undefined, - text: noString, - type: 'text', - unfurls: undefined, - ...m, -}) +export const makeMessageText = (m?: Partial): MessageTypes.MessageText => { + const text = m?.text ?? noString + // Canonical display body for text rows. Keep raw text separately for edit/retry/quote flows. + const decoratedText = m?.decoratedText ?? text + return { + ...makeMessageCommon, + ...makeMessageExplodable, + inlinePaymentSuccessful: false, + isDeleteable: true, + isEditable: true, + mentionsAt: undefined, + mentionsChannel: 'none', + reactions: undefined, + text, + type: 'text', + unfurls: undefined, + ...m, + decoratedText, + text, + } +} export const makeMessageAttachment = ( m?: Partial diff --git a/shared/constants/types/chat/message.tsx b/shared/constants/types/chat/message.tsx index 3d8766ab0df5..5df13c18f1dd 100644 --- a/shared/constants/types/chat/message.tsx +++ b/shared/constants/types/chat/message.tsx @@ -153,6 +153,7 @@ export interface MessageText extends _MessageCommon { readonly mentionsChannel: MentionsChannel // this is actually a real Message type but with immutable the circular reference confuses TS, so only expose a small subset of the fields readonly replyTo?: MessageReplyTo + readonly decoratedText: HiddenString readonly text: HiddenString readonly paymentInfo?: ChatPaymentInfo // If null, we are waiting on this from the service, readonly unfurls: undefined | UnfurlMap diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index d3af476071df..4d171d3c9189 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -126,6 +126,7 @@ type ConvoStore = T.Immutable<{ participants: T.Chat.ParticipantInfo pendingJumpMessageID?: T.Chat.MessageID pendingOutboxToOrdinal: Map // messages waiting to be sent, + reactionOrderMap: Map> rowRecycleTypeMap: Map separatorMap: Map showUsernameMap: Map @@ -183,6 +184,7 @@ const initialConvoStore: ConvoStore = { participants: noParticipantInfo, pendingJumpMessageID: undefined, pendingOutboxToOrdinal: new Map(), + reactionOrderMap: new Map(), rowRecycleTypeMap: new Map(), separatorMap: new Map(), showUsernameMap: new Map(), @@ -661,6 +663,7 @@ const createSlice = for (const ordinal of ordinalsToRefresh) { const idx = findOrdinalIndex(messageOrdinals, ordinal) if (messageOrdinals[idx] !== ordinal) { + s.reactionOrderMap.delete(ordinal) s.rowRecycleTypeMap.delete(ordinal) s.separatorMap.delete(ordinal) s.showUsernameMap.delete(ordinal) @@ -670,6 +673,7 @@ const createSlice = const previousOrdinal = idx > 0 ? messageOrdinals[idx - 1]! : T.Chat.numberToOrdinal(0) const message = s.messageMap.get(ordinal) if (!message) { + s.reactionOrderMap.delete(ordinal) s.rowRecycleTypeMap.delete(ordinal) s.separatorMap.delete(ordinal) s.showUsernameMap.delete(ordinal) @@ -716,6 +720,11 @@ const createSlice = message: T.Chat.Message ) => { const renderType = s.messageTypeMap.get(ordinal) ?? Message.getMessageRenderType(message) + if (message.reactions?.size) { + s.reactionOrderMap.set(ordinal, Message.getReactionOrder(message.reactions)) + } else { + s.reactionOrderMap.delete(ordinal) + } const rowRecycleType = getRowRecycleType(message, renderType) if (rowRecycleType) { s.rowRecycleTypeMap.set(ordinal, rowRecycleType) @@ -2412,6 +2421,7 @@ const createSlice = s.messageMap.clear() s.messageOrdinals = undefined s.messageTypeMap.clear() + s.reactionOrderMap.clear() s.rowRecycleTypeMap.clear() s.separatorMap.clear() s.showUsernameMap.clear() From 359614cfc3495a17a7a46ae8c225b906f008b9cb Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 9 Apr 2026 11:14:36 -0400 Subject: [PATCH 04/59] WIP --- .../conversation/messages/reactions-rows.tsx | 9 ++++++--- .../conversation/messages/wrapper/wrapper.tsx | 16 ++-------------- shared/stores/convostate.tsx | 10 ---------- 3 files changed, 8 insertions(+), 27 deletions(-) diff --git a/shared/chat/conversation/messages/reactions-rows.tsx b/shared/chat/conversation/messages/reactions-rows.tsx index 89f32d5f4b32..96cc532c7cd6 100644 --- a/shared/chat/conversation/messages/reactions-rows.tsx +++ b/shared/chat/conversation/messages/reactions-rows.tsx @@ -1,3 +1,4 @@ +import * as Message from '@/constants/chat/message' import * as Kb from '@/common-adapters' import * as React from 'react' import EmojiRow from './emoji-row' @@ -14,13 +15,15 @@ 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 : emptyEmojis + const {hasUnfurls, messageType, onReact, onReply, reactions} = p + const emojis = React.useMemo( + () => (reactions?.size ? Message.getReactionOrder(reactions) : emptyEmojis), + [reactions] + ) return emojis.length === 0 ? null : ( diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index 1652f24d9e5e..401a9d046064 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -50,7 +50,6 @@ const messageShowsPopup = (type?: T.Chat.Message['type']) => // If there is no matching message treat it like a deleted const missingMessage = Chat.makeMessageDeleted({}) -const noReactionOrder: ReadonlyArray = [] type AuthorProps = { author: string @@ -252,7 +251,6 @@ const getCommonMessageData = ({ messageCenterOrdinal, ordinal, paymentStatusMap, - reactionOrderMap, unfurlPrompt, you, }: { @@ -263,7 +261,6 @@ const getCommonMessageData = ({ messageCenterOrdinal: ConvoState['messageCenterOrdinal'] ordinal: T.Chat.Ordinal paymentStatusMap: ReturnType['paymentStatusMap'] - reactionOrderMap: ConvoState['reactionOrderMap'] unfurlPrompt: ConvoState['unfurlPrompt'] you: string }) => { @@ -296,7 +293,6 @@ const getCommonMessageData = ({ : 'pending' const replyTo = message.type === 'text' ? message.replyTo : undefined const reactions = message.reactions - const reactionOrder = hasReactions ? reactionOrderMap.get(ordinal) ?? noReactionOrder : noReactionOrder const isExplodingMessage = message.type === 'text' || message.type === 'attachment' const showReplyTo = !!replyTo const text = message.type === 'text' ? message.decoratedText.stringValue() : '' @@ -326,7 +322,6 @@ const getCommonMessageData = ({ isEditing: editing === ordinal, messageKey: isExplodingMessage ? Chat.getMessageKey(message) : '', reactions, - reactionOrder, replyTo, sendIndicatorFailed: (message.type === 'text' || message.type === 'attachment') && message.submitState === 'failed', @@ -380,7 +375,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, }) @@ -412,7 +406,6 @@ const useMessageDataWithMessage = (ordinal: T.Chat.Ordinal, isCenteredHighlight? messageCenterOrdinal: s.messageCenterOrdinal, ordinal, paymentStatusMap: Chat.useChatState.getState().paymentStatusMap, - reactionOrderMap: s.reactionOrderMap, unfurlPrompt: s.unfurlPrompt, you, }) @@ -503,7 +496,6 @@ type TSProps = { ordinal: T.Chat.Ordinal outboxID?: T.Chat.OutboxID popupAnchor: React.RefObject - reactionOrder: ReadonlyArray reactions?: T.Chat.Reactions sendIndicatorFailed: boolean sendIndicatorID: number @@ -551,7 +543,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 @@ -609,7 +601,6 @@ function TextAndSiblings(p: TSProps) { messageType={type} ordinal={p.ordinal} outboxID={p.outboxID} - reactionOrder={reactionOrder} reactions={reactions} setEditing={p.setEditing} setReplyTo={p.setReplyTo} @@ -728,7 +719,6 @@ type BProps = { messageRetry: RowActions['messageRetry'] ordinal: T.Chat.Ordinal outboxID?: T.Chat.OutboxID - reactionOrder: ReadonlyArray reactions?: T.Chat.Reactions setEditing: RowActions['setEditing'] setReplyTo: RowActions['setReplyTo'] @@ -738,7 +728,7 @@ type BProps = { // reactions function BottomSide(p: BProps) { const {showingPopup, setShowingPicker, bottomChildren, canShowReactionsPopup, ecrType, hasBeenEdited} = p - const {exploding, failureDescription, hasReactions, hasUnfurlList, messageType, ordinal, reactionOrder, reactions} = p + const {exploding, failureDescription, hasReactions, hasUnfurlList, messageType, ordinal, reactions} = p const {messageDelete, messageRetry, outboxID, setEditing, setReplyTo, toggleMessageReaction} = p const onReact = (emoji: string) => { @@ -754,7 +744,6 @@ function BottomSide(p: BProps) { messageType={messageType} onReact={onReact} onReply={onReply} - reactionOrder={reactionOrder} reactions={reactions} /> ) : null @@ -943,7 +932,6 @@ export function WrapperMessage(p: WrapperMessageProps) { ordinal, outboxID, popupAnchor, - reactionOrder: mdata.reactionOrder, reactions, sendIndicatorFailed, sendIndicatorID, diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 4d171d3c9189..d3af476071df 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -126,7 +126,6 @@ type ConvoStore = T.Immutable<{ participants: T.Chat.ParticipantInfo pendingJumpMessageID?: T.Chat.MessageID pendingOutboxToOrdinal: Map // messages waiting to be sent, - reactionOrderMap: Map> rowRecycleTypeMap: Map separatorMap: Map showUsernameMap: Map @@ -184,7 +183,6 @@ const initialConvoStore: ConvoStore = { participants: noParticipantInfo, pendingJumpMessageID: undefined, pendingOutboxToOrdinal: new Map(), - reactionOrderMap: new Map(), rowRecycleTypeMap: new Map(), separatorMap: new Map(), showUsernameMap: new Map(), @@ -663,7 +661,6 @@ const createSlice = for (const ordinal of ordinalsToRefresh) { const idx = findOrdinalIndex(messageOrdinals, ordinal) if (messageOrdinals[idx] !== ordinal) { - s.reactionOrderMap.delete(ordinal) s.rowRecycleTypeMap.delete(ordinal) s.separatorMap.delete(ordinal) s.showUsernameMap.delete(ordinal) @@ -673,7 +670,6 @@ const createSlice = const previousOrdinal = idx > 0 ? messageOrdinals[idx - 1]! : T.Chat.numberToOrdinal(0) const message = s.messageMap.get(ordinal) if (!message) { - s.reactionOrderMap.delete(ordinal) s.rowRecycleTypeMap.delete(ordinal) s.separatorMap.delete(ordinal) s.showUsernameMap.delete(ordinal) @@ -720,11 +716,6 @@ const createSlice = message: T.Chat.Message ) => { const renderType = s.messageTypeMap.get(ordinal) ?? Message.getMessageRenderType(message) - if (message.reactions?.size) { - s.reactionOrderMap.set(ordinal, Message.getReactionOrder(message.reactions)) - } else { - s.reactionOrderMap.delete(ordinal) - } const rowRecycleType = getRowRecycleType(message, renderType) if (rowRecycleType) { s.rowRecycleTypeMap.set(ordinal, rowRecycleType) @@ -2421,7 +2412,6 @@ const createSlice = s.messageMap.clear() s.messageOrdinals = undefined s.messageTypeMap.clear() - s.reactionOrderMap.clear() s.rowRecycleTypeMap.clear() s.separatorMap.clear() s.showUsernameMap.clear() From b5c5be77f0ea0a7ce381a3edf9c3e3a6f9ec3ed3 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 9 Apr 2026 11:34:45 -0400 Subject: [PATCH 05/59] WIP --- .../conversation/messages/wrapper/wrapper.tsx | 3 +- shared/chat/conversation/pinned-message.tsx | 3 +- shared/constants/chat/message.tsx | 35 ++++++++----------- shared/constants/types/chat/message.tsx | 2 +- 4 files changed, 19 insertions(+), 24 deletions(-) diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index 401a9d046064..71b35b04ba24 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -295,7 +295,8 @@ const getCommonMessageData = ({ const reactions = message.reactions const isExplodingMessage = message.type === 'text' || message.type === 'attachment' const showReplyTo = !!replyTo - const text = message.type === 'text' ? message.decoratedText.stringValue() : '' + const text = + message.type === 'text' ? (message.decoratedText?.stringValue() ?? message.text.stringValue()) : '' const showCenteredHighlight = isCenteredHighlight ?? !!( diff --git a/shared/chat/conversation/pinned-message.tsx b/shared/chat/conversation/pinned-message.tsx index e3a543b740a6..f50cfc452062 100644 --- a/shared/chat/conversation/pinned-message.tsx +++ b/shared/chat/conversation/pinned-message.tsx @@ -25,7 +25,8 @@ const PinnedMessage = function PinnedMessage() { const attachment: T.Chat.MessageAttachment | undefined = message?.type === 'attachment' && message.attachmentType === 'image' ? message : undefined const {previewHeight: imageHeight, previewURL: imageURL, previewWidth: imageWidth} = attachment ?? {} - const text = type === 'text' ? message.decoratedText.stringValue() : message?.title || message?.fileName + const text = + type === 'text' ? (message?.decoratedText?.stringValue() ?? '') : message?.title || message?.fileName const yourMessage = pinnerUsername === you const dismissUnpins = yourMessage || canAdminDelete diff --git a/shared/constants/chat/message.tsx b/shared/constants/chat/message.tsx index f3da1b8a9f9b..34e0284f9c22 100644 --- a/shared/constants/chat/message.tsx +++ b/shared/constants/chat/message.tsx @@ -221,27 +221,20 @@ export const makeMessageDeleted = ( ...m, }) -export const makeMessageText = (m?: Partial): MessageTypes.MessageText => { - const text = m?.text ?? noString - // Canonical display body for text rows. Keep raw text separately for edit/retry/quote flows. - const decoratedText = m?.decoratedText ?? text - return { - ...makeMessageCommon, - ...makeMessageExplodable, - inlinePaymentSuccessful: false, - isDeleteable: true, - isEditable: true, - mentionsAt: undefined, - mentionsChannel: 'none', - reactions: undefined, - text, - type: 'text', - unfurls: undefined, - ...m, - decoratedText, - text, - } -} +export const makeMessageText = (m?: Partial): MessageTypes.MessageText => ({ + ...makeMessageCommon, + ...makeMessageExplodable, + inlinePaymentSuccessful: false, + isDeleteable: true, + isEditable: true, + mentionsAt: undefined, + mentionsChannel: 'none', + reactions: undefined, + text: noString, + type: 'text', + unfurls: undefined, + ...m, +}) export const makeMessageAttachment = ( m?: Partial diff --git a/shared/constants/types/chat/message.tsx b/shared/constants/types/chat/message.tsx index 5df13c18f1dd..6c216ef2906a 100644 --- a/shared/constants/types/chat/message.tsx +++ b/shared/constants/types/chat/message.tsx @@ -153,7 +153,7 @@ export interface MessageText extends _MessageCommon { readonly mentionsChannel: MentionsChannel // this is actually a real Message type but with immutable the circular reference confuses TS, so only expose a small subset of the fields readonly replyTo?: MessageReplyTo - readonly decoratedText: HiddenString + readonly decoratedText?: HiddenString readonly text: HiddenString readonly paymentInfo?: ChatPaymentInfo // If null, we are waiting on this from the service, readonly unfurls: undefined | UnfurlMap From dd971e3b1538cf0321438d15fc20342f06bc489c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 9 Apr 2026 11:35:24 -0400 Subject: [PATCH 06/59] WIP --- shared/constants/types/chat/message.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/shared/constants/types/chat/message.tsx b/shared/constants/types/chat/message.tsx index 6c216ef2906a..0b9c6552d19a 100644 --- a/shared/constants/types/chat/message.tsx +++ b/shared/constants/types/chat/message.tsx @@ -140,7 +140,6 @@ export interface MessageReplyTo extends _MessageCommon { export interface MessageText extends _MessageCommon { readonly botUsername?: string - readonly decoratedText?: HiddenString readonly exploded: boolean readonly explodedBy: string // only if 'explode now' happened, readonly exploding: boolean From b34db322c6a7233523bc6e98e0cfd0d232a81ba4 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 9 Apr 2026 11:35:54 -0400 Subject: [PATCH 07/59] WIP --- shared/constants/types/chat/message.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/constants/types/chat/message.tsx b/shared/constants/types/chat/message.tsx index 0b9c6552d19a..3d8766ab0df5 100644 --- a/shared/constants/types/chat/message.tsx +++ b/shared/constants/types/chat/message.tsx @@ -140,6 +140,7 @@ export interface MessageReplyTo extends _MessageCommon { export interface MessageText extends _MessageCommon { readonly botUsername?: string + readonly decoratedText?: HiddenString readonly exploded: boolean readonly explodedBy: string // only if 'explode now' happened, readonly exploding: boolean @@ -152,7 +153,6 @@ export interface MessageText extends _MessageCommon { readonly mentionsChannel: MentionsChannel // this is actually a real Message type but with immutable the circular reference confuses TS, so only expose a small subset of the fields readonly replyTo?: MessageReplyTo - readonly decoratedText?: HiddenString readonly text: HiddenString readonly paymentInfo?: ChatPaymentInfo // If null, we are waiting on this from the service, readonly unfurls: undefined | UnfurlMap From fe8cd8cc60c93a1a0426e0a4314984f92f9e19b3 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 9 Apr 2026 11:42:40 -0400 Subject: [PATCH 08/59] WIP --- plans/chat-refactor.md | 28 ++++++++-- .../conversation/list-area/index.desktop.tsx | 10 ++-- .../conversation/list-area/index.native.tsx | 52 ++++++++++++------- shared/perf/PERF-TESTING.md | 18 +++++-- 4 files changed, 77 insertions(+), 31 deletions(-) diff --git a/plans/chat-refactor.md b/plans/chat-refactor.md index 4f67ae4e8d09..b4ba644363c8 100644 --- a/plans/chat-refactor.md +++ b/plans/chat-refactor.md @@ -104,7 +104,7 @@ Primary files: - [x] Remove avoidable array cloning / reversing in the hottest list path. - [x] Replace effect-driven recycle subtype reporting with data available before or during row render. - [x] Re-check list item type stability after workstreams 1 and 3 land. -- [ ] Keep scroll position and centered-message behavior unchanged. +- [x] Keep scroll position and centered-message behavior unchanged. Primary files: @@ -114,9 +114,9 @@ Primary files: ### 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. +- [x] Add or improve lightweight profiling hooks where they help compare before/after behavior. +- [x] Define a manual verification checklist for initial thread mount, new incoming message, placeholder resolution, reactions, edits, and centered jumps. +- [x] Capture follow-up profiling notes after each landed workstream. Primary files: @@ -124,6 +124,26 @@ Primary files: - `shared/chat/conversation/list-area/index.desktop.tsx` - `shared/perf/*` +Decision note: + +- Keep `MessageList` and `Msg-{type}` as the main thread-level React Profiler comparators. +- Add a desktop `MessageWaypoint` profiler so chunked waypoint rendering shows up separately from full-list commits during scroll and centered-jump regressions. + +Manual verification checklist: + +1. Initial thread mount: open an already-active conversation and confirm the list lands at the bottom when there is no centered target, without a slow drift after messages finish loading. +2. Older-message pagination: scroll upward far enough to load history and confirm the visible message stays anchored instead of jumping when older rows are inserted above it. +3. New incoming message while pinned: with the list at the bottom, receive or send a new message and confirm the list stays pinned to the latest message on both desktop and native. +4. Placeholder resolution: send a message that first appears as a placeholder/pending row and confirm it resolves in place without row reuse glitches or scroll jumps. +5. Reactions and edits: add/remove a reaction and edit a message, then confirm only the affected row updates and desktop still scrolls to the editing message when edit mode opens. +6. Centered jumps: use thread search or another centered-message entry point and confirm the requested message is centered/highlighted without breaking later scroll anchoring. + +Profiling notes: + +- Workstream 5: compare native `MessageList` plus hot `Msg-{type}` buckets with `yarn maestro-test-perf-thread`, and compare desktop thread runs with `yarn desktop-perf-thread` while spot-checking edit-jump and centered-jump flows. +- Workstream 6: use the new desktop `MessageWaypoint` profiler to separate waypoint chunk commits from whole-list commits when evaluating scroll or search-jump changes. +- This machine does not have `node_modules`, so profiling and manual validation need to happen in another environment with the repo toolchain installed. + ## Recommended Order 1. Workstream 1: Row Renderer Boundary diff --git a/shared/chat/conversation/list-area/index.desktop.tsx b/shared/chat/conversation/list-area/index.desktop.tsx index ed591fb61c97..ba9d3df80fb6 100644 --- a/shared/chat/conversation/list-area/index.desktop.tsx +++ b/shared/chat/conversation/list-area/index.desktop.tsx @@ -341,7 +341,7 @@ const useScrolling = (p: { const editingOrdinal = Chat.useChatUIContext(s => s.editing) const lastEditingOrdinalRef = React.useRef(0) React.useEffect(() => { - if (lastEditingOrdinalRef.current !== editingOrdinal) return + if (lastEditingOrdinalRef.current === editingOrdinal) return lastEditingOrdinalRef.current = editingOrdinal if (!editingOrdinal) return const idx = messageOrdinals.indexOf(editingOrdinal) @@ -698,9 +698,11 @@ function Content(p: ContentType) { const {id, ordinals, rowRenderer, ref} = p // Apply data-key to the dom node so we can search for editing messages return ( -
- {ordinals.map((o): React.ReactNode => rowRenderer(o))} -
+ +
+ {ordinals.map((o): React.ReactNode => rowRenderer(o))} +
+
) } diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 43e20999aa97..6874f836951a 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -1,3 +1,4 @@ +import * as C from '@/constants' import * as Chat from '@/stores/chat' import * as T from '@/constants/types' import * as Hooks from './hooks' @@ -101,22 +102,32 @@ const ConversationList = function ConversationList() { ) : null - const conversationIDKey = Chat.useChatContext(s => s.id) - - const loaded = Chat.useChatContext(s => s.loaded) - 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 rowRecycleTypeMap = Chat.useChatContext(s => s.rowRecycleTypeMap) + const listData = Chat.useChatContext( + C.useShallow(s => { + const {id: conversationIDKey, loaded, messageCenterOrdinal} = s + const centeredOrdinal = messageCenterOrdinal?.ordinal ?? T.Chat.numberToOrdinal(-1) + const centeredHighlightOrdinal = + messageCenterOrdinal && messageCenterOrdinal.highlightMode !== 'none' + ? messageCenterOrdinal.ordinal + : T.Chat.numberToOrdinal(-1) + return { + centeredHighlightOrdinal, + centeredOrdinal, + conversationIDKey, + loaded, + messageOrdinals: s.messageOrdinals ?? noOrdinals, + } + }) + ) + const {centeredHighlightOrdinal, centeredOrdinal, conversationIDKey, loaded} = listData - const messageOrdinals = useInvertedMessageOrdinals(_messageOrdinals) + const messageOrdinals = useInvertedMessageOrdinals(listData.messageOrdinals) const listRef = React.useRef |*/ FlatList | null>(null) + const conversationIDKeyRef = React.useRef(conversationIDKey) + React.useEffect(() => { + conversationIDKeyRef.current = conversationIDKey + }, [conversationIDKey]) const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) const keyExtractor = (ordinal: ItemType) => { return String(ordinal) @@ -137,14 +148,15 @@ const ConversationList = function ConversationList() { const numOrdinals = messageOrdinals.length - const getItemType = (ordinal: T.Chat.Ordinal) => { - if (!ordinal) { - return 'null' + const [getItemType] = React.useState( + () => (ordinal: T.Chat.Ordinal) => { + if (!ordinal) { + return 'null' + } + const convoState = Chat.getConvoState(conversationIDKeyRef.current) + return convoState.rowRecycleTypeMap.get(ordinal) ?? convoState.messageTypeMap.get(ordinal) ?? 'text' } - const recycled = rowRecycleTypeMap.get(ordinal) - if (recycled) return recycled - return messageTypeMap.get(ordinal) ?? 'text' - } + ) const {scrollToCentered, scrollToBottom, onEndReached} = useScrolling({ centeredOrdinal, diff --git a/shared/perf/PERF-TESTING.md b/shared/perf/PERF-TESTING.md index 5766acf33bf8..0151eeaae4fe 100644 --- a/shared/perf/PERF-TESTING.md +++ b/shared/perf/PERF-TESTING.md @@ -66,9 +66,10 @@ Components currently wrapped with ``: |----|----------|---------------| | `Inbox` | `chat/inbox/index.native.tsx` | Full inbox container | | `InboxRow-{type}` | `chat/inbox/index.native.tsx` | Each inbox row (small, big, bigHeader, divider, teamBuilder) | -| `Conversation` | `chat/conversation/normal/index.native.tsx` | Full conversation screen | -| `MessageList` | `chat/conversation/list-area/index.native.tsx` | Message list FlatList container | -| `Msg-{type}` | `chat/conversation/list-area/index.native.tsx` | Each message (text, attachment, system*, etc.) | +| `Conversation` | `chat/conversation/normal/index.native.tsx`, `chat/conversation/normal/index.desktop.tsx` | Full conversation screen | +| `MessageList` | `chat/conversation/list-area/index.native.tsx`, `chat/conversation/list-area/index.desktop.tsx` | Message list container | +| `MessageWaypoint` | `chat/conversation/list-area/index.desktop.tsx` | Desktop waypoint chunk content rendered inside the scrolling thread | +| `Msg-{type}` | `chat/conversation/messages/wrapper/index.tsx` | Each message row (text, attachment, system*, etc.) | | `ChatInput` | `chat/conversation/input-area/container.tsx` | Chat input area | | `TeamsList` | `teams/main/index.tsx` | Full teams list container | | `TeamRow` | `teams/main/index.tsx` | Each team row | @@ -129,6 +130,17 @@ All output goes to `shared/perf/output/` (gitignored): | `maestro-fps.json` | FPS data from PerfFPSMonitor | | `maestro.log` | Maestro test console output | +## Chat Thread Regression Checklist + +Use this alongside automated thread perf runs when changing `chat/conversation/list-area/*` or row rendering: + +1. Open an existing conversation with no centered target and confirm it lands at the latest message without drifting after load. +2. Scroll upward until older messages paginate in and confirm the visible anchor does not jump. +3. While pinned to bottom, send or receive a message and confirm the list remains pinned to the latest row. +4. Let a pending/placeholder message resolve and confirm it swaps in place without reuse glitches. +5. Add/remove a reaction and open edit mode on a message, then confirm the correct row updates and desktop scrolls to the editing message. +6. Trigger a centered jump from thread search and confirm the target row is centered/highlighted without breaking later scrolling. + ### Adding New Flows Create a YAML file in `shared/.maestro/performance/`. Example structure: From 19fb0692a2cd1f226887d9c7d56572177c254186 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 9 Apr 2026 12:19:33 -0400 Subject: [PATCH 09/59] move search out of store --- .../references/on-device-ai/SKILL.md | 75 ---- plans/chat-refactor.md | 165 ------- .../input-area/location-popup.native.tsx | 24 +- shared/chat/inbox-and-conversation.tsx | 15 +- shared/chat/inbox-search/index.tsx | 18 +- shared/chat/inbox/defer-loading.tsx | 7 +- shared/chat/inbox/filter-row.tsx | 11 +- shared/chat/inbox/search-row.tsx | 39 +- shared/chat/inbox/search-state.test.tsx | 30 ++ shared/chat/inbox/search-state.tsx | 407 ++++++++++++++++++ shared/chat/inbox/use-inbox-state.tsx | 5 +- shared/constants/init/index.native.tsx | 3 +- shared/constants/init/shared.tsx | 6 - shared/stores/chat.tsx | 348 --------------- shared/stores/tests/chat.test.ts | 27 -- .../references/store-checklist.md | 2 + 16 files changed, 508 insertions(+), 674 deletions(-) delete mode 100644 .agents/skills/react-native-best-practices/references/on-device-ai/SKILL.md delete mode 100644 plans/chat-refactor.md create mode 100644 shared/chat/inbox/search-state.test.tsx create mode 100644 shared/chat/inbox/search-state.tsx diff --git a/.agents/skills/react-native-best-practices/references/on-device-ai/SKILL.md b/.agents/skills/react-native-best-practices/references/on-device-ai/SKILL.md deleted file mode 100644 index d8cc8c311ce7..000000000000 --- a/.agents/skills/react-native-best-practices/references/on-device-ai/SKILL.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -name: on-device-ai -description: "Best practices for building on-device AI features in React Native using React Native ExecuTorch. Use when the user wants to add AI to a mobile app without cloud dependencies: AI chatbots and assistants, image classification, object detection, text recognition and document parsing (OCR), style transfer, image generation, speech-to-text transcription, text-to-speech synthesis, voice activity detection, semantic search with embeddings, real-time camera AI with VisionCamera, or vision-language image understanding. Also use when the user mentions offline AI, on-device ML, privacy-preserving AI, reducing cloud API costs or latency, running models locally on mobile, or downloading and managing ML models. Covers react-native-executorch hooks (useLLM, useClassification, useObjectDetection, useOCR, useSemanticSegmentation, useInstanceSegmentation, useStyleTransfer, useTextToImage, useImageEmbeddings, useSpeechToText, useTextToSpeech, useVAD, useTextEmbeddings, useExecutorchModule), tool calling, structured output, VLMs, model loading, and resource management." ---- - -# On-Device AI - -Software Mansion's production patterns for on-device AI in React Native using [React Native ExecuTorch](https://github.com/software-mansion/react-native-executorch). - -Load at most one reference file per question. For hook API signatures, model constants, and configuration options, webfetch the relevant page from the official docs at `https://docs.swmansion.com/react-native-executorch/docs/`. - -## Decision Tree - -Pick the right hook based on the AI task. - -``` -What AI task does the feature need? -│ -├── Text generation, chatbot, or reasoning? -│ └── useLLM → see llm.md -│ ├── Text-only chat → standard useLLM -│ ├── Vision-language (image+text) → useLLM with VLM model -│ ├── Tool calling → configure with toolsConfig -│ └── Structured JSON output → getStructuredOutputPrompt -│ -├── Understanding images? -│ ├── What's in this image? → useClassification → see vision.md -│ ├── Where are objects? → useObjectDetection → see vision.md -│ ├── Read text from image? → useOCR / useVerticalOCR → see vision.md -│ ├── Segment by class? → useSemanticSegmentation → see vision.md -│ ├── Segment per-instance? → useInstanceSegmentation → see vision.md -│ ├── Apply artistic style? → useStyleTransfer → see vision.md -│ ├── Generate image from text? → useTextToImage → see vision.md -│ └── Embed image as vector? → useImageEmbeddings → see vision.md -│ -├── Speech or audio processing? -│ ├── Transcribe speech → useSpeechToText → see speech.md -│ ├── Synthesize speech → useTextToSpeech → see speech.md -│ └── Detect speech segments → useVAD → see speech.md -│ -├── Text utilities? -│ ├── Convert text to vectors → useTextEmbeddings → see vision.md -│ └── Count tokens → useTokenizer -│ -├── Real-time camera processing? -│ └── runOnFrame with VisionCamera v5 → see vision.md -│ -└── Custom model (.pte)? - └── useExecutorchModule → see setup.md -``` - -## Critical Rules - -- **Call `initExecutorch()` before any other API.** You must initialize the library with a resource fetcher adapter at the entry point of your app. Without it, all hooks throw `ResourceFetcherAdapterNotInitialized`. - -- **Always check `isReady` before calling `forward` or `generate`.** Hooks load models asynchronously. Calling inference methods before the model is ready throws `ModuleNotLoaded`. - -- **Interrupt LLM generation before unmounting the component.** Unmounting while `isGenerating` is true causes a crash. Call `llm.interrupt()` and wait for `isGenerating` to become false before navigating away. - -- **Use quantized models on mobile.** Full-precision models consume too much memory for most devices. React Native ExecuTorch ships quantized variants for all supported models. - -- **Audio for speech-to-text must be 16kHz mono.** Mismatched sample rates produce garbled transcriptions silently. - -- **Audio from text-to-speech is 24kHz.** Create the `AudioContext` with `{ sampleRate: 24000 }` for playback. - -- **Set `pixelFormat: 'rgb'` and `orientationSource="device"` for VisionCamera frame processing.** The default `yuv` format produces incorrect results with ExecuTorch vision models. Missing `orientationSource` causes misaligned bounding boxes and masks. - -## References - -| File | When to read | -|------|-------------| -| `llm.md` | LLM chat (functional and managed), tool calling, structured output, token batching, context strategy, vision-language models (VLM), model selection, generation config | -| `vision.md` | Image classification, object detection, OCR, semantic segmentation, instance segmentation, style transfer, text-to-image, image/text embeddings, VisionCamera real-time frame processing with `runOnFrame` | -| `speech.md` | Speech-to-text (batch and streaming transcription with timestamps), text-to-speech (batch and streaming synthesis, phoneme input), voice activity detection, audio format requirements | -| `setup.md` | Installation with `initExecutorch`, resource fetcher adapters, model loading strategies (bundled, remote, local), download management, error handling with `RnExecutorchError`, custom models with `useExecutorchModule`, Metro config for `.pte` files | diff --git a/plans/chat-refactor.md b/plans/chat-refactor.md deleted file mode 100644 index b4ba644363c8..000000000000 --- a/plans/chat-refactor.md +++ /dev/null @@ -1,165 +0,0 @@ -# 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. -- 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 - -### 1. Row Renderer Boundary - -- [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: - -- `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 - -- [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. -- [x] Re-evaluate whether some derived metadata should live in store state at all. -- [x] 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. - -Decision note: - -- Cache per-row reaction order in convo-store derived metadata so reaction chips do not resort on every render. -- Keep separator orange-line timing/render decisions local for now because they still depend on live orange-line context and platform-specific presentation. - -Audit outcome: - -- If a field is message-local, deterministic, and the raw form has no semantic consumers, normalize it once when we build or merge the stored message instead of repeatedly massaging it during render. -- Do not add separate UI-derived duplicates to the stored message when the result depends on current user, convo meta, team permissions, platform, orange-line state, or other non-message context. -- Good row-derived metadata still belongs adjacent to the message, not inside the raw payload, when it depends on list position or neighboring messages. Today that includes render type, recycle type, separator linkage, username grouping, and reaction order. -- Current row-wrapper computations such as `showSendIndicator`, `showExplodingCountdown`, `hasUnfurlList`, `hasCoinFlip`, `textType`, `messageKey`, and popup eligibility should stay out of the stored message because they are not pure payload normalization. -- Popup-only work should stay local. Message popup item assembly, reaction tooltip user sorting, popup header formatting, git-push popup text generation, and journey-card action construction are transient and do not affect steady-state row mount cost. -- Presentation-dependent work should stay local. Timestamp formatting, orange-line time labels, image sizing, reply preview display text, and platform/team-role specific label decisions depend on UI context. -- The next normalization pass should focus on message fields that are effectively always consumed through one canonical display form, rather than widening the message shape with additional derived UI booleans. - -Primary files: - -- `shared/stores/convostate.tsx` -- `shared/chat/conversation/messages/separator.tsx` -- `shared/chat/conversation/messages/reactions-rows.tsx` - -### 3. Row Subscription Consolidation - -- [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. -- [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: - -- `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 - -- [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. -- [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: - -- `shared/stores/convostate.tsx` -- `shared/chat/conversation/*` - -### 5. List Data Stability And Recycling - -- [x] Remove avoidable array cloning / reversing in the hottest list path. -- [x] Replace effect-driven recycle subtype reporting with data available before or during row render. -- [x] Re-check list item type stability after workstreams 1 and 3 land. -- [x] 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 - -- [x] Add or improve lightweight profiling hooks where they help compare before/after behavior. -- [x] Define a manual verification checklist for initial thread mount, new incoming message, placeholder resolution, reactions, edits, and centered jumps. -- [x] 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/*` - -Decision note: - -- Keep `MessageList` and `Msg-{type}` as the main thread-level React Profiler comparators. -- Add a desktop `MessageWaypoint` profiler so chunked waypoint rendering shows up separately from full-list commits during scroll and centered-jump regressions. - -Manual verification checklist: - -1. Initial thread mount: open an already-active conversation and confirm the list lands at the bottom when there is no centered target, without a slow drift after messages finish loading. -2. Older-message pagination: scroll upward far enough to load history and confirm the visible message stays anchored instead of jumping when older rows are inserted above it. -3. New incoming message while pinned: with the list at the bottom, receive or send a new message and confirm the list stays pinned to the latest message on both desktop and native. -4. Placeholder resolution: send a message that first appears as a placeholder/pending row and confirm it resolves in place without row reuse glitches or scroll jumps. -5. Reactions and edits: add/remove a reaction and edit a message, then confirm only the affected row updates and desktop still scrolls to the editing message when edit mode opens. -6. Centered jumps: use thread search or another centered-message entry point and confirm the requested message is centered/highlighted without breaking later scroll anchoring. - -Profiling notes: - -- Workstream 5: compare native `MessageList` plus hot `Msg-{type}` buckets with `yarn maestro-test-perf-thread`, and compare desktop thread runs with `yarn desktop-perf-thread` while spot-checking edit-jump and centered-jump flows. -- Workstream 6: use the new desktop `MessageWaypoint` profiler to separate waypoint chunk commits from whole-list commits when evaluating scroll or search-jump changes. -- This machine does not have `node_modules`, so profiling and manual validation need to happen in another environment with the repo toolchain installed. - -## 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." diff --git a/shared/chat/conversation/input-area/location-popup.native.tsx b/shared/chat/conversation/input-area/location-popup.native.tsx index b6d6273a1adc..dd6d1e4cc65e 100644 --- a/shared/chat/conversation/input-area/location-popup.native.tsx +++ b/shared/chat/conversation/input-area/location-popup.native.tsx @@ -9,6 +9,7 @@ import LocationMap from '@/chat/location-map' import {useCurrentUserState} from '@/stores/current-user' import {requestLocationPermission} from '@/util/platform-specific' import * as ExpoLocation from 'expo-location' +import {ignorePromise} from '@/constants/utils' const LocationButton = (props: {disabled: boolean; label: string; onClick: () => void; subLabel?: string; primary?: boolean}) => ( ) -const useWatchPosition = (conversationIDKey: T.Chat.ConversationIDKey) => { - const updateLastCoord = Chat.useChatState(s => s.dispatch.updateLastCoord) +const updateLocation = (coord: T.Chat.Coordinate) => { + const f = async () => { + const {accuracy, lat, lon} = coord + await T.RPCChat.localLocationUpdateRpcPromise({coord: {accuracy, lat, lon}}) + } + ignorePromise(f()) +} + +const useWatchPosition = ( + conversationIDKey: T.Chat.ConversationIDKey, + setLocation: React.Dispatch> +) => { const setCommandStatusInfo = Chat.useChatUIContext(s => s.dispatch.setCommandStatusInfo) React.useEffect(() => { let unsub = () => {} @@ -43,7 +54,8 @@ const useWatchPosition = (conversationIDKey: T.Chat.ConversationIDKey) => { lat: location.coords.latitude, lon: location.coords.longitude, } - updateLastCoord(coord) + setLocation(coord) + updateLocation(coord) } ) unsub = () => sub.remove() @@ -62,14 +74,14 @@ const useWatchPosition = (conversationIDKey: T.Chat.ConversationIDKey) => { return () => { unsub() } - }, [conversationIDKey, updateLastCoord, setCommandStatusInfo]) + }, [conversationIDKey, setCommandStatusInfo, setLocation]) } const LocationPopup = () => { const conversationIDKey = Chat.useChatContext(s => s.id) const username = useCurrentUserState(s => s.username) const httpSrv = useConfigState(s => s.httpSrv) - const location = Chat.useChatState(s => s.lastCoord) + const [location, setLocation] = React.useState() const locationDenied = Chat.useChatUIContext( s => s.commandStatus?.displayType === T.RPCChat.UICommandStatusDisplayTyp.error ) @@ -85,7 +97,7 @@ const LocationPopup = () => { sendMessage(duration ? `/location live ${duration}` : '/location') } - useWatchPosition(conversationIDKey) + useWatchPosition(conversationIDKey, setLocation) const width = Math.ceil(Kb.Styles.dimensionWidth) const height = Math.ceil(Kb.Styles.dimensionHeight - 320) diff --git a/shared/chat/inbox-and-conversation.tsx b/shared/chat/inbox-and-conversation.tsx index 9ecd83505100..32cad3eca94c 100644 --- a/shared/chat/inbox-and-conversation.tsx +++ b/shared/chat/inbox-and-conversation.tsx @@ -9,15 +9,16 @@ 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' +import {InboxSearchProvider, useInboxSearchState} from './inbox/search-state' type Props = ThreadSearchRouteProps & { conversationIDKey?: T.Chat.ConversationIDKey infoPanel?: {tab?: Panel} } -function InboxAndConversation(props: Props) { +function InboxAndConversationBody(props: Props) { const conversationIDKey = props.conversationIDKey ?? Chat.noConversationIDKey - const inboxSearch = Chat.useChatState(s => s.inboxSearch) + const isSearching = useInboxSearchState(s => s.enabled) const infoPanel = props.infoPanel const validConvoID = conversationIDKey && conversationIDKey !== Chat.noConversationIDKey const seenValidCIDRef = React.useRef(validConvoID ? conversationIDKey : '') @@ -43,7 +44,7 @@ function InboxAndConversation(props: Props) { - {!C.isTablet && inboxSearch ? ( + {!C.isTablet && isSearching ? ( ) : ( @@ -62,6 +63,14 @@ function InboxAndConversation(props: Props) { ) } +function InboxAndConversation(props: Props) { + return ( + + + + ) +} + const styles = Kb.Styles.styleSheetCreate( () => ({ diff --git a/shared/chat/inbox-search/index.tsx b/shared/chat/inbox-search/index.tsx index edef226c2db8..f1ad02ff140a 100644 --- a/shared/chat/inbox-search/index.tsx +++ b/shared/chat/inbox-search/index.tsx @@ -11,6 +11,7 @@ import type * as T from '@/constants/types' import {Bot} from '../conversation/info-panel/bot' import {TeamAvatar} from '../avatars' import {inboxWidth} from '../inbox/row/sizes' +import {inboxSearchMaxTextMessages, useInboxSearchState} from '../inbox/search-state' type OwnProps = {header?: React.ReactElement | null} @@ -42,19 +43,13 @@ type OpenTeamResult = { type Item = NameResult | TextResult | BotResult | OpenTeamResult -const emptySearch = Chat.makeInboxSearchInfo() - export default function InboxSearchContainer(ownProps: OwnProps) { - const {_inboxSearch, toggleInboxSearch, inboxSearchSelect} = Chat.useChatState( + const {_inboxSearch, inboxSearchSelect} = useInboxSearchState( C.useShallow(s => ({ - _inboxSearch: s.inboxSearch ?? emptySearch, - inboxSearchSelect: s.dispatch.inboxSearchSelect, - toggleInboxSearch: s.dispatch.toggleInboxSearch, + _inboxSearch: s.searchInfo, + inboxSearchSelect: s.dispatch.select, })) ) - const onCancel = () => { - toggleInboxSearch(false) - } const navigateAppend = C.Router2.navigateAppend const onInstallBot = (username: string) => { navigateAppend({name: 'chatInstallBotPick', params: {botUsername: username}}) @@ -229,7 +224,7 @@ export default function InboxSearchContainer(ownProps: OwnProps) { isSelected={!Kb.Styles.isMobile && selectedIndex === realIndex} name={item.name} numSearchHits={numHits} - maxSearchHits={Chat.inboxSearchMaxTextMessages} + maxSearchHits={inboxSearchMaxTextMessages} onSelectConversation={() => section.onSelect(item, realIndex)} /> @@ -239,7 +234,7 @@ export default function InboxSearchContainer(ownProps: OwnProps) { isSelected={!Kb.Styles.isMobile && selectedIndex === realIndex} name={item.name} numSearchHits={numHits} - maxSearchHits={Chat.inboxSearchMaxTextMessages} + maxSearchHits={inboxSearchMaxTextMessages} onSelectConversation={() => section.onSelect(item, realIndex)} /> @@ -249,7 +244,6 @@ export default function InboxSearchContainer(ownProps: OwnProps) { const selectName = (item: Item, index: number) => { if (item.type !== 'name') return onSelectConversation(item.conversationIDKey, index, '') - onCancel() } const nameResults: Array = nameCollapsed diff --git a/shared/chat/inbox/defer-loading.tsx b/shared/chat/inbox/defer-loading.tsx index 58b0f01795bd..bffbb514f19c 100644 --- a/shared/chat/inbox/defer-loading.tsx +++ b/shared/chat/inbox/defer-loading.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import Inbox from '.' import {useIsFocused} from '@react-navigation/core' +import {InboxSearchProvider} from './search-state' // keep track of this even on unmount, else if you background / foreground you'll lose it let _everFocused = false @@ -25,5 +26,9 @@ export default function Deferred() { } }, [isFocused, visible]) - return visible ? : null + return visible ? ( + + + + ) : null } diff --git a/shared/chat/inbox/filter-row.tsx b/shared/chat/inbox/filter-row.tsx index a373f5b1e26e..ae61b7ce58a4 100644 --- a/shared/chat/inbox/filter-row.tsx +++ b/shared/chat/inbox/filter-row.tsx @@ -1,7 +1,7 @@ import * as C from '@/constants' -import * as Chat from '@/stores/chat' import * as React from 'react' import * as Kb from '@/common-adapters' +import {useInboxSearchState} from './search-state' type OwnProps = { onEnsureSelection: () => void @@ -18,15 +18,16 @@ function ConversationFilterInput(ownProps: OwnProps) { const {onEnsureSelection, onSelectDown, onSelectUp, showSearch} = ownProps const {onQueryChanged: onSetFilter, query: filter} = ownProps - const isSearching = Chat.useChatState(s => !!s.inboxSearch) + const isSearching = useInboxSearchState(s => s.enabled) const appendNewChatBuilder = C.Router2.appendNewChatBuilder - const toggleInboxSearch = Chat.useChatState(s => s.dispatch.toggleInboxSearch) + const startSearch = useInboxSearchState(s => s.dispatch.startSearch) + const cancelSearch = useInboxSearchState(s => s.dispatch.cancelSearch) const onStartSearch = () => { - toggleInboxSearch(true) + startSearch() } const onStopSearch = () => { - toggleInboxSearch(false) + cancelSearch() } const inputRef = React.useRef(null) diff --git a/shared/chat/inbox/search-row.tsx b/shared/chat/inbox/search-row.tsx index 69f2c5308e26..3a5a070f56d2 100644 --- a/shared/chat/inbox/search-row.tsx +++ b/shared/chat/inbox/search-row.tsx @@ -1,13 +1,14 @@ -import * as React from 'react' import * as C from '@/constants' import * as Chat from '@/stores/chat' import ChatFilterRow from './filter-row' import StartNewChat from './row/start-new-chat' +import {useInboxSearchState} from './search-state' type OwnProps = {headerContext: 'chat-header' | 'inbox-header'} export default function InboxSearchRow(ownProps: OwnProps) { const {headerContext} = ownProps + const isSearching = useInboxSearchState(s => s.enabled) const chatState = Chat.useChatState( C.useShallow(s => { const hasLoadedEmptyInbox = @@ -16,33 +17,27 @@ export default function InboxSearchRow(ownProps: OwnProps) { (s.inboxLayout.smallTeams || []).length === 0 && (s.inboxLayout.bigTeams || []).length === 0 return { - inboxSearch: s.dispatch.inboxSearch, - inboxSearchMoveSelectedIndex: s.dispatch.inboxSearchMoveSelectedIndex, - inboxSearchSelect: s.dispatch.inboxSearchSelect, - isSearching: !!s.inboxSearch, - showEmptyInbox: !s.inboxSearch && hasLoadedEmptyInbox, + showEmptyInbox: hasLoadedEmptyInbox, } }) ) - const {inboxSearch, inboxSearchMoveSelectedIndex, inboxSearchSelect, isSearching, showEmptyInbox} = chatState - const showStartNewChat = !C.isMobile && showEmptyInbox - const showFilter = !showEmptyInbox + const {showEmptyInbox} = chatState + const {query, moveSelectedIndex, select, setQuery} = useInboxSearchState( + C.useShallow(s => ({ + moveSelectedIndex: s.dispatch.moveSelectedIndex, + query: s.searchInfo.query, + select: s.dispatch.select, + setQuery: s.dispatch.setQuery, + })) + ) + const showStartNewChat = !C.isMobile && !isSearching && showEmptyInbox + const showFilter = isSearching || !showEmptyInbox const appendNewChatBuilder = C.Router2.appendNewChatBuilder const navigateUp = C.Router2.navigateUp - const [query, setQuery] = React.useState('') const onQueryChanged = (q: string) => { setQuery(q) - inboxSearch(q) - } - - const [lastSearching, setLastSearching] = React.useState(isSearching) - if (lastSearching !== isSearching) { - setLastSearching(isSearching) - if (!isSearching) { - setQuery('') - } } const showNewChat = headerContext === 'chat-header' @@ -55,9 +50,9 @@ export default function InboxSearchRow(ownProps: OwnProps) { )} {!!showFilter && ( inboxSearchMoveSelectedIndex(false)} - onSelectDown={() => inboxSearchMoveSelectedIndex(true)} - onEnsureSelection={inboxSearchSelect} + onSelectUp={() => moveSelectedIndex(false)} + onSelectDown={() => moveSelectedIndex(true)} + onEnsureSelection={select} onQueryChanged={onQueryChanged} query={query} showNewChat={showNewChat} diff --git a/shared/chat/inbox/search-state.test.tsx b/shared/chat/inbox/search-state.test.tsx new file mode 100644 index 000000000000..25d70f798c83 --- /dev/null +++ b/shared/chat/inbox/search-state.test.tsx @@ -0,0 +1,30 @@ +/// +import {makeInboxSearchInfo, nextInboxSearchSelectedIndex} from './search-state' + +test('inbox search helpers derive stable defaults', () => { + const info = makeInboxSearchInfo() + + expect(info.query).toBe('') + expect(info.selectedIndex).toBe(0) + expect(info.nameStatus).toBe('initial') + expect(info.textStatus).toBe('initial') +}) + +test('inbox search selection movement stays within available results', () => { + const inboxSearch = makeInboxSearchInfo() + inboxSearch.nameResults = [{conversationIDKey: '1'} as any, {conversationIDKey: '2'} as any] + inboxSearch.textResults = [{conversationIDKey: '3', query: 'needle', time: 1} as any] + + let selectedIndex = inboxSearch.selectedIndex + selectedIndex = nextInboxSearchSelectedIndex({...inboxSearch, selectedIndex}, true) + expect(selectedIndex).toBe(1) + + selectedIndex = nextInboxSearchSelectedIndex({...inboxSearch, selectedIndex}, true) + selectedIndex = nextInboxSearchSelectedIndex({...inboxSearch, selectedIndex}, true) + expect(selectedIndex).toBe(2) + + selectedIndex = nextInboxSearchSelectedIndex({...inboxSearch, selectedIndex}, false) + selectedIndex = nextInboxSearchSelectedIndex({...inboxSearch, selectedIndex}, false) + selectedIndex = nextInboxSearchSelectedIndex({...inboxSearch, selectedIndex}, false) + expect(selectedIndex).toBe(0) +}) diff --git a/shared/chat/inbox/search-state.tsx b/shared/chat/inbox/search-state.tsx new file mode 100644 index 000000000000..4f9344bb6fd8 --- /dev/null +++ b/shared/chat/inbox/search-state.tsx @@ -0,0 +1,407 @@ +import * as React from 'react' +import * as T from '@/constants/types' +import * as Chat from '@/stores/chat' +import * as Z from '@/util/zustand' +import {ignorePromise} from '@/constants/utils' +import {RPCError} from '@/util/errors' +import logger from '@/logger' +import {useConfigState} from '@/stores/config' +import {isMobile} from '@/constants/platform' + +export const inboxSearchMaxTextMessages = 25 +export const inboxSearchMaxTextResults = 50 +export const inboxSearchMaxNameResults = 7 +export const inboxSearchMaxUnreadNameResults = isMobile ? 5 : 10 + +export const makeInboxSearchInfo = (): T.Chat.InboxSearchInfo => ({ + botsResults: [], + botsResultsSuggested: false, + botsStatus: 'initial', + indexPercent: 0, + nameResults: [], + nameResultsUnread: false, + nameStatus: 'initial', + openTeamsResults: [], + openTeamsResultsSuggested: false, + openTeamsStatus: 'initial', + query: '', + selectedIndex: 0, + textResults: [], + textStatus: 'initial', +}) + +const getInboxSearchSelected = ( + inboxSearch: T.Immutable +): + | undefined + | { + conversationIDKey: T.Chat.ConversationIDKey + query?: string + } => { + const {selectedIndex, nameResults, botsResults, openTeamsResults, textResults} = inboxSearch + const firstTextResultIdx = botsResults.length + openTeamsResults.length + nameResults.length + const firstOpenTeamResultIdx = nameResults.length + + if (selectedIndex < firstOpenTeamResultIdx) { + const maybeNameResults = nameResults[selectedIndex] + const conversationIDKey = maybeNameResults === undefined ? undefined : maybeNameResults.conversationIDKey + if (conversationIDKey) { + return { + conversationIDKey, + query: undefined, + } + } + } else if (selectedIndex < firstTextResultIdx) { + return + } else if (selectedIndex >= firstTextResultIdx) { + const result = textResults[selectedIndex - firstTextResultIdx] + if (result) { + return { + conversationIDKey: result.conversationIDKey, + query: result.query, + } + } + } + return +} + +export const nextInboxSearchSelectedIndex = ( + inboxSearch: T.Immutable, + increment: boolean +) => { + const {selectedIndex} = inboxSearch + const totalResults = inboxSearch.nameResults.length + inboxSearch.textResults.length + if (increment && selectedIndex < totalResults - 1) { + return selectedIndex + 1 + } + if (!increment && selectedIndex > 0) { + return selectedIndex - 1 + } + return selectedIndex +} + +type Store = T.Immutable<{ + enabled: boolean + searchInfo: T.Chat.InboxSearchInfo +}> + +const initialStore: Store = { + enabled: false, + searchInfo: makeInboxSearchInfo(), +} + +type State = Store & { + dispatch: { + cancelSearch: () => void + moveSelectedIndex: (increment: boolean) => void + resetState: () => void + select: ( + conversationIDKey?: T.Chat.ConversationIDKey, + query?: string, + selectedIndex?: number + ) => void + setQuery: (query: string) => void + startSearch: () => void + } +} + +export const useInboxSearchState = Z.createZustand(set => { + let activeSearchID = 0 + + const cancelActiveSearch = () => { + const f = async () => { + try { + await T.RPCChat.localCancelActiveInboxSearchRpcPromise() + } catch {} + } + ignorePromise(f()) + } + + const isActiveSearch = (searchID: number) => + searchID === activeSearchID && useInboxSearchState.getState().enabled + + const runSearch = (query: string) => { + const searchID = ++activeSearchID + set(s => { + s.searchInfo.query = query + }) + const f = async () => { + try { + await T.RPCChat.localCancelActiveInboxSearchRpcPromise() + } catch {} + + if (!isActiveSearch(searchID) || useInboxSearchState.getState().searchInfo.query !== query) { + return + } + + const teamType = (t: T.RPCChat.TeamType) => (t === T.RPCChat.TeamType.complex ? 'big' : 'small') + + const updateIfActive = (updater: (draft: T.Chat.InboxSearchInfo) => void) => { + if (!isActiveSearch(searchID)) { + return + } + set(s => { + if (!isActiveSearch(searchID)) { + return + } + updater(s.searchInfo) + }) + } + + const onConvHits = (resp: T.RPCChat.MessageTypes['chat.1.chatUi.chatSearchConvHits']['inParam']) => { + const results = (resp.hits.hits || []).reduce>((arr, h) => { + arr.push({ + conversationIDKey: T.Chat.stringToConversationIDKey(h.convID), + name: h.name, + teamType: teamType(h.teamType), + }) + return arr + }, []) + + updateIfActive(draft => { + draft.nameResults = results + draft.nameResultsUnread = resp.hits.unreadMatches + draft.nameStatus = 'success' + }) + + const missingMetas = results.reduce>((arr, r) => { + if (!Chat.getConvoState(r.conversationIDKey).isMetaGood()) { + arr.push(r.conversationIDKey) + } + return arr + }, []) + if (missingMetas.length > 0) { + Chat.useChatState.getState().dispatch.unboxRows(missingMetas, true) + } + } + + const onOpenTeamHits = ( + resp: T.RPCChat.MessageTypes['chat.1.chatUi.chatSearchTeamHits']['inParam'] + ) => { + const results = (resp.hits.hits || []).reduce>((arr, h) => { + const {description, name, memberCount, inTeam} = h + arr.push({ + description: description ?? '', + inTeam, + memberCount, + name, + publicAdmins: [], + }) + return arr + }, []) + updateIfActive(draft => { + draft.openTeamsResultsSuggested = resp.hits.suggestedMatches + draft.openTeamsResults = T.castDraft(results) + draft.openTeamsStatus = 'success' + }) + } + + const onBotsHits = (resp: T.RPCChat.MessageTypes['chat.1.chatUi.chatSearchBotHits']['inParam']) => { + updateIfActive(draft => { + draft.botsResultsSuggested = resp.hits.suggestedMatches + draft.botsResults = T.castDraft(resp.hits.hits || []) + draft.botsStatus = 'success' + }) + } + + const onTextHit = (resp: T.RPCChat.MessageTypes['chat.1.chatUi.chatSearchInboxHit']['inParam']) => { + const {convID, convName, hits, teamType: tt, time} = resp.searchHit + const result = { + conversationIDKey: T.Chat.conversationIDToKey(convID), + name: convName, + numHits: hits?.length ?? 0, + query: resp.searchHit.query, + teamType: teamType(tt), + time, + } as const + + updateIfActive(draft => { + const textResults = draft.textResults.filter(r => r.conversationIDKey !== result.conversationIDKey) + textResults.push(result) + draft.textResults = textResults.sort((l, r) => r.time - l.time) + }) + + if ( + Chat.getConvoState(result.conversationIDKey).meta.conversationIDKey === T.Chat.noConversationIDKey + ) { + Chat.useChatState.getState().dispatch.unboxRows([result.conversationIDKey], true) + } + } + + const onStart = () => { + updateIfActive(draft => { + draft.nameStatus = 'inprogress' + draft.selectedIndex = 0 + draft.textResults = [] + draft.textStatus = 'inprogress' + draft.openTeamsStatus = 'inprogress' + draft.botsStatus = 'inprogress' + }) + } + + const onDone = () => { + updateIfActive(draft => { + draft.textStatus = 'success' + }) + } + + const onIndexStatus = ( + resp: T.RPCChat.MessageTypes['chat.1.chatUi.chatSearchIndexStatus']['inParam'] + ) => { + updateIfActive(draft => { + draft.indexPercent = resp.status.percentIndexed + }) + } + + try { + await T.RPCChat.localSearchInboxRpcListener({ + incomingCallMap: { + 'chat.1.chatUi.chatSearchBotHits': onBotsHits, + 'chat.1.chatUi.chatSearchConvHits': onConvHits, + 'chat.1.chatUi.chatSearchInboxDone': onDone, + 'chat.1.chatUi.chatSearchInboxHit': onTextHit, + 'chat.1.chatUi.chatSearchInboxStart': onStart, + 'chat.1.chatUi.chatSearchIndexStatus': onIndexStatus, + 'chat.1.chatUi.chatSearchTeamHits': onOpenTeamHits, + }, + params: { + identifyBehavior: T.RPCGen.TLFIdentifyBehavior.chatGui, + namesOnly: false, + opts: { + afterContext: 0, + beforeContext: 0, + isRegex: false, + matchMentions: false, + maxBots: 10, + maxConvsHit: inboxSearchMaxTextResults, + maxConvsSearched: 0, + maxHits: inboxSearchMaxTextMessages, + maxMessages: -1, + maxNameConvs: query.length > 0 ? inboxSearchMaxNameResults : inboxSearchMaxUnreadNameResults, + maxTeams: 10, + reindexMode: T.RPCChat.ReIndexingMode.postsearchSync, + sentAfter: 0, + sentBefore: 0, + sentBy: '', + sentTo: '', + skipBotCache: false, + }, + query, + }, + }) + } catch (error) { + if (error instanceof RPCError && error.code !== T.RPCGen.StatusCode.sccanceled) { + logger.error('search failed: ' + error.message) + updateIfActive(draft => { + draft.textStatus = 'error' + }) + } + } + } + ignorePromise(f()) + } + + const dispatch: State['dispatch'] = { + cancelSearch: () => { + activeSearchID++ + set(s => { + s.enabled = false + s.searchInfo = T.castDraft(makeInboxSearchInfo()) + }) + cancelActiveSearch() + }, + moveSelectedIndex: increment => { + set(s => { + s.searchInfo.selectedIndex = nextInboxSearchSelectedIndex(s.searchInfo, increment) + }) + }, + resetState: () => { + activeSearchID++ + set(s => { + s.enabled = false + s.searchInfo = T.castDraft(makeInboxSearchInfo()) + }) + cancelActiveSearch() + }, + select: (_conversationIDKey, q, selectedIndex) => { + let conversationIDKey = _conversationIDKey + let query = q + if (selectedIndex !== undefined) { + set(s => { + s.searchInfo.selectedIndex = selectedIndex + }) + } + + const {enabled, searchInfo} = useInboxSearchState.getState() + if (!enabled) { + return + } + + const selected = getInboxSearchSelected(searchInfo) + if (!conversationIDKey) { + conversationIDKey = selected?.conversationIDKey + } + if (!conversationIDKey) { + return + } + if (!query) { + query = selected?.query + } + + if (query) { + Chat.getConvoState(conversationIDKey).dispatch.navigateToThread( + 'inboxSearch', + undefined, + undefined, + query + ) + } else { + Chat.getConvoState(conversationIDKey).dispatch.navigateToThread('inboxSearch') + dispatch.cancelSearch() + } + }, + setQuery: query => { + if (!useInboxSearchState.getState().enabled) { + return + } + runSearch(query) + }, + startSearch: () => { + if (useInboxSearchState.getState().enabled) { + return + } + set(s => { + s.enabled = true + s.searchInfo = T.castDraft(makeInboxSearchInfo()) + }) + runSearch('') + }, + } + + return { + ...initialStore, + dispatch, + } +}) + +export const InboxSearchProvider = ({children}: {children: React.ReactNode}) => { + const mobileAppState = useConfigState(s => s.mobileAppState) + const enabled = useInboxSearchState(s => s.enabled) + const cancelSearch = useInboxSearchState(s => s.dispatch.cancelSearch) + const resetState = useInboxSearchState(s => s.dispatch.resetState) + + React.useEffect(() => { + resetState() + return () => { + resetState() + } + }, [resetState]) + + React.useEffect(() => { + if (mobileAppState === 'background' && enabled) { + cancelSearch() + } + }, [mobileAppState, enabled, cancelSearch]) + + return <>{children} +} diff --git a/shared/chat/inbox/use-inbox-state.tsx b/shared/chat/inbox/use-inbox-state.tsx index 9f7c3d3588fa..cb9944ed6dbd 100644 --- a/shared/chat/inbox/use-inbox-state.tsx +++ b/shared/chat/inbox/use-inbox-state.tsx @@ -4,11 +4,13 @@ import * as React from 'react' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useIsFocused} from '@react-navigation/core' +import {useInboxSearchState} from './search-state' export function useInboxState(conversationIDKey?: string) { const isFocused = useIsFocused() const loggedIn = useConfigState(s => s.loggedIn) const username = useCurrentUserState(s => s.username) + const isSearching = useInboxSearchState(s => s.enabled) const chatState = Chat.useChatState( C.useShallow(s => ({ @@ -17,7 +19,6 @@ export function useInboxState(conversationIDKey?: string) { inboxNumSmallRows: s.inboxNumSmallRows ?? 5, inboxRefresh: s.dispatch.inboxRefresh, inboxRows: s.inboxRows, - isSearching: !!s.inboxSearch, queueMetaToRequest: s.dispatch.queueMetaToRequest, setInboxNumSmallRows: s.dispatch.setInboxNumSmallRows, smallTeamsExpanded: s.inboxSmallTeamsExpanded, @@ -25,7 +26,7 @@ export function useInboxState(conversationIDKey?: string) { })) ) const {allowShowFloatingButton, inboxHasLoaded, inboxNumSmallRows, inboxRefresh, inboxRows} = chatState - const {isSearching, queueMetaToRequest, setInboxNumSmallRows, smallTeamsExpanded, toggleSmallTeamsExpanded} = chatState + const {queueMetaToRequest, setInboxNumSmallRows, smallTeamsExpanded, toggleSmallTeamsExpanded} = chatState const appendNewChatBuilder = C.Router2.appendNewChatBuilder const selectedConversationIDKey = conversationIDKey ?? Chat.noConversationIDKey diff --git a/shared/constants/init/index.native.tsx b/shared/constants/init/index.native.tsx index c52c23f5332d..8128da7c6a93 100644 --- a/shared/constants/init/index.native.tsx +++ b/shared/constants/init/index.native.tsx @@ -1,6 +1,5 @@ // links all the stores together, stores never import this import {ignorePromise, neverThrowPromiseFunc, timeoutPromise} from '../utils' -import {useChatState} from '@/stores/chat' import {useConfigState} from '@/stores/config' import {useDaemonState} from '@/stores/daemon' import {useDarkModeState} from '@/stores/darkmode' @@ -162,7 +161,7 @@ const ensureBackgroundTask = () => { lon: pos?.coords.longitude ?? 0, } - useChatState.getState().dispatch.updateLastCoord(coord) + await T.RPCChat.localLocationUpdateRpcPromise({coord}) return Promise.resolve() }) } diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index feb063d639bd..4cbd18e92fdb 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -454,12 +454,6 @@ export const initSharedSubscriptions = () => { } } - if (s.mobileAppState !== old.mobileAppState) { - if (s.mobileAppState === 'background' && storeRegistry.getState('chat').inboxSearch) { - storeRegistry.getState('chat').dispatch.toggleInboxSearch(false) - } - } - if (s.revokedTrigger !== old.revokedTrigger) { storeRegistry .getState('daemon') diff --git a/shared/stores/chat.tsx b/shared/stores/chat.tsx index 1a342b3cf5ba..3a375422abf5 100644 --- a/shared/stores/chat.tsx +++ b/shared/stores/chat.tsx @@ -50,63 +50,6 @@ export const DEBUG_CHAT_DUMP = true const blockButtonsGregorPrefix = 'blockButtons.' -export const inboxSearchMaxTextMessages = 25 -export const inboxSearchMaxTextResults = 50 -export const inboxSearchMaxNameResults = 7 -export const inboxSearchMaxUnreadNameResults = isMobile ? 5 : 10 - -export const makeInboxSearchInfo = (): T.Chat.InboxSearchInfo => ({ - botsResults: [], - botsResultsSuggested: false, - botsStatus: 'initial', - indexPercent: 0, - nameResults: [], - nameResultsUnread: false, - nameStatus: 'initial', - openTeamsResults: [], - openTeamsResultsSuggested: false, - openTeamsStatus: 'initial', - query: '', - selectedIndex: 0, - textResults: [], - textStatus: 'initial', -}) - -const getInboxSearchSelected = ( - inboxSearch: T.Immutable -): - | undefined - | { - conversationIDKey: T.Chat.ConversationIDKey - query?: string - } => { - const {selectedIndex, nameResults, botsResults, openTeamsResults, textResults} = inboxSearch - const firstTextResultIdx = botsResults.length + openTeamsResults.length + nameResults.length - const firstOpenTeamResultIdx = nameResults.length - - if (selectedIndex < firstOpenTeamResultIdx) { - const maybeNameResults = nameResults[selectedIndex] - const conversationIDKey = maybeNameResults === undefined ? undefined : maybeNameResults.conversationIDKey - if (conversationIDKey) { - return { - conversationIDKey, - query: undefined, - } - } - } else if (selectedIndex < firstTextResultIdx) { - return - } else if (selectedIndex >= firstTextResultIdx) { - const result = textResults[selectedIndex - firstTextResultIdx] - if (result) { - return { - conversationIDKey: result.conversationIDKey, - query: result.query, - } - } - } - return -} - export const getMessageKey = (message: T.Chat.Message) => `${message.conversationIDKey}:${T.Chat.ordinalToNumber(message.ordinal)}` @@ -245,7 +188,6 @@ type Store = T.Immutable<{ smallTeamBadgeCount: number bigTeamBadgeCount: number smallTeamsExpanded: boolean // if we're showing all small teams, - lastCoord?: T.Chat.Coordinate paymentStatusMap: Map staticConfig?: T.Chat.StaticConfig // static config stuff from the service. only needs to be loaded once. if null, it hasn't been loaded, trustedInboxHasLoaded: boolean // if we've done initial trusted inbox load, @@ -256,7 +198,6 @@ type Store = T.Immutable<{ inboxLayout?: T.RPCChat.UIInboxLayout // layout of the inbox inboxAllowShowFloatingButton: boolean inboxRows: Array - inboxSearch?: T.Chat.InboxSearchInfo inboxSmallTeamsExpanded: boolean flipStatusMap: Map maybeMentionMap: Map @@ -274,9 +215,7 @@ const initialStore: Store = { inboxNumSmallRows: 5, inboxRetriedOnCurrentEmpty: false, inboxRows: [], - inboxSearch: undefined, inboxSmallTeamsExpanded: false, - lastCoord: undefined, maybeMentionMap: new Map(), paymentStatusMap: new Map(), smallTeamBadgeCount: 0, @@ -323,13 +262,6 @@ export type State = Store & { createConversation: (participants: ReadonlyArray, highlightMessageID?: T.Chat.MessageID) => void ensureWidgetMetas: () => void inboxRefresh: (reason: RefreshReason) => void - inboxSearch: (query: string) => void - inboxSearchMoveSelectedIndex: (increment: boolean) => void - inboxSearchSelect: ( - conversationIDKey?: T.Chat.ConversationIDKey, - query?: string, - selectedIndex?: number - ) => void loadStaticConfig: () => void maybeChangeSelectedConv: () => void metasReceived: ( @@ -361,12 +293,10 @@ export type State = Store & { setMaybeMentionInfo: (name: string, info: T.RPCChat.UIMaybeMentionInfo) => void setTrustedInboxHasLoaded: () => void setInboxNumSmallRows: (rows: number, ignoreWrite?: boolean) => void - toggleInboxSearch: (enabled: boolean) => void toggleSmallTeamsExpanded: () => void unboxRows: (ids: ReadonlyArray, force?: boolean) => void updateCoinFlipStatus: (statuses: ReadonlyArray) => void updateInboxLayout: (layout: string) => void - updateLastCoord: (coord: T.Chat.Coordinate) => void updateUserReacjis: (userReacjis: T.RPCGen.UserReacjis) => void updatedGregor: ( items: ReadonlyArray<{md: T.RPCGen.Gregor1.Metadata; item: T.RPCGen.Gregor1.Item}> @@ -655,253 +585,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { } ignorePromise(f()) }, - inboxSearch: query => { - set(s => { - const {inboxSearch} = s - if (inboxSearch) { - inboxSearch.query = query - } - }) - const f = async () => { - const teamType = (t: T.RPCChat.TeamType) => (t === T.RPCChat.TeamType.complex ? 'big' : 'small') - - const onConvHits = (resp: T.RPCChat.MessageTypes['chat.1.chatUi.chatSearchConvHits']['inParam']) => { - const results = (resp.hits.hits || []).reduce>((arr, h) => { - arr.push({ - conversationIDKey: T.Chat.stringToConversationIDKey(h.convID), - name: h.name, - teamType: teamType(h.teamType), - }) - return arr - }, []) - - set(s => { - const unread = resp.hits.unreadMatches - const {inboxSearch} = s - if (inboxSearch?.nameStatus === 'inprogress') { - inboxSearch.nameResults = results - inboxSearch.nameResultsUnread = unread - inboxSearch.nameStatus = 'success' - } - }) - - const missingMetas = results.reduce>((arr, r) => { - if (!storeRegistry.getConvoState(r.conversationIDKey).isMetaGood()) { - arr.push(r.conversationIDKey) - } - return arr - }, []) - if (missingMetas.length > 0) { - get().dispatch.unboxRows(missingMetas, true) - } - } - - const onOpenTeamHits = ( - resp: T.RPCChat.MessageTypes['chat.1.chatUi.chatSearchTeamHits']['inParam'] - ) => { - const results = (resp.hits.hits || []).reduce>((arr, h) => { - const {description, name, memberCount, inTeam} = h - arr.push({ - description: description ?? '', - inTeam, - memberCount, - name, - publicAdmins: [], - }) - return arr - }, []) - const suggested = resp.hits.suggestedMatches - set(s => { - const {inboxSearch} = s - if (inboxSearch?.openTeamsStatus === 'inprogress') { - inboxSearch.openTeamsResultsSuggested = suggested - inboxSearch.openTeamsResults = T.castDraft(results) - inboxSearch.openTeamsStatus = 'success' - } - }) - } - - const onBotsHits = (resp: T.RPCChat.MessageTypes['chat.1.chatUi.chatSearchBotHits']['inParam']) => { - const results = resp.hits.hits || [] - const suggested = resp.hits.suggestedMatches - set(s => { - const {inboxSearch} = s - if (inboxSearch?.botsStatus === 'inprogress') { - inboxSearch.botsResultsSuggested = suggested - inboxSearch.botsResults = T.castDraft(results) - inboxSearch.botsStatus = 'success' - } - }) - } - - const onTextHit = (resp: T.RPCChat.MessageTypes['chat.1.chatUi.chatSearchInboxHit']['inParam']) => { - const {convID, convName, hits, query, teamType: tt, time} = resp.searchHit - - const result = { - conversationIDKey: T.Chat.conversationIDToKey(convID), - name: convName, - numHits: hits?.length ?? 0, - query, - teamType: teamType(tt), - time, - } as const - set(s => { - const {inboxSearch} = s - if (inboxSearch?.textStatus === 'inprogress') { - const {conversationIDKey} = result - const textResults = inboxSearch.textResults.filter( - r => r.conversationIDKey !== conversationIDKey - ) - textResults.push(result) - inboxSearch.textResults = textResults.sort((l, r) => r.time - l.time) - } - }) - - if ( - storeRegistry.getConvoState(result.conversationIDKey).meta.conversationIDKey === - T.Chat.noConversationIDKey - ) { - get().dispatch.unboxRows([result.conversationIDKey], true) - } - } - const onStart = () => { - set(s => { - const {inboxSearch} = s - if (inboxSearch) { - inboxSearch.nameStatus = 'inprogress' - inboxSearch.selectedIndex = 0 - inboxSearch.textResults = [] - inboxSearch.textStatus = 'inprogress' - inboxSearch.openTeamsStatus = 'inprogress' - inboxSearch.botsStatus = 'inprogress' - } - }) - } - const onDone = () => { - set(s => { - const status = 'success' - const inboxSearch = s.inboxSearch ?? makeInboxSearchInfo() - s.inboxSearch = T.castDraft(inboxSearch) - inboxSearch.textStatus = status - }) - } - - const onIndexStatus = ( - resp: T.RPCChat.MessageTypes['chat.1.chatUi.chatSearchIndexStatus']['inParam'] - ) => { - const percent = resp.status.percentIndexed - set(s => { - const {inboxSearch} = s - if (inboxSearch?.textStatus === 'inprogress') { - inboxSearch.indexPercent = percent - } - }) - } - - try { - await T.RPCChat.localSearchInboxRpcListener({ - incomingCallMap: { - 'chat.1.chatUi.chatSearchBotHits': onBotsHits, - 'chat.1.chatUi.chatSearchConvHits': onConvHits, - 'chat.1.chatUi.chatSearchInboxDone': onDone, - 'chat.1.chatUi.chatSearchInboxHit': onTextHit, - 'chat.1.chatUi.chatSearchInboxStart': onStart, - 'chat.1.chatUi.chatSearchIndexStatus': onIndexStatus, - 'chat.1.chatUi.chatSearchTeamHits': onOpenTeamHits, - }, - params: { - identifyBehavior: T.RPCGen.TLFIdentifyBehavior.chatGui, - namesOnly: false, - opts: { - afterContext: 0, - beforeContext: 0, - isRegex: false, - matchMentions: false, - maxBots: 10, - maxConvsHit: inboxSearchMaxTextResults, - maxConvsSearched: 0, - maxHits: inboxSearchMaxTextMessages, - maxMessages: -1, - maxNameConvs: query.length > 0 ? inboxSearchMaxNameResults : inboxSearchMaxUnreadNameResults, - maxTeams: 10, - reindexMode: T.RPCChat.ReIndexingMode.postsearchSync, - sentAfter: 0, - sentBefore: 0, - sentBy: '', - sentTo: '', - skipBotCache: false, - }, - query, - }, - }) - } catch (error) { - if (error instanceof RPCError) { - if (!(error.code === T.RPCGen.StatusCode.sccanceled)) { - logger.error('search failed: ' + error.message) - set(s => { - const status = 'error' - const inboxSearch = s.inboxSearch ?? makeInboxSearchInfo() - s.inboxSearch = T.castDraft(inboxSearch) - inboxSearch.textStatus = status - }) - } - } - } - } - ignorePromise(f()) - }, - inboxSearchMoveSelectedIndex: increment => { - set(s => { - const {inboxSearch} = s - if (inboxSearch) { - const {selectedIndex} = inboxSearch - const totalResults = inboxSearch.nameResults.length + inboxSearch.textResults.length - if (increment && selectedIndex < totalResults - 1) { - inboxSearch.selectedIndex = selectedIndex + 1 - } else if (!increment && selectedIndex > 0) { - inboxSearch.selectedIndex = selectedIndex - 1 - } - } - }) - }, - inboxSearchSelect: (_conversationIDKey, q, selectedIndex) => { - let conversationIDKey = _conversationIDKey - let query = q - set(s => { - const {inboxSearch} = s - if (inboxSearch && selectedIndex !== undefined) { - inboxSearch.selectedIndex = selectedIndex - } - }) - - const {inboxSearch} = get() - if (!inboxSearch) { - return - } - const selected = getInboxSearchSelected(inboxSearch) - if (!conversationIDKey) { - conversationIDKey = selected?.conversationIDKey - } - - if (!conversationIDKey) { - return - } - if (!query) { - query = selected?.query - } - - if (query) { - storeRegistry.getConvoState(conversationIDKey).dispatch.navigateToThread( - 'inboxSearch', - undefined, - undefined, - query - ) - } else { - storeRegistry.getConvoState(conversationIDKey).dispatch.navigateToThread('inboxSearch') - get().dispatch.toggleInboxSearch(false) - } - }, loadStaticConfig: () => { if (get().staticConfig) { return @@ -1772,27 +1455,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { s.trustedInboxHasLoaded = true }) }, - toggleInboxSearch: enabled => { - set(s => { - const {inboxSearch} = s - if (enabled && !inboxSearch) { - s.inboxSearch = T.castDraft(makeInboxSearchInfo()) - } else if (!enabled && inboxSearch) { - s.inboxSearch = undefined - } - }) - const f = async () => { - const {inboxSearch} = get() - if (!inboxSearch) { - await T.RPCChat.localCancelActiveInboxSearchRpcPromise() - return - } - if (inboxSearch.nameStatus === 'initial') { - get().dispatch.inboxSearch('') - } - } - ignorePromise(f()) - }, toggleSmallTeamsExpanded: () => { set(s => { s.smallTeamsExpanded = !s.smallTeamsExpanded @@ -1895,16 +1557,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { } }) }, - updateLastCoord: coord => { - set(s => { - s.lastCoord = coord - }) - const f = async () => { - const {accuracy, lat, lon} = coord - await T.RPCChat.localLocationUpdateRpcPromise({coord: {accuracy, lat, lon}}) - } - ignorePromise(f()) - }, updateUserReacjis: userReacjis => { set(s => { const {skinTone, topReacjis} = userReacjis diff --git a/shared/stores/tests/chat.test.ts b/shared/stores/tests/chat.test.ts index 0dfe5cad4eb2..d19a5ec04cbd 100644 --- a/shared/stores/tests/chat.test.ts +++ b/shared/stores/tests/chat.test.ts @@ -3,7 +3,6 @@ import { clampImageSize, getTeamMentionName, isAssertion, - makeInboxSearchInfo, useChatState, zoomImage, } from '../chat' @@ -13,12 +12,6 @@ afterEach(() => { }) test('chat helper utilities derive stable defaults and formatting', () => { - const info = makeInboxSearchInfo() - - expect(info.query).toBe('') - expect(info.selectedIndex).toBe(0) - expect(info.nameStatus).toBe('initial') - expect(info.textStatus).toBe('initial') expect(getTeamMentionName('acme', 'general')).toBe('acme#general') expect(getTeamMentionName('acme', '')).toBe('acme') expect(isAssertion('alice@twitter')).toBe(true) @@ -37,26 +30,6 @@ test('chat sizing helpers clamp and center oversized images', () => { expect(zoomed.margins.marginRight).toBeCloseTo(0) }) -test('inbox search selection movement stays within available results', () => { - const inboxSearch = makeInboxSearchInfo() - inboxSearch.nameResults = [{conversationIDKey: '1'} as any, {conversationIDKey: '2'} as any] - inboxSearch.textResults = [{conversationIDKey: '3', query: 'needle', time: 1} as any] - useChatState.setState({inboxSearch} as any) - - const {dispatch} = useChatState.getState() - dispatch.inboxSearchMoveSelectedIndex(true) - expect(useChatState.getState().inboxSearch?.selectedIndex).toBe(1) - - dispatch.inboxSearchMoveSelectedIndex(true) - dispatch.inboxSearchMoveSelectedIndex(true) - expect(useChatState.getState().inboxSearch?.selectedIndex).toBe(2) - - dispatch.inboxSearchMoveSelectedIndex(false) - dispatch.inboxSearchMoveSelectedIndex(false) - dispatch.inboxSearchMoveSelectedIndex(false) - expect(useChatState.getState().inboxSearch?.selectedIndex).toBe(0) -}) - test('setInboxNumSmallRows ignores non-positive values when updating local state', () => { const {dispatch} = useChatState.getState() diff --git a/skill/zustand-store-pruning/references/store-checklist.md b/skill/zustand-store-pruning/references/store-checklist.md index 1d836cedbd6e..5acbf7c8598f 100644 --- a/skill/zustand-store-pruning/references/store-checklist.md +++ b/skill/zustand-store-pruning/references/store-checklist.md @@ -10,6 +10,8 @@ Status: - `[~]` intentionally skipped for now - [ ] `teams` +- [x] `chat` + Notes: moved inbox search state/RPC orchestration into `shared/chat/inbox/search-state.tsx`; moved location preview coordinate state out of `shared/stores/chat.tsx`; pending create-conversation error flow intentionally kept for now. - [ ] `push` Files: `shared/stores/push.desktop.tsx`, `shared/stores/push.native.tsx`, `shared/stores/push.d.ts` - [ ] `settings-contacts` From 51c60361f7b609abf4f2e28405a95e6db2f0ca7a Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 9 Apr 2026 12:36:52 -0400 Subject: [PATCH 10/59] WIP --- .../markdown/generate-emoji-parser.mts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/shared/common-adapters/markdown/generate-emoji-parser.mts b/shared/common-adapters/markdown/generate-emoji-parser.mts index af455b6fd34d..0de43dd0ba1f 100644 --- a/shared/common-adapters/markdown/generate-emoji-parser.mts +++ b/shared/common-adapters/markdown/generate-emoji-parser.mts @@ -1,6 +1,6 @@ import {default as fs, promises as fsp} from 'fs' import path from 'path' -import emojiData from 'emoji-datasource-apple' +import type {EmojiData} from 'emoji-datasource-apple' import escapeRegExp from 'lodash/escapeRegExp' import prettier from 'prettier' import {fileURLToPath} from 'node:url' @@ -46,7 +46,12 @@ function UTF162JSON(text: string) { return r.join('') } -function genEmojiData() { +const readEmojiData = async () => { + const emojiDataPath = path.join(__dirname, '../../node_modules/emoji-datasource-apple/emoji.json') + return JSON.parse(await fsp.readFile(emojiDataPath, 'utf8')) as Array +} + +function genEmojiData(emojiData: Array) { const emojiIndexByChar: {[key: string]: string} = {} const emojiIndexByName: {[key: string]: string} = {} const emojiLiterals: Array = [] @@ -118,7 +123,8 @@ async function buildEmojiFile() { const p = path.join(__dirname, 'emoji-gen.tsx') const {swidth, sheight} = await getSpriteSheetSize() - const {emojiIndexByName, emojiIndexByChar} = genEmojiData() + const emojiData = await readEmojiData() + const {emojiIndexByName, emojiIndexByChar} = genEmojiData(emojiData) const regIndex = Object.keys(emojiIndexByName) .map((s: string) => escapeRegExp(s).replace(/\\/g, '\\\\')) .join('|') From 1b6c7dd1d9829e6ed3924daf00b411a0afb1aae6 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 9 Apr 2026 12:40:15 -0400 Subject: [PATCH 11/59] fix go deadlocks --- go/chat/search/indexer.go | 2 -- go/chat/search/storage.go | 61 +++++++++++++++++++++++++-------------- 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/go/chat/search/indexer.go b/go/chat/search/indexer.go index b3b2417f34b1..ec303f752515 100644 --- a/go/chat/search/indexer.go +++ b/go/chat/search/indexer.go @@ -867,8 +867,6 @@ func (idx *Indexer) PercentIndexed(ctx context.Context, convID chat1.Conversatio func (idx *Indexer) Clear(ctx context.Context, uid gregor1.UID, convID chat1.ConversationID) (err error) { defer idx.Trace(ctx, &err, "Indexer.Clear uid: %v convID: %v", uid, convID)() - idx.Lock() - defer idx.Unlock() return idx.store.Clear(ctx, uid, convID) } diff --git a/go/chat/search/storage.go b/go/chat/search/storage.go index a98bf537c8bc..ff6547078389 100644 --- a/go/chat/search/storage.go +++ b/go/chat/search/storage.go @@ -672,26 +672,45 @@ func (s *store) Add(ctx context.Context, convID chat1.ConversationID, msgs []chat1.MessageUnboxed, ) (err error) { defer s.Trace(ctx, &err, "Add")() - s.Lock() - defer s.Unlock() - fetchSupersededMsgs := func(msg chat1.MessageUnboxed) []chat1.MessageUnboxed { - superIDs, err := utils.GetSupersedes(msg) - if err != nil { - s.Debug(ctx, "unable to get supersedes: %v", err) - return nil - } - reason := chat1.GetThreadReason_INDEXED_SEARCH - supersededMsgs, err := s.G().ChatHelper.GetMessages(ctx, s.uid, convID, superIDs, - false /* resolveSupersedes*/, &reason) - if err != nil { - // Log but ignore error - s.Debug(ctx, "unable to get fetch messages: %v", err) - return nil + // Pre-fetch superseded messages before acquiring the lock. EDIT and + // ATTACHMENTUPLOADED messages require a network/DB lookup to find the + // message they supersede, and that call can block indefinitely on the + // conv lock. Holding s.Lock() during that call would block ClearMemory + // (and transitively Indexer.Clear → idx.Lock()), freezing all thread + // loading. Fetch outside the lock; only the in-memory index mutations + // need serialization. + type supersededFetch struct { + msgs []chat1.MessageUnboxed + tokens tokenMap // only set for EDIT + } + reason := chat1.GetThreadReason_INDEXED_SEARCH + superseded := make(map[chat1.MessageID]supersededFetch, len(msgs)) + for _, msg := range msgs { + switch msg.GetMessageType() { + case chat1.MessageType_ATTACHMENTUPLOADED, chat1.MessageType_EDIT: + superIDs, err := utils.GetSupersedes(msg) + if err != nil { + s.Debug(ctx, "Add: unable to get supersedes: %v", err) + continue + } + supersededMsgs, err := s.G().ChatHelper.GetMessages(ctx, s.uid, convID, superIDs, + false /* resolveSupersedes */, &reason) + if err != nil { + s.Debug(ctx, "Add: unable to fetch superseded messages: %v", err) + continue + } + fetch := supersededFetch{msgs: supersededMsgs} + if msg.GetMessageType() == chat1.MessageType_EDIT { + fetch.tokens = tokensFromMsg(msg) + } + superseded[msg.GetMessageID()] = fetch } - return supersededMsgs } + s.Lock() + defer s.Unlock() + modified := false md, err := s.GetMetadata(ctx, convID) if err != nil { @@ -716,8 +735,7 @@ func (s *store) Add(ctx context.Context, convID chat1.ConversationID, // indexed. switch msg.GetMessageType() { case chat1.MessageType_ATTACHMENTUPLOADED: - supersededMsgs := fetchSupersededMsgs(msg) - for _, sm := range supersededMsgs { + for _, sm := range superseded[msg.GetMessageID()].msgs { seenIDs[sm.GetMessageID()] = chat1.EmptyStruct{} err := s.addMsg(ctx, convID, sm) if err != nil { @@ -725,17 +743,16 @@ func (s *store) Add(ctx context.Context, convID chat1.ConversationID, } } case chat1.MessageType_EDIT: - tokens := tokensFromMsg(msg) - supersededMsgs := fetchSupersededMsgs(msg) + fetch := superseded[msg.GetMessageID()] // remove the original message text and replace it with the edited // contents (using the original id in the index) - for _, sm := range supersededMsgs { + for _, sm := range fetch.msgs { seenIDs[sm.GetMessageID()] = chat1.EmptyStruct{} err := s.removeMsg(ctx, convID, sm) if err != nil { return err } - err = s.addTokens(ctx, convID, tokens, sm.GetMessageID()) + err = s.addTokens(ctx, convID, fetch.tokens, sm.GetMessageID()) if err != nil { return err } From 04dc2eb27707f80a8f2017610c14fcd98f5fc066 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 9 Apr 2026 14:25:15 -0400 Subject: [PATCH 12/59] go test for lock --- go/chat/search_test.go | 131 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/go/chat/search_test.go b/go/chat/search_test.go index 6b0647d9f726..76d98527a15b 100644 --- a/go/chat/search_test.go +++ b/go/chat/search_test.go @@ -17,6 +17,29 @@ import ( "github.com/stretchr/testify/require" ) +// blockingGetMsgsChatHelper wraps MockChatHelper and blocks GetMessages until +// released, so tests can observe locking behavior in store.Add. +type blockingGetMsgsChatHelper struct { + *kbtest.MockChatHelper + calledCh chan struct{} + releaseCh chan struct{} +} + +func (h *blockingGetMsgsChatHelper) GetMessages(ctx context.Context, uid gregor1.UID, + convID chat1.ConversationID, msgIDs []chat1.MessageID, + resolveSupersedes bool, reason *chat1.GetThreadReason, +) ([]chat1.MessageUnboxed, error) { + select { + case h.calledCh <- struct{}{}: + default: + } + select { + case <-h.releaseCh: + case <-ctx.Done(): + } + return nil, nil +} + func TestChatSearchConvRegexp(t *testing.T) { runWithMemberTypes(t, func(mt chat1.ConversationMembersType) { // Only test against IMPTEAMNATIVE. There is a bug in ChatRemoteMock @@ -1029,3 +1052,111 @@ func TestChatSearchInbox(t *testing.T) { require.Zero(t, pi) }) } + +// TestSearchIndexerNoDeadlockOnClearDuringAdd verifies that Indexer.Clear and +// Indexer.Suspend do not deadlock when store.Add is simultaneously blocked in +// ChatHelper.GetMessages while processing an EDIT message. +// +// Before the fix, store.Add held s.Lock() during the ChatHelper.GetMessages +// call, which blocked store.ClearMemory. Indexer.Clear also held idx.Lock() +// during the entire Clear operation, which blocked Indexer.Suspend. +func TestSearchIndexerNoDeadlockOnClearDuringAdd(t *testing.T) { + ctx := context.TODO() + ctc := makeChatTestContext(t, "SearchIndexerDeadlock", 1) + defer ctc.cleanup() + users := ctc.users() + u1 := users[0] + g1 := ctc.world.Tcs[u1.Username].Context() + uid1 := gregor1.UID(u1.User.GetUID().ToBytes()) + + calledCh := make(chan struct{}, 1) + releaseCh := make(chan struct{}) + g1.ExternalG().ChatHelper = &blockingGetMsgsChatHelper{ + MockChatHelper: kbtest.NewMockChatHelper(), + calledCh: calledCh, + releaseCh: releaseCh, + } + + indexer := search.NewIndexer(g1) + indexer.SetUID(uid1) + consumeCh := make(chan chat1.ConversationID, 1) + indexer.SetConsumeCh(consumeCh) + select { + case <-g1.Indexer.Stop(ctx): + case <-time.After(10 * time.Second): + require.Fail(t, "original indexer did not stop") + } + g1.Indexer = indexer + indexer.StartStorageLoop() + + convID := chat1.ConversationID([]byte{ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + }) + editMsg := chat1.NewMessageUnboxedWithValid(chat1.MessageUnboxedValid{ + ClientHeader: chat1.MessageClientHeaderVerified{ + MessageType: chat1.MessageType_EDIT, + Conv: chat1.ConversationIDTriple{ + TopicType: chat1.TopicType_CHAT, + }, + }, + MessageBody: chat1.NewMessageBodyWithEdit(chat1.MessageEdit{ + MessageID: 1, + Body: "hello world", + }), + ServerHeader: chat1.MessageServerHeader{ + MessageID: 2, + }, + }) + + // Dispatch the EDIT message. storageLoop will call store.Add, which now + // pre-fetches superseded messages outside s.Lock() before acquiring it. + require.NoError(t, indexer.Add(ctx, convID, []chat1.MessageUnboxed{editMsg})) + + // Wait for store.Add to reach the ChatHelper.GetMessages call. + select { + case <-calledCh: + case <-time.After(10 * time.Second): + require.Fail(t, "GetMessages was never called") + } + + // With store.Add blocked in GetMessages (outside s.Lock()), Clear must be + // able to acquire s.Lock() and complete. Before the fix it would deadlock. + clearDone := make(chan struct{}) + go func() { + defer close(clearDone) + require.NoError(t, indexer.Clear(ctx, uid1, convID)) + }() + select { + case <-clearDone: + case <-time.After(5 * time.Second): + require.Fail(t, "Indexer.Clear deadlocked while store.Add was blocked in GetMessages") + } + + // Suspend must also complete; before the fix, Indexer.Clear held idx.Lock() + // during the entire store.Clear, blocking Suspend indefinitely. + suspendDone := make(chan struct{}) + go func() { + defer close(suspendDone) + indexer.Suspend(ctx) + }() + select { + case <-suspendDone: + case <-time.After(5 * time.Second): + require.Fail(t, "Indexer.Suspend deadlocked while store.Add was blocked in GetMessages") + } + indexer.Resume(ctx) + + // Release the blocked GetMessages so store.Add can finish and the indexer + // can shut down cleanly. + close(releaseCh) + select { + case <-consumeCh: + case <-time.After(10 * time.Second): + require.Fail(t, "store.Add never completed after GetMessages was released") + } + select { + case <-indexer.Stop(ctx): + case <-time.After(10 * time.Second): + require.Fail(t, "indexer did not stop") + } +} From f152f436b20040fe7ad4a69f9cea102a3bff2b59 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 9 Apr 2026 14:46:54 -0400 Subject: [PATCH 13/59] WIP --- shared/common-adapters/markdown/generate-emoji-parser.mts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/shared/common-adapters/markdown/generate-emoji-parser.mts b/shared/common-adapters/markdown/generate-emoji-parser.mts index 0de43dd0ba1f..e8ab0a452cbd 100644 --- a/shared/common-adapters/markdown/generate-emoji-parser.mts +++ b/shared/common-adapters/markdown/generate-emoji-parser.mts @@ -1,7 +1,6 @@ import {default as fs, promises as fsp} from 'fs' import path from 'path' import type {EmojiData} from 'emoji-datasource-apple' -import escapeRegExp from 'lodash/escapeRegExp' import prettier from 'prettier' import {fileURLToPath} from 'node:url' @@ -46,6 +45,10 @@ function UTF162JSON(text: string) { return r.join('') } +function escapeRegExp(text: string) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + const readEmojiData = async () => { const emojiDataPath = path.join(__dirname, '../../node_modules/emoji-datasource-apple/emoji.json') return JSON.parse(await fsp.readFile(emojiDataPath, 'utf8')) as Array From 27fc24dee9b832171be4ff0a9a68366bf05701d3 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Thu, 9 Apr 2026 15:05:41 -0400 Subject: [PATCH 14/59] WIP --- shared/constants/init/index.native.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/shared/constants/init/index.native.tsx b/shared/constants/init/index.native.tsx index 8128da7c6a93..14470d7da011 100644 --- a/shared/constants/init/index.native.tsx +++ b/shared/constants/init/index.native.tsx @@ -161,7 +161,11 @@ const ensureBackgroundTask = () => { lon: pos?.coords.longitude ?? 0, } - await T.RPCChat.localLocationUpdateRpcPromise({coord}) + try { + await T.RPCChat.localLocationUpdateRpcPromise({coord}) + } catch (error) { + logger.info('background location update failed: ' + String(error)) + } return Promise.resolve() }) } From 91c7a05afe5d98c2d47e23a4bc38f707b5f010c0 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 10 Apr 2026 09:47:43 -0400 Subject: [PATCH 15/59] WIP --- shared/stores/chat.tsx | 51 +++++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/shared/stores/chat.tsx b/shared/stores/chat.tsx index 3a375422abf5..886ae3bcee3e 100644 --- a/shared/stores/chat.tsx +++ b/shared/stores/chat.tsx @@ -20,7 +20,7 @@ import {flushInboxRowUpdates} from '@/stores/inbox-rows' import type {ChatInboxRowItem} from '@/chat/inbox/rowitem' import type {StaticScreenProps} from '@react-navigation/core' import {ignorePromise, timeoutPromise} from '@/constants/utils' -import {isMobile, isPhone} from '@/constants/platform' +import {isPhone} from '@/constants/platform' import { navigateAppend, navUpToScreen, @@ -1671,17 +1671,19 @@ export const useChatState = Z.createZustand('chat', (set, get) => { // See constants/router.tsx IsExactlyRecord for explanation type IsExactlyRecord = string extends keyof T ? true : false -type NavigatorParamsFromProps

= P extends Record - ? IsExactlyRecord

extends true - ? undefined - : keyof P extends never +type NavigatorParamsFromProps

= + P extends Record + ? IsExactlyRecord

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

= P extends Record - ? Omit & {conversationIDKey?: T.Chat.ConversationIDKey} - : {conversationIDKey?: T.Chat.ConversationIDKey} +type AddConversationIDKey

= + P extends Record + ? Omit & {conversationIDKey?: T.Chat.ConversationIDKey} + : {conversationIDKey?: T.Chat.ConversationIDKey} type LazyInnerComponent> = COM extends React.LazyExoticComponent ? Inner : never @@ -1698,31 +1700,30 @@ type ChatScreenComponent> = ( export function makeChatScreen>( Component: COM, options?: { - getOptions?: - | GetOptionsRet - | ((props: ChatScreenProps) => GetOptionsRet) + getOptions?: GetOptionsRet | ((props: ChatScreenProps) => GetOptionsRet) skipProvider?: boolean canBeNullConvoID?: boolean } ): RouteDef, ChatScreenParams> { const getOptionsOption = options?.getOptions - const getOptions = typeof getOptionsOption === 'function' - ? (p: ChatScreenProps) => - // getOptions can run before params are materialized on the route object. - getOptionsOption({ - ...p, - route: { - ...p.route, - params: (((p.route as {params?: ChatScreenParams}).params ?? {}) as ChatScreenParams), - }, - }) - : getOptionsOption + const getOptions = + typeof getOptionsOption === 'function' + ? (p: ChatScreenProps) => + // getOptions can run before params are materialized on the route object. + getOptionsOption({ + ...p, + route: { + ...p.route, + params: ((p.route as {params?: ChatScreenParams}).params ?? {}) as ChatScreenParams, + }, + }) + : getOptionsOption return { ...options, getOptions, screen: function Screen(p: ChatScreenProps) { const Comp = Component as any - const params = (((p.route as {params?: ChatScreenParams}).params ?? {}) as ChatScreenParams) + const params = ((p.route as {params?: ChatScreenParams}).params ?? {}) as ChatScreenParams return options?.skipProvider ? ( ) : ( From 67c46e5e2228c66ef8cad5fb03c551cf1de90455 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 10:17:46 -0400 Subject: [PATCH 16/59] WIP --- AGENTS.md | 1 - 1 file changed, 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index f9254958aee9..c4885b6a5ce7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,6 @@ - Keep types accurate. Do not use casts or misleading annotations to mask a real type mismatch just to get around an issue; fix the type or fix the implementation. - Do not add new exported functions, types, or constants unless they are required outside the file. Prefer file-local helpers for one-off implementation details and tests. - Do not edit lockfiles by hand. They are generated artifacts. If you cannot regenerate one locally, leave it unchanged. -- Do not use `navigation.setOptions` for header state in this repo. Pass header-driving state through route params so `getOptions` can read it synchronously, or use [`shared/stores/modal-header.tsx`](/Users/ChrisNojima/SourceCode/go/src/github.com/keybase/client/shared/stores/modal-header.tsx) when the flow already uses the shared modal header mechanism. - 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. From ecfc846bd0794fce5cb5daf74106d67a4673565d Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 10:27:04 -0400 Subject: [PATCH 17/59] WIP --- plans/inbox.md | 115 +++++ shared/chat/inbox-and-conversation-header.tsx | 9 +- shared/chat/inbox-and-conversation.tsx | 48 +- shared/chat/inbox-search/index.tsx | 21 +- shared/chat/inbox/defer-loading.tsx | 8 +- shared/chat/inbox/filter-row.tsx | 34 +- shared/chat/inbox/index.native.tsx | 22 +- shared/chat/inbox/search-row.tsx | 34 +- shared/chat/inbox/search-state.tsx | 407 ----------------- ...ate.test.tsx => use-inbox-search.test.tsx} | 2 +- shared/chat/inbox/use-inbox-search.tsx | 411 ++++++++++++++++++ shared/chat/inbox/use-inbox-state.tsx | 4 +- 12 files changed, 629 insertions(+), 486 deletions(-) create mode 100644 plans/inbox.md delete mode 100644 shared/chat/inbox/search-state.tsx rename shared/chat/inbox/{search-state.test.tsx => use-inbox-search.test.tsx} (98%) create mode 100644 shared/chat/inbox/use-inbox-search.tsx diff --git a/plans/inbox.md b/plans/inbox.md new file mode 100644 index 000000000000..9f75007d6ba7 --- /dev/null +++ b/plans/inbox.md @@ -0,0 +1,115 @@ +# Inbox Search Refactor + +## Summary + +The current inbox search setup uses `shared/chat/inbox/search-state.tsx` as a temporary bridge because desktop search input is rendered in `shared/chat/inbox-and-conversation-header.tsx`, while inbox search results and inbox list rendering live in the inbox tree. The refactor should happen in two parts: + +1. Move desktop search out of the navigator header and into the inbox pane so desktop search can be owned by ordinary React state in one tree. +2. Follow with a broader cleanup that unifies native and desktop inbox search ownership and reduces remaining platform divergence where practical. + +## Part 1: Move Desktop Search Out Of The Header + +Goal: + +- Remove the cross-tree desktop search split. +- Delete the temporary inbox-search Zustand store/provider after desktop no longer needs it. +- Keep user-visible native behavior unchanged. + +Implementation changes: + +- Remove `` from `shared/chat/inbox-and-conversation-header.tsx`. +- Render the desktop search row at the top of the inbox pane, in the same tree that decides between desktop inbox list and desktop inbox search results. +- Make `shared/chat/inbox-and-conversation.tsx` own desktop search state and search lifecycle: + - `isSearching` + - `query` + - `searchInfo` + - selected result movement + - select / cancel / submit handlers + - active search RPC cancellation and restart +- Convert desktop `SearchRow` to a prop-driven component for search state and actions instead of reading `useInboxSearchState` directly. +- Convert desktop `InboxSearch` to receive search state and handlers from the local owner instead of reading the inbox-search Zustand store. +- Remove `InboxSearchProvider` usage from `shared/chat/inbox-and-conversation.tsx` and `shared/chat/inbox/defer-loading.tsx`. +- Delete `shared/chat/inbox/search-state.tsx` and its test once all consumers are moved off it. + +Behavior requirements: + +- Desktop still supports `Cmd+K`, Escape, Arrow Up/Down, and Enter. +- Desktop still swaps between inbox list and inbox search results the same way it does now. +- Selecting a conversation result closes search. +- Selecting a text hit opens thread search with the query. +- Desktop cancels active search RPCs on new query, unmount, and background. + +Part 1 boundaries: + +- Native keeps search inside the inbox tree as it does today. +- Do not bundle broader inbox row/list cleanup into this step. +- Do not change `convostate`. + +## Part 2: Broader Inbox Cleanup / Native + Desktop Unification + +Goal: + +- Unify inbox search ownership across native and desktop. +- Share one inbox-search controller shape and one search-results rendering path where possible. +- Reduce the remaining “desktop owner vs native owner” split without forcing identical list implementations. + +Implementation changes: + +- Introduce one inbox-search controller hook in the inbox feature layer, owned by the route tree rather than Zustand. +- Use the same controller contract on both platforms: + - `isSearching` + - `query` + - `searchInfo` + - `startSearch` + - `cancelSearch` + - `setQuery` + - `moveSelectedIndex` + - `selectResult` +- Make both `shared/chat/inbox/index.desktop.tsx` and `shared/chat/inbox/index.native.tsx` consume that controller contract. +- Keep platform-specific list behavior where it is genuinely platform-specific: + - desktop drag divider / DOM-specific list behavior + - native mobile list layout behavior +- Keep one inbox-search results component interface so native and desktop no longer depend on different ownership models. +- Keep search lifecycle management in the shared inbox-search hook: + - cancel previous search when query changes + - cancel on unmount + - cancel on background while mounted + +Part 2 boundaries: + +- Do not force desktop and native into one identical inbox screen file. +- Do not refactor inbox virtualization, drag resizing, or unread shortcut behavior unless required to support the shared search controller. +- Additional pruning of non-search inbox state in `shared/stores/chat.tsx` is a follow-up, not required for this plan. + +## Interfaces / Types + +- `SearchRow` should become prop-driven and stop depending on a dedicated inbox-search store. +- `InboxSearch` should be fed by the local inbox owner / shared controller hook, not Zustand. +- `T.Chat.InboxSearchInfo` should remain the backing results/status shape unless a later cleanup proves a smaller local type is clearly better. +- No daemon RPC interfaces change. + +## Test Plan + +Part 1: + +- Desktop search opens from the inbox pane, not the navigator header. +- `Cmd+K` focuses the desktop inbox search input. +- Typing updates results and selection behavior still works. +- Escape closes search. +- Enter selects the highlighted result. +- Active inbox search RPC is canceled on query replacement, background, and unmount. +- There are no remaining imports/usages of `useInboxSearchState` or `InboxSearchProvider`. + +Part 2: + +- Desktop and native both use the same inbox-search controller contract. +- Native user-visible behavior stays the same. +- Desktop and native both cancel active search RPCs on unmount/background. +- Selecting text hits vs conversation hits behaves the same on both platforms. +- No inbox-search state remains in `shared/stores/chat.tsx`. + +## Assumptions + +- Part 1 is intentionally desktop-first and can leave native slightly different until Part 2. +- The main value of Part 1 is eliminating the cross-tree desktop search problem so local React ownership becomes possible. +- “Unify native and desktop inbox work” means shared search ownership and interface, not necessarily identical screen files or identical list internals. diff --git a/shared/chat/inbox-and-conversation-header.tsx b/shared/chat/inbox-and-conversation-header.tsx index 4ca66af4ca3b..176e4b498e22 100644 --- a/shared/chat/inbox-and-conversation-header.tsx +++ b/shared/chat/inbox-and-conversation-header.tsx @@ -2,7 +2,6 @@ import * as C from '@/constants' import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' import type {StyleOverride} from '@/common-adapters/markdown' -import SearchRow from './inbox/search-row' import NewChatButton from './inbox/new-chat-button' import {useRoute, type RouteProp} from '@react-navigation/native' import {useUsersState} from '@/stores/users' @@ -151,13 +150,7 @@ const Header2 = () => { const leftSide = ( - {Kb.Styles.isMobile ? null : ( - - - - - - )} + {!Kb.Styles.isMobile && } ) diff --git a/shared/chat/inbox-and-conversation.tsx b/shared/chat/inbox-and-conversation.tsx index 32cad3eca94c..75d41596fdcc 100644 --- a/shared/chat/inbox-and-conversation.tsx +++ b/shared/chat/inbox-and-conversation.tsx @@ -9,7 +9,9 @@ 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' -import {InboxSearchProvider, useInboxSearchState} from './inbox/search-state' +import SearchRow from './inbox/search-row' +import {inboxWidth} from './inbox/row/sizes' +import {useInboxSearch} from './inbox/use-inbox-search' type Props = ThreadSearchRouteProps & { conversationIDKey?: T.Chat.ConversationIDKey @@ -18,7 +20,8 @@ type Props = ThreadSearchRouteProps & { function InboxAndConversationBody(props: Props) { const conversationIDKey = props.conversationIDKey ?? Chat.noConversationIDKey - const isSearching = useInboxSearchState(s => s.enabled) + const search = useInboxSearch() + const isSearching = search.isSearching const infoPanel = props.infoPanel const validConvoID = conversationIDKey && conversationIDKey !== Chat.noConversationIDKey const seenValidCIDRef = React.useRef(validConvoID ? conversationIDKey : '') @@ -44,8 +47,26 @@ function InboxAndConversationBody(props: Props) { - {!C.isTablet && isSearching ? ( - + {!C.isTablet ? ( + + + + {isSearching ? ( + + ) : ( + + )} + + ) : ( )} @@ -63,17 +84,18 @@ function InboxAndConversationBody(props: Props) { ) } -function InboxAndConversation(props: Props) { - return ( - - - - ) -} - const styles = Kb.Styles.styleSheetCreate( () => ({ + inboxBody: { + flex: 1, + minHeight: 0, + }, + inboxPane: { + backgroundColor: Kb.Styles.globalColors.blueGrey, + maxWidth: inboxWidth, + minWidth: inboxWidth, + }, infoPanel: { backgroundColor: Kb.Styles.globalColors.white, bottom: 0, @@ -85,4 +107,4 @@ const styles = Kb.Styles.styleSheetCreate( }) as const ) -export default InboxAndConversation +export default InboxAndConversationBody diff --git a/shared/chat/inbox-search/index.tsx b/shared/chat/inbox-search/index.tsx index f1ad02ff140a..1b4882aed634 100644 --- a/shared/chat/inbox-search/index.tsx +++ b/shared/chat/inbox-search/index.tsx @@ -11,9 +11,17 @@ import type * as T from '@/constants/types' import {Bot} from '../conversation/info-panel/bot' import {TeamAvatar} from '../avatars' import {inboxWidth} from '../inbox/row/sizes' -import {inboxSearchMaxTextMessages, useInboxSearchState} from '../inbox/search-state' - -type OwnProps = {header?: React.ReactElement | null} +import {inboxSearchMaxTextMessages} from '../inbox/use-inbox-search' + +type OwnProps = { + header?: React.ReactElement | null + searchInfo: T.Chat.InboxSearchInfo + select: ( + conversationIDKey?: T.Chat.ConversationIDKey, + query?: string, + selectedIndex?: number + ) => void +} type NameResult = { conversationIDKey: T.Chat.ConversationIDKey @@ -44,12 +52,7 @@ type OpenTeamResult = { type Item = NameResult | TextResult | BotResult | OpenTeamResult export default function InboxSearchContainer(ownProps: OwnProps) { - const {_inboxSearch, inboxSearchSelect} = useInboxSearchState( - C.useShallow(s => ({ - _inboxSearch: s.searchInfo, - inboxSearchSelect: s.dispatch.select, - })) - ) + const {searchInfo: _inboxSearch, select: inboxSearchSelect} = ownProps const navigateAppend = C.Router2.navigateAppend const onInstallBot = (username: string) => { navigateAppend({name: 'chatInstallBotPick', params: {botUsername: username}}) diff --git a/shared/chat/inbox/defer-loading.tsx b/shared/chat/inbox/defer-loading.tsx index bffbb514f19c..6aff6067944a 100644 --- a/shared/chat/inbox/defer-loading.tsx +++ b/shared/chat/inbox/defer-loading.tsx @@ -1,7 +1,6 @@ import * as React from 'react' import Inbox from '.' import {useIsFocused} from '@react-navigation/core' -import {InboxSearchProvider} from './search-state' // keep track of this even on unmount, else if you background / foreground you'll lose it let _everFocused = false @@ -25,10 +24,5 @@ export default function Deferred() { clearTimeout(id) } }, [isFocused, visible]) - - return visible ? ( - - - - ) : null + return visible ? : null } diff --git a/shared/chat/inbox/filter-row.tsx b/shared/chat/inbox/filter-row.tsx index ae61b7ce58a4..bce9b1bb7de3 100644 --- a/shared/chat/inbox/filter-row.tsx +++ b/shared/chat/inbox/filter-row.tsx @@ -1,40 +1,31 @@ import * as C from '@/constants' import * as React from 'react' import * as Kb from '@/common-adapters' -import {useInboxSearchState} from './search-state' type OwnProps = { + isSearching: boolean + onCancelSearch: () => void onEnsureSelection: () => void onSelectDown: () => void onSelectUp: () => void onQueryChanged: (arg0: string) => void query: string - showNewChat: boolean showSearch: boolean + startSearch: () => void } - function ConversationFilterInput(ownProps: OwnProps) { - const {onEnsureSelection, onSelectDown, onSelectUp, showSearch} = ownProps + const {isSearching, onCancelSearch, onEnsureSelection, onSelectDown, onSelectUp, showSearch} = ownProps const {onQueryChanged: onSetFilter, query: filter} = ownProps - const isSearching = useInboxSearchState(s => s.enabled) - const appendNewChatBuilder = C.Router2.appendNewChatBuilder - const startSearch = useInboxSearchState(s => s.dispatch.startSearch) - const cancelSearch = useInboxSearchState(s => s.dispatch.cancelSearch) - const onStartSearch = () => { - startSearch() - } - const onStopSearch = () => { - cancelSearch() - } + const {startSearch} = ownProps const inputRef = React.useRef(null) const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { - onStopSearch() + onCancelSearch() } else if (e.key === 'ArrowDown') { e.preventDefault() e.stopPropagation() @@ -67,7 +58,7 @@ function ConversationFilterInput(ownProps: OwnProps) { appendNewChatBuilder() } Kb.useHotKey('mod+n', onHotKeys) - Kb.useHotKey('mod+k', onStartSearch) + Kb.useHotKey('mod+k', startSearch) React.useEffect(() => { if (isSearching) { @@ -87,14 +78,19 @@ function ConversationFilterInput(ownProps: OwnProps) { valueControlled={true} focusOnMount={Kb.Styles.isMobile} onChange={onChange} - onCancel={onStopSearch} + onCancel={onCancelSearch} onKeyDown={onKeyDown} onEnterKeyDown={onEnterKeyDown} /> ) : ( - - + + {Kb.Styles.isMobile ? 'Search' : 'Search (\u2318K)'} diff --git a/shared/chat/inbox/index.native.tsx b/shared/chat/inbox/index.native.tsx index 7baef49ca501..01a4af99abe7 100644 --- a/shared/chat/inbox/index.native.tsx +++ b/shared/chat/inbox/index.native.tsx @@ -15,6 +15,7 @@ import {Alert} from 'react-native' import type {LegendListRef} from '@/common-adapters' import {makeRow} from './row' import {useOpenedRowState} from './row/opened-row-state' +import {useInboxSearch} from './use-inbox-search' import {useInboxState} from './use-inbox-state' import {type RowItem, type ViewableItemsData, viewabilityConfig, getItemType, keyExtractor, useUnreadShortcut, useScrollUnbox} from './list-helpers' @@ -49,15 +50,26 @@ const NoChats = (props: {onNewChat: () => void}) => ( ) -const HeadComponent = - type InboxProps = {conversationIDKey?: T.Chat.ConversationIDKey} function Inbox(p: InboxProps) { - const inbox = useInboxState(p.conversationIDKey) + const search = useInboxSearch() + const inbox = useInboxState(p.conversationIDKey, search.isSearching) const {onUntrustedInboxVisible, toggleSmallTeamsExpanded, selectedConversationIDKey} = inbox const {unreadIndices, unreadTotal, rows, smallTeamsExpanded, isSearching, allowShowFloatingButton} = inbox const {neverLoaded, onNewChat, inboxNumSmallRows, setInboxNumSmallRows} = inbox + const headComponent = ( + + ) const listRef = React.useRef(null) const {showFloating, showUnread, unreadCount, scrollToUnread, applyUnreadAndFloating} = @@ -150,12 +162,12 @@ function Inbox(p: InboxProps) { {isSearching ? ( - + ) : ( void + headerContext: 'chat-header' | 'inbox-header' + isSearching: boolean + moveSelectedIndex: (increment: boolean) => void + query: string + select: ( + conversationIDKey?: T.Chat.ConversationIDKey, + query?: string, + selectedIndex?: number + ) => void + setQuery: (query: string) => void + startSearch: () => void +} export default function InboxSearchRow(ownProps: OwnProps) { - const {headerContext} = ownProps - const isSearching = useInboxSearchState(s => s.enabled) + const {cancelSearch, headerContext, isSearching, moveSelectedIndex, query, select, setQuery, startSearch} = + ownProps const chatState = Chat.useChatState( C.useShallow(s => { const hasLoadedEmptyInbox = @@ -22,14 +35,6 @@ export default function InboxSearchRow(ownProps: OwnProps) { }) ) const {showEmptyInbox} = chatState - const {query, moveSelectedIndex, select, setQuery} = useInboxSearchState( - C.useShallow(s => ({ - moveSelectedIndex: s.dispatch.moveSelectedIndex, - query: s.searchInfo.query, - select: s.dispatch.select, - setQuery: s.dispatch.setQuery, - })) - ) const showStartNewChat = !C.isMobile && !isSearching && showEmptyInbox const showFilter = isSearching || !showEmptyInbox @@ -40,7 +45,6 @@ export default function InboxSearchRow(ownProps: OwnProps) { setQuery(q) } - const showNewChat = headerContext === 'chat-header' const showSearch = headerContext === 'chat-header' ? !C.isTablet : C.isMobile return ( @@ -50,13 +54,15 @@ export default function InboxSearchRow(ownProps: OwnProps) { )} {!!showFilter && ( moveSelectedIndex(false)} onSelectDown={() => moveSelectedIndex(true)} onEnsureSelection={select} onQueryChanged={onQueryChanged} query={query} - showNewChat={showNewChat} showSearch={showSearch} + startSearch={startSearch} /> )} diff --git a/shared/chat/inbox/search-state.tsx b/shared/chat/inbox/search-state.tsx deleted file mode 100644 index 4f9344bb6fd8..000000000000 --- a/shared/chat/inbox/search-state.tsx +++ /dev/null @@ -1,407 +0,0 @@ -import * as React from 'react' -import * as T from '@/constants/types' -import * as Chat from '@/stores/chat' -import * as Z from '@/util/zustand' -import {ignorePromise} from '@/constants/utils' -import {RPCError} from '@/util/errors' -import logger from '@/logger' -import {useConfigState} from '@/stores/config' -import {isMobile} from '@/constants/platform' - -export const inboxSearchMaxTextMessages = 25 -export const inboxSearchMaxTextResults = 50 -export const inboxSearchMaxNameResults = 7 -export const inboxSearchMaxUnreadNameResults = isMobile ? 5 : 10 - -export const makeInboxSearchInfo = (): T.Chat.InboxSearchInfo => ({ - botsResults: [], - botsResultsSuggested: false, - botsStatus: 'initial', - indexPercent: 0, - nameResults: [], - nameResultsUnread: false, - nameStatus: 'initial', - openTeamsResults: [], - openTeamsResultsSuggested: false, - openTeamsStatus: 'initial', - query: '', - selectedIndex: 0, - textResults: [], - textStatus: 'initial', -}) - -const getInboxSearchSelected = ( - inboxSearch: T.Immutable -): - | undefined - | { - conversationIDKey: T.Chat.ConversationIDKey - query?: string - } => { - const {selectedIndex, nameResults, botsResults, openTeamsResults, textResults} = inboxSearch - const firstTextResultIdx = botsResults.length + openTeamsResults.length + nameResults.length - const firstOpenTeamResultIdx = nameResults.length - - if (selectedIndex < firstOpenTeamResultIdx) { - const maybeNameResults = nameResults[selectedIndex] - const conversationIDKey = maybeNameResults === undefined ? undefined : maybeNameResults.conversationIDKey - if (conversationIDKey) { - return { - conversationIDKey, - query: undefined, - } - } - } else if (selectedIndex < firstTextResultIdx) { - return - } else if (selectedIndex >= firstTextResultIdx) { - const result = textResults[selectedIndex - firstTextResultIdx] - if (result) { - return { - conversationIDKey: result.conversationIDKey, - query: result.query, - } - } - } - return -} - -export const nextInboxSearchSelectedIndex = ( - inboxSearch: T.Immutable, - increment: boolean -) => { - const {selectedIndex} = inboxSearch - const totalResults = inboxSearch.nameResults.length + inboxSearch.textResults.length - if (increment && selectedIndex < totalResults - 1) { - return selectedIndex + 1 - } - if (!increment && selectedIndex > 0) { - return selectedIndex - 1 - } - return selectedIndex -} - -type Store = T.Immutable<{ - enabled: boolean - searchInfo: T.Chat.InboxSearchInfo -}> - -const initialStore: Store = { - enabled: false, - searchInfo: makeInboxSearchInfo(), -} - -type State = Store & { - dispatch: { - cancelSearch: () => void - moveSelectedIndex: (increment: boolean) => void - resetState: () => void - select: ( - conversationIDKey?: T.Chat.ConversationIDKey, - query?: string, - selectedIndex?: number - ) => void - setQuery: (query: string) => void - startSearch: () => void - } -} - -export const useInboxSearchState = Z.createZustand(set => { - let activeSearchID = 0 - - const cancelActiveSearch = () => { - const f = async () => { - try { - await T.RPCChat.localCancelActiveInboxSearchRpcPromise() - } catch {} - } - ignorePromise(f()) - } - - const isActiveSearch = (searchID: number) => - searchID === activeSearchID && useInboxSearchState.getState().enabled - - const runSearch = (query: string) => { - const searchID = ++activeSearchID - set(s => { - s.searchInfo.query = query - }) - const f = async () => { - try { - await T.RPCChat.localCancelActiveInboxSearchRpcPromise() - } catch {} - - if (!isActiveSearch(searchID) || useInboxSearchState.getState().searchInfo.query !== query) { - return - } - - const teamType = (t: T.RPCChat.TeamType) => (t === T.RPCChat.TeamType.complex ? 'big' : 'small') - - const updateIfActive = (updater: (draft: T.Chat.InboxSearchInfo) => void) => { - if (!isActiveSearch(searchID)) { - return - } - set(s => { - if (!isActiveSearch(searchID)) { - return - } - updater(s.searchInfo) - }) - } - - const onConvHits = (resp: T.RPCChat.MessageTypes['chat.1.chatUi.chatSearchConvHits']['inParam']) => { - const results = (resp.hits.hits || []).reduce>((arr, h) => { - arr.push({ - conversationIDKey: T.Chat.stringToConversationIDKey(h.convID), - name: h.name, - teamType: teamType(h.teamType), - }) - return arr - }, []) - - updateIfActive(draft => { - draft.nameResults = results - draft.nameResultsUnread = resp.hits.unreadMatches - draft.nameStatus = 'success' - }) - - const missingMetas = results.reduce>((arr, r) => { - if (!Chat.getConvoState(r.conversationIDKey).isMetaGood()) { - arr.push(r.conversationIDKey) - } - return arr - }, []) - if (missingMetas.length > 0) { - Chat.useChatState.getState().dispatch.unboxRows(missingMetas, true) - } - } - - const onOpenTeamHits = ( - resp: T.RPCChat.MessageTypes['chat.1.chatUi.chatSearchTeamHits']['inParam'] - ) => { - const results = (resp.hits.hits || []).reduce>((arr, h) => { - const {description, name, memberCount, inTeam} = h - arr.push({ - description: description ?? '', - inTeam, - memberCount, - name, - publicAdmins: [], - }) - return arr - }, []) - updateIfActive(draft => { - draft.openTeamsResultsSuggested = resp.hits.suggestedMatches - draft.openTeamsResults = T.castDraft(results) - draft.openTeamsStatus = 'success' - }) - } - - const onBotsHits = (resp: T.RPCChat.MessageTypes['chat.1.chatUi.chatSearchBotHits']['inParam']) => { - updateIfActive(draft => { - draft.botsResultsSuggested = resp.hits.suggestedMatches - draft.botsResults = T.castDraft(resp.hits.hits || []) - draft.botsStatus = 'success' - }) - } - - const onTextHit = (resp: T.RPCChat.MessageTypes['chat.1.chatUi.chatSearchInboxHit']['inParam']) => { - const {convID, convName, hits, teamType: tt, time} = resp.searchHit - const result = { - conversationIDKey: T.Chat.conversationIDToKey(convID), - name: convName, - numHits: hits?.length ?? 0, - query: resp.searchHit.query, - teamType: teamType(tt), - time, - } as const - - updateIfActive(draft => { - const textResults = draft.textResults.filter(r => r.conversationIDKey !== result.conversationIDKey) - textResults.push(result) - draft.textResults = textResults.sort((l, r) => r.time - l.time) - }) - - if ( - Chat.getConvoState(result.conversationIDKey).meta.conversationIDKey === T.Chat.noConversationIDKey - ) { - Chat.useChatState.getState().dispatch.unboxRows([result.conversationIDKey], true) - } - } - - const onStart = () => { - updateIfActive(draft => { - draft.nameStatus = 'inprogress' - draft.selectedIndex = 0 - draft.textResults = [] - draft.textStatus = 'inprogress' - draft.openTeamsStatus = 'inprogress' - draft.botsStatus = 'inprogress' - }) - } - - const onDone = () => { - updateIfActive(draft => { - draft.textStatus = 'success' - }) - } - - const onIndexStatus = ( - resp: T.RPCChat.MessageTypes['chat.1.chatUi.chatSearchIndexStatus']['inParam'] - ) => { - updateIfActive(draft => { - draft.indexPercent = resp.status.percentIndexed - }) - } - - try { - await T.RPCChat.localSearchInboxRpcListener({ - incomingCallMap: { - 'chat.1.chatUi.chatSearchBotHits': onBotsHits, - 'chat.1.chatUi.chatSearchConvHits': onConvHits, - 'chat.1.chatUi.chatSearchInboxDone': onDone, - 'chat.1.chatUi.chatSearchInboxHit': onTextHit, - 'chat.1.chatUi.chatSearchInboxStart': onStart, - 'chat.1.chatUi.chatSearchIndexStatus': onIndexStatus, - 'chat.1.chatUi.chatSearchTeamHits': onOpenTeamHits, - }, - params: { - identifyBehavior: T.RPCGen.TLFIdentifyBehavior.chatGui, - namesOnly: false, - opts: { - afterContext: 0, - beforeContext: 0, - isRegex: false, - matchMentions: false, - maxBots: 10, - maxConvsHit: inboxSearchMaxTextResults, - maxConvsSearched: 0, - maxHits: inboxSearchMaxTextMessages, - maxMessages: -1, - maxNameConvs: query.length > 0 ? inboxSearchMaxNameResults : inboxSearchMaxUnreadNameResults, - maxTeams: 10, - reindexMode: T.RPCChat.ReIndexingMode.postsearchSync, - sentAfter: 0, - sentBefore: 0, - sentBy: '', - sentTo: '', - skipBotCache: false, - }, - query, - }, - }) - } catch (error) { - if (error instanceof RPCError && error.code !== T.RPCGen.StatusCode.sccanceled) { - logger.error('search failed: ' + error.message) - updateIfActive(draft => { - draft.textStatus = 'error' - }) - } - } - } - ignorePromise(f()) - } - - const dispatch: State['dispatch'] = { - cancelSearch: () => { - activeSearchID++ - set(s => { - s.enabled = false - s.searchInfo = T.castDraft(makeInboxSearchInfo()) - }) - cancelActiveSearch() - }, - moveSelectedIndex: increment => { - set(s => { - s.searchInfo.selectedIndex = nextInboxSearchSelectedIndex(s.searchInfo, increment) - }) - }, - resetState: () => { - activeSearchID++ - set(s => { - s.enabled = false - s.searchInfo = T.castDraft(makeInboxSearchInfo()) - }) - cancelActiveSearch() - }, - select: (_conversationIDKey, q, selectedIndex) => { - let conversationIDKey = _conversationIDKey - let query = q - if (selectedIndex !== undefined) { - set(s => { - s.searchInfo.selectedIndex = selectedIndex - }) - } - - const {enabled, searchInfo} = useInboxSearchState.getState() - if (!enabled) { - return - } - - const selected = getInboxSearchSelected(searchInfo) - if (!conversationIDKey) { - conversationIDKey = selected?.conversationIDKey - } - if (!conversationIDKey) { - return - } - if (!query) { - query = selected?.query - } - - if (query) { - Chat.getConvoState(conversationIDKey).dispatch.navigateToThread( - 'inboxSearch', - undefined, - undefined, - query - ) - } else { - Chat.getConvoState(conversationIDKey).dispatch.navigateToThread('inboxSearch') - dispatch.cancelSearch() - } - }, - setQuery: query => { - if (!useInboxSearchState.getState().enabled) { - return - } - runSearch(query) - }, - startSearch: () => { - if (useInboxSearchState.getState().enabled) { - return - } - set(s => { - s.enabled = true - s.searchInfo = T.castDraft(makeInboxSearchInfo()) - }) - runSearch('') - }, - } - - return { - ...initialStore, - dispatch, - } -}) - -export const InboxSearchProvider = ({children}: {children: React.ReactNode}) => { - const mobileAppState = useConfigState(s => s.mobileAppState) - const enabled = useInboxSearchState(s => s.enabled) - const cancelSearch = useInboxSearchState(s => s.dispatch.cancelSearch) - const resetState = useInboxSearchState(s => s.dispatch.resetState) - - React.useEffect(() => { - resetState() - return () => { - resetState() - } - }, [resetState]) - - React.useEffect(() => { - if (mobileAppState === 'background' && enabled) { - cancelSearch() - } - }, [mobileAppState, enabled, cancelSearch]) - - return <>{children} -} diff --git a/shared/chat/inbox/search-state.test.tsx b/shared/chat/inbox/use-inbox-search.test.tsx similarity index 98% rename from shared/chat/inbox/search-state.test.tsx rename to shared/chat/inbox/use-inbox-search.test.tsx index 25d70f798c83..4ecab6c6f086 100644 --- a/shared/chat/inbox/search-state.test.tsx +++ b/shared/chat/inbox/use-inbox-search.test.tsx @@ -1,5 +1,5 @@ /// -import {makeInboxSearchInfo, nextInboxSearchSelectedIndex} from './search-state' +import {makeInboxSearchInfo, nextInboxSearchSelectedIndex} from './use-inbox-search' test('inbox search helpers derive stable defaults', () => { const info = makeInboxSearchInfo() diff --git a/shared/chat/inbox/use-inbox-search.tsx b/shared/chat/inbox/use-inbox-search.tsx new file mode 100644 index 000000000000..2862dea5e16d --- /dev/null +++ b/shared/chat/inbox/use-inbox-search.tsx @@ -0,0 +1,411 @@ +import {ignorePromise} from '@/constants/utils' +import * as T from '@/constants/types' +import logger from '@/logger' +import {useConfigState} from '@/stores/config' +import {RPCError} from '@/util/errors' +import {isMobile} from '@/constants/platform' +import * as Chat from '@/stores/chat' +import * as React from 'react' + +export const inboxSearchMaxTextMessages = 25 +export const inboxSearchMaxTextResults = 50 +export const inboxSearchMaxNameResults = 7 +export const inboxSearchMaxUnreadNameResults = isMobile ? 5 : 10 + +export const makeInboxSearchInfo = (): T.Chat.InboxSearchInfo => ({ + botsResults: [], + botsResultsSuggested: false, + botsStatus: 'initial', + indexPercent: 0, + nameResults: [], + nameResultsUnread: false, + nameStatus: 'initial', + openTeamsResults: [], + openTeamsResultsSuggested: false, + openTeamsStatus: 'initial', + query: '', + selectedIndex: 0, + textResults: [], + textStatus: 'initial', +}) + +const getInboxSearchSelected = ( + inboxSearch: T.Immutable +): + | undefined + | { + conversationIDKey: T.Chat.ConversationIDKey + query?: string + } => { + const {selectedIndex, nameResults, botsResults, openTeamsResults, textResults} = inboxSearch + const firstTextResultIdx = botsResults.length + openTeamsResults.length + nameResults.length + const firstOpenTeamResultIdx = nameResults.length + + if (selectedIndex < firstOpenTeamResultIdx) { + const maybeNameResults = nameResults[selectedIndex] + const conversationIDKey = maybeNameResults === undefined ? undefined : maybeNameResults.conversationIDKey + if (conversationIDKey) { + return { + conversationIDKey, + query: undefined, + } + } + } else if (selectedIndex < firstTextResultIdx) { + return + } else if (selectedIndex >= firstTextResultIdx) { + const result = textResults[selectedIndex - firstTextResultIdx] + if (result) { + return { + conversationIDKey: result.conversationIDKey, + query: result.query, + } + } + } + return +} + +export const nextInboxSearchSelectedIndex = ( + inboxSearch: T.Immutable, + increment: boolean +) => { + const {selectedIndex} = inboxSearch + const totalResults = inboxSearch.nameResults.length + inboxSearch.textResults.length + if (increment && selectedIndex < totalResults - 1) { + return selectedIndex + 1 + } + if (!increment && selectedIndex > 0) { + return selectedIndex - 1 + } + return selectedIndex +} + +type SearchInfoUpdater = (prev: T.Chat.InboxSearchInfo) => T.Chat.InboxSearchInfo + +export function useInboxSearch() { + const mobileAppState = useConfigState(s => s.mobileAppState) + const [isSearching, setIsSearching] = React.useState(false) + const [searchInfo, setSearchInfo] = React.useState(makeInboxSearchInfo) + const activeSearchIDRef = React.useRef(0) + const isSearchingRef = React.useRef(isSearching) + const searchInfoRef = React.useRef(searchInfo) + + React.useEffect(() => { + isSearchingRef.current = isSearching + }, [isSearching]) + + React.useEffect(() => { + searchInfoRef.current = searchInfo + }, [searchInfo]) + + const updateSearchInfo = React.useCallback((updater: SearchInfoUpdater) => { + setSearchInfo(prev => { + const next = updater(prev) + searchInfoRef.current = next + return next + }) + }, []) + + const cancelActiveSearch = React.useCallback(() => { + const f = async () => { + try { + await T.RPCChat.localCancelActiveInboxSearchRpcPromise() + } catch {} + } + ignorePromise(f()) + }, []) + + const clearSearch = React.useCallback(() => { + activeSearchIDRef.current++ + isSearchingRef.current = false + const next = makeInboxSearchInfo() + searchInfoRef.current = next + setIsSearching(false) + setSearchInfo(next) + cancelActiveSearch() + }, [cancelActiveSearch]) + + const isActiveSearch = React.useCallback( + (searchID: number) => searchID === activeSearchIDRef.current && isSearchingRef.current, + [] + ) + + const runSearch = React.useCallback( + (query: string) => { + const searchID = ++activeSearchIDRef.current + updateSearchInfo(prev => ({...prev, query})) + const f = async () => { + try { + await T.RPCChat.localCancelActiveInboxSearchRpcPromise() + } catch {} + + if (!isActiveSearch(searchID) || searchInfoRef.current.query !== query) { + return + } + + const teamType = (t: T.RPCChat.TeamType) => (t === T.RPCChat.TeamType.complex ? 'big' : 'small') + + const updateIfActive = (updater: SearchInfoUpdater) => { + if (!isActiveSearch(searchID)) { + return + } + updateSearchInfo(prev => { + if (!isActiveSearch(searchID)) { + return prev + } + return updater(prev) + }) + } + + const onConvHits = (resp: T.RPCChat.MessageTypes['chat.1.chatUi.chatSearchConvHits']['inParam']) => { + const results = (resp.hits.hits || []).reduce>((arr, h) => { + arr.push({ + conversationIDKey: T.Chat.stringToConversationIDKey(h.convID), + name: h.name, + teamType: teamType(h.teamType), + }) + return arr + }, []) + + updateIfActive(prev => ({ + ...prev, + nameResults: results, + nameResultsUnread: resp.hits.unreadMatches, + nameStatus: 'success', + })) + + const missingMetas = results.reduce>((arr, r) => { + if (!Chat.getConvoState(r.conversationIDKey).isMetaGood()) { + arr.push(r.conversationIDKey) + } + return arr + }, []) + if (missingMetas.length > 0) { + Chat.useChatState.getState().dispatch.unboxRows(missingMetas, true) + } + } + + const onOpenTeamHits = ( + resp: T.RPCChat.MessageTypes['chat.1.chatUi.chatSearchTeamHits']['inParam'] + ) => { + const results = (resp.hits.hits || []).reduce>((arr, h) => { + const {description, name, memberCount, inTeam} = h + arr.push({ + description: description ?? '', + inTeam, + memberCount, + name, + publicAdmins: [], + }) + return arr + }, []) + updateIfActive(prev => ({ + ...prev, + openTeamsResults: results, + openTeamsResultsSuggested: resp.hits.suggestedMatches, + openTeamsStatus: 'success', + })) + } + + const onBotsHits = (resp: T.RPCChat.MessageTypes['chat.1.chatUi.chatSearchBotHits']['inParam']) => { + updateIfActive(prev => ({ + ...prev, + botsResults: resp.hits.hits || [], + botsResultsSuggested: resp.hits.suggestedMatches, + botsStatus: 'success', + })) + } + + const onTextHit = (resp: T.RPCChat.MessageTypes['chat.1.chatUi.chatSearchInboxHit']['inParam']) => { + const {convID, convName, hits, teamType: tt, time} = resp.searchHit + const result = { + conversationIDKey: T.Chat.conversationIDToKey(convID), + name: convName, + numHits: hits?.length ?? 0, + query: resp.searchHit.query, + teamType: teamType(tt), + time, + } as const + + updateIfActive(prev => { + const textResults = prev.textResults.filter(r => r.conversationIDKey !== result.conversationIDKey) + textResults.push(result) + textResults.sort((l, r) => r.time - l.time) + return {...prev, textResults} + }) + + if ( + Chat.getConvoState(result.conversationIDKey).meta.conversationIDKey === T.Chat.noConversationIDKey + ) { + Chat.useChatState.getState().dispatch.unboxRows([result.conversationIDKey], true) + } + } + + const onStart = () => { + updateIfActive(prev => ({ + ...prev, + botsStatus: 'inprogress', + nameStatus: 'inprogress', + openTeamsStatus: 'inprogress', + selectedIndex: 0, + textResults: [], + textStatus: 'inprogress', + })) + } + + const onDone = () => { + updateIfActive(prev => ({...prev, textStatus: 'success'})) + } + + const onIndexStatus = ( + resp: T.RPCChat.MessageTypes['chat.1.chatUi.chatSearchIndexStatus']['inParam'] + ) => { + updateIfActive(prev => ({...prev, indexPercent: resp.status.percentIndexed})) + } + + try { + await T.RPCChat.localSearchInboxRpcListener({ + incomingCallMap: { + 'chat.1.chatUi.chatSearchBotHits': onBotsHits, + 'chat.1.chatUi.chatSearchConvHits': onConvHits, + 'chat.1.chatUi.chatSearchInboxDone': onDone, + 'chat.1.chatUi.chatSearchInboxHit': onTextHit, + 'chat.1.chatUi.chatSearchInboxStart': onStart, + 'chat.1.chatUi.chatSearchIndexStatus': onIndexStatus, + 'chat.1.chatUi.chatSearchTeamHits': onOpenTeamHits, + }, + params: { + identifyBehavior: T.RPCGen.TLFIdentifyBehavior.chatGui, + namesOnly: false, + opts: { + afterContext: 0, + beforeContext: 0, + isRegex: false, + matchMentions: false, + maxBots: 10, + maxConvsHit: inboxSearchMaxTextResults, + maxConvsSearched: 0, + maxHits: inboxSearchMaxTextMessages, + maxMessages: -1, + maxNameConvs: query.length > 0 ? inboxSearchMaxNameResults : inboxSearchMaxUnreadNameResults, + maxTeams: 10, + reindexMode: T.RPCChat.ReIndexingMode.postsearchSync, + sentAfter: 0, + sentBefore: 0, + sentBy: '', + sentTo: '', + skipBotCache: false, + }, + query, + }, + }) + } catch (error) { + if (error instanceof RPCError && error.code !== T.RPCGen.StatusCode.sccanceled) { + logger.error('search failed: ' + error.message) + updateIfActive(prev => ({...prev, textStatus: 'error'})) + } + } + } + ignorePromise(f()) + }, + [isActiveSearch, updateSearchInfo] + ) + + const cancelSearch = React.useCallback(() => { + clearSearch() + }, [clearSearch]) + + const moveSelectedIndex = React.useCallback((increment: boolean) => { + updateSearchInfo(prev => ({ + ...prev, + selectedIndex: nextInboxSearchSelectedIndex(prev, increment), + })) + }, [updateSearchInfo]) + + const select = React.useCallback( + (_conversationIDKey?: T.Chat.ConversationIDKey, q?: string, selectedIndex?: number) => { + let conversationIDKey = _conversationIDKey + let query = q + let latestSearchInfo = searchInfoRef.current + + if (selectedIndex !== undefined) { + latestSearchInfo = {...latestSearchInfo, selectedIndex} + searchInfoRef.current = latestSearchInfo + setSearchInfo(latestSearchInfo) + } + + if (!isSearchingRef.current) { + return + } + + const selected = getInboxSearchSelected(latestSearchInfo) + if (!conversationIDKey) { + conversationIDKey = selected?.conversationIDKey + } + if (!conversationIDKey) { + return + } + if (!query) { + query = selected?.query + } + + if (query) { + Chat.getConvoState(conversationIDKey).dispatch.navigateToThread( + 'inboxSearch', + undefined, + undefined, + query + ) + } else { + Chat.getConvoState(conversationIDKey).dispatch.navigateToThread('inboxSearch') + clearSearch() + } + }, + [clearSearch] + ) + + const setQuery = React.useCallback( + (query: string) => { + if (!isSearchingRef.current) { + return + } + runSearch(query) + }, + [runSearch] + ) + + const startSearch = React.useCallback(() => { + if (isSearchingRef.current) { + return + } + isSearchingRef.current = true + const next = makeInboxSearchInfo() + searchInfoRef.current = next + setIsSearching(true) + setSearchInfo(next) + runSearch('') + }, [runSearch]) + + React.useEffect(() => { + clearSearch() + return () => { + clearSearch() + } + }, [clearSearch]) + + React.useEffect(() => { + if (mobileAppState === 'background' && isSearchingRef.current) { + clearSearch() + } + }, [clearSearch, mobileAppState]) + + return { + cancelSearch, + isSearching, + moveSelectedIndex, + query: searchInfo.query, + searchInfo, + select, + setQuery, + startSearch, + } +} diff --git a/shared/chat/inbox/use-inbox-state.tsx b/shared/chat/inbox/use-inbox-state.tsx index cb9944ed6dbd..193ae8c6b554 100644 --- a/shared/chat/inbox/use-inbox-state.tsx +++ b/shared/chat/inbox/use-inbox-state.tsx @@ -4,13 +4,11 @@ import * as React from 'react' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useIsFocused} from '@react-navigation/core' -import {useInboxSearchState} from './search-state' -export function useInboxState(conversationIDKey?: string) { +export function useInboxState(conversationIDKey?: string, isSearching = false) { const isFocused = useIsFocused() const loggedIn = useConfigState(s => s.loggedIn) const username = useCurrentUserState(s => s.username) - const isSearching = useInboxSearchState(s => s.enabled) const chatState = Chat.useChatState( C.useShallow(s => ({ From fc37ea74ebc6d166d4767c0a36a4da35359463f9 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 10:36:01 -0400 Subject: [PATCH 18/59] WIP --- shared/chat/inbox-and-conversation-header.tsx | 15 ++- shared/chat/inbox-and-conversation.tsx | 110 ------------------ 2 files changed, 14 insertions(+), 111 deletions(-) delete mode 100644 shared/chat/inbox-and-conversation.tsx diff --git a/shared/chat/inbox-and-conversation-header.tsx b/shared/chat/inbox-and-conversation-header.tsx index 176e4b498e22..20bb67c9c6f3 100644 --- a/shared/chat/inbox-and-conversation-header.tsx +++ b/shared/chat/inbox-and-conversation-header.tsx @@ -3,6 +3,7 @@ import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' import type {StyleOverride} from '@/common-adapters/markdown' import NewChatButton from './inbox/new-chat-button' +import {setDesktopInboxSearchPortalNode} from './inbox/desktop-search-portal' import {useRoute, type RouteProp} from '@react-navigation/native' import {useUsersState} from '@/stores/users' import {useCurrentUserState} from '@/stores/current-user' @@ -150,7 +151,15 @@ const Header2 = () => { const leftSide = ( - {!Kb.Styles.isMobile && } + {!Kb.Styles.isMobile && ( + + setDesktopInboxSearchPortalNode(node)} + /> + + )} ) @@ -351,6 +360,10 @@ const styles = Kb.Styles.styleSheetCreate( }, isMobile: {paddingLeft: Kb.Styles.globalMargins.tiny}, }), + searchPortal: { + height: '100%', + width: '100%', + }, shhIconStyle: {marginLeft: Kb.Styles.globalMargins.xtiny}, }) as const ) diff --git a/shared/chat/inbox-and-conversation.tsx b/shared/chat/inbox-and-conversation.tsx deleted file mode 100644 index 75d41596fdcc..000000000000 --- a/shared/chat/inbox-and-conversation.tsx +++ /dev/null @@ -1,110 +0,0 @@ -// Just for desktop and tablet, we show inbox and conversation side by side -import * as C from '@/constants' -import * as Chat from '@/stores/chat' -import * as Kb from '@/common-adapters' -import * as React from 'react' -import type * as T from '@/constants/types' -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' -import SearchRow from './inbox/search-row' -import {inboxWidth} from './inbox/row/sizes' -import {useInboxSearch} from './inbox/use-inbox-search' - -type Props = ThreadSearchRouteProps & { - conversationIDKey?: T.Chat.ConversationIDKey - infoPanel?: {tab?: Panel} -} - -function InboxAndConversationBody(props: Props) { - const conversationIDKey = props.conversationIDKey ?? Chat.noConversationIDKey - const search = useInboxSearch() - const isSearching = search.isSearching - const infoPanel = props.infoPanel - const validConvoID = conversationIDKey && conversationIDKey !== Chat.noConversationIDKey - const seenValidCIDRef = React.useRef(validConvoID ? conversationIDKey : '') - const selectNextConvo = Chat.useChatState(s => { - if (seenValidCIDRef.current) { - return null - } - const first = s.inboxLayout?.smallTeams?.[0] - return first?.convID - }) - - React.useEffect(() => { - if (selectNextConvo && seenValidCIDRef.current !== selectNextConvo) { - seenValidCIDRef.current = selectNextConvo - // need to defer , not sure why, shouldn't be - setTimeout(() => { - Chat.getConvoState(selectNextConvo).dispatch.navigateToThread('findNewestConversationFromLayout') - }, 100) - } - }, [selectNextConvo]) - - return ( - - - - {!C.isTablet ? ( - - - - {isSearching ? ( - - ) : ( - - )} - - - ) : ( - - )} - - - - {infoPanel ? ( - - - - ) : null} - - - - ) -} - -const styles = Kb.Styles.styleSheetCreate( - () => - ({ - inboxBody: { - flex: 1, - minHeight: 0, - }, - inboxPane: { - backgroundColor: Kb.Styles.globalColors.blueGrey, - maxWidth: inboxWidth, - minWidth: inboxWidth, - }, - infoPanel: { - backgroundColor: Kb.Styles.globalColors.white, - bottom: 0, - position: 'absolute', - right: 0, - top: 0, - width: C.isTablet ? 350 : 320, - }, - }) as const -) - -export default InboxAndConversationBody From 1cdcb87783093db800f5909aa05b13597384cd10 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 10:36:05 -0400 Subject: [PATCH 19/59] WIP --- shared/chat/inbox-and-conversation-shared.tsx | 74 +++++++++++++++++++ .../chat/inbox-and-conversation.desktop.tsx | 60 +++++++++++++++ shared/chat/inbox-and-conversation.native.tsx | 11 +++ shared/chat/inbox/desktop-search-portal.tsx | 26 +++++++ 4 files changed, 171 insertions(+) create mode 100644 shared/chat/inbox-and-conversation-shared.tsx create mode 100644 shared/chat/inbox-and-conversation.desktop.tsx create mode 100644 shared/chat/inbox-and-conversation.native.tsx create mode 100644 shared/chat/inbox/desktop-search-portal.tsx diff --git a/shared/chat/inbox-and-conversation-shared.tsx b/shared/chat/inbox-and-conversation-shared.tsx new file mode 100644 index 000000000000..b2a96d8b5e29 --- /dev/null +++ b/shared/chat/inbox-and-conversation-shared.tsx @@ -0,0 +1,74 @@ +// Just for desktop and tablet, we show inbox and conversation side by side +import * as C from '@/constants' +import * as Chat from '@/stores/chat' +import * as Kb from '@/common-adapters' +import * as React from 'react' +import type * as T from '@/constants/types' +import Conversation from './conversation/container' +import InfoPanel, {type Panel} from './conversation/info-panel' +import type {ThreadSearchRouteProps} from './conversation/thread-search-route' + +export type InboxAndConversationProps = ThreadSearchRouteProps & { + conversationIDKey?: T.Chat.ConversationIDKey + infoPanel?: {tab?: Panel} +} + +type Props = InboxAndConversationProps & { + leftPane: React.ReactNode +} + +export function InboxAndConversationShell(props: Props) { + const conversationIDKey = props.conversationIDKey ?? Chat.noConversationIDKey + const infoPanel = props.infoPanel + const validConvoID = conversationIDKey && conversationIDKey !== Chat.noConversationIDKey + const seenValidCIDRef = React.useRef(validConvoID ? conversationIDKey : '') + const selectNextConvo = Chat.useChatState(s => { + if (seenValidCIDRef.current) { + return null + } + const first = s.inboxLayout?.smallTeams?.[0] + return first?.convID + }) + + React.useEffect(() => { + if (selectNextConvo && seenValidCIDRef.current !== selectNextConvo) { + seenValidCIDRef.current = selectNextConvo + // need to defer , not sure why, shouldn't be + setTimeout(() => { + Chat.getConvoState(selectNextConvo).dispatch.navigateToThread('findNewestConversationFromLayout') + }, 100) + } + }, [selectNextConvo]) + + return ( + + + + {props.leftPane} + + + + {infoPanel ? ( + + + + ) : null} + + + + ) +} + +const styles = Kb.Styles.styleSheetCreate( + () => + ({ + infoPanel: { + backgroundColor: Kb.Styles.globalColors.white, + bottom: 0, + position: 'absolute', + right: 0, + top: 0, + width: C.isTablet ? 350 : 320, + }, + }) as const +) diff --git a/shared/chat/inbox-and-conversation.desktop.tsx b/shared/chat/inbox-and-conversation.desktop.tsx new file mode 100644 index 000000000000..22f9c8baf025 --- /dev/null +++ b/shared/chat/inbox-and-conversation.desktop.tsx @@ -0,0 +1,60 @@ +import * as Kb from '@/common-adapters' +import * as React from 'react' +import {createPortal} from 'react-dom' +import Inbox from './inbox' +import InboxSearch from './inbox-search' +import {InboxAndConversationShell, type InboxAndConversationProps} from './inbox-and-conversation-shared' +import {useDesktopInboxSearchPortalNode} from './inbox/desktop-search-portal' +import SearchRow from './inbox/search-row' +import {inboxWidth} from './inbox/row/sizes' +import {useInboxSearch} from './inbox/use-inbox-search' + +export default function InboxAndConversationDesktop(props: InboxAndConversationProps) { + const conversationIDKey = props.conversationIDKey + const search = useInboxSearch() + const searchPortalNode = useDesktopInboxSearchPortalNode() + + const leftPane = ( + + {searchPortalNode + ? createPortal( + , + searchPortalNode + ) + : null} + + {search.isSearching ? ( + + ) : ( + + )} + + + ) + + return +} + +const styles = Kb.Styles.styleSheetCreate( + () => + ({ + inboxBody: { + flex: 1, + minHeight: 0, + }, + inboxPane: { + backgroundColor: Kb.Styles.globalColors.blueGrey, + maxWidth: inboxWidth, + minWidth: inboxWidth, + }, + }) as const +) diff --git a/shared/chat/inbox-and-conversation.native.tsx b/shared/chat/inbox-and-conversation.native.tsx new file mode 100644 index 000000000000..8fae9a4df839 --- /dev/null +++ b/shared/chat/inbox-and-conversation.native.tsx @@ -0,0 +1,11 @@ +import Inbox from './inbox' +import {InboxAndConversationShell, type InboxAndConversationProps} from './inbox-and-conversation-shared' + +export default function InboxAndConversationNative(props: InboxAndConversationProps) { + return ( + } + /> + ) +} diff --git a/shared/chat/inbox/desktop-search-portal.tsx b/shared/chat/inbox/desktop-search-portal.tsx new file mode 100644 index 000000000000..f22e7b08b059 --- /dev/null +++ b/shared/chat/inbox/desktop-search-portal.tsx @@ -0,0 +1,26 @@ +import * as React from 'react' + +let portalNode: HTMLDivElement | null = null +const listeners = new Set<() => void>() + +const notify = () => { + listeners.forEach(listener => listener()) +} + +const subscribe = (listener: () => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export const useDesktopInboxSearchPortalNode = () => + React.useSyncExternalStore(subscribe, () => portalNode, () => null) + +export const setDesktopInboxSearchPortalNode = (node: HTMLDivElement | null) => { + if (portalNode === node) { + return + } + portalNode = node + notify() +} From 008a4e7c04a7fd293eddc1656639ad91aff33ab2 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 10:39:54 -0400 Subject: [PATCH 20/59] WIP --- .../chat/conversation/thread-search-route.ts | 3 +- shared/chat/inbox-and-conversation-header.tsx | 13 +++------ shared/chat/inbox-and-conversation-shared.tsx | 2 ++ .../chat/inbox-and-conversation.desktop.tsx | 1 - shared/chat/inbox/desktop-search-portal.tsx | 4 +-- shared/chat/routes.tsx | 28 +++++++++++-------- 6 files changed, 27 insertions(+), 24 deletions(-) diff --git a/shared/chat/conversation/thread-search-route.ts b/shared/chat/conversation/thread-search-route.ts index d36dc0e84353..2397a4660d9e 100644 --- a/shared/chat/conversation/thread-search-route.ts +++ b/shared/chat/conversation/thread-search-route.ts @@ -11,5 +11,6 @@ export type ThreadSearchRouteProps = { export const useThreadSearchRoute = () => { const route = useRoute | RootRouteProps<'chatRoot'>>() - return getRouteParamsFromRoute<'chatConversation' | 'chatRoot'>(route)?.threadSearch + const params = getRouteParamsFromRoute<'chatConversation' | 'chatRoot'>(route) + return params && 'threadSearch' in params ? params.threadSearch : undefined } diff --git a/shared/chat/inbox-and-conversation-header.tsx b/shared/chat/inbox-and-conversation-header.tsx index 20bb67c9c6f3..e7fe5aaf7fdc 100644 --- a/shared/chat/inbox-and-conversation-header.tsx +++ b/shared/chat/inbox-and-conversation-header.tsx @@ -3,18 +3,14 @@ import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' import type {StyleOverride} from '@/common-adapters/markdown' import NewChatButton from './inbox/new-chat-button' +import type {ChatRootRouteParams} from './inbox-and-conversation' import {setDesktopInboxSearchPortalNode} from './inbox/desktop-search-portal' 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 = ThreadSearchRouteProps & { - conversationIDKey?: string - infoPanel?: object -} -type ChatRootRoute = RouteProp<{chatRoot: ChatRootParams}, 'chatRoot'> +type ChatRootRoute = RouteProp<{chatRoot: ChatRootRouteParams}, 'chatRoot'> const Header = () => { const {params} = useRoute() @@ -153,9 +149,8 @@ const Header2 = () => { {!Kb.Styles.isMobile && ( - setDesktopInboxSearchPortalNode(node)} /> diff --git a/shared/chat/inbox-and-conversation-shared.tsx b/shared/chat/inbox-and-conversation-shared.tsx index b2a96d8b5e29..0a932f563c2f 100644 --- a/shared/chat/inbox-and-conversation-shared.tsx +++ b/shared/chat/inbox-and-conversation-shared.tsx @@ -13,6 +13,8 @@ export type InboxAndConversationProps = ThreadSearchRouteProps & { infoPanel?: {tab?: Panel} } +export type ChatRootRouteParams = InboxAndConversationProps + type Props = InboxAndConversationProps & { leftPane: React.ReactNode } diff --git a/shared/chat/inbox-and-conversation.desktop.tsx b/shared/chat/inbox-and-conversation.desktop.tsx index 22f9c8baf025..9d7e6a5b7555 100644 --- a/shared/chat/inbox-and-conversation.desktop.tsx +++ b/shared/chat/inbox-and-conversation.desktop.tsx @@ -1,5 +1,4 @@ import * as Kb from '@/common-adapters' -import * as React from 'react' import {createPortal} from 'react-dom' import Inbox from './inbox' import InboxSearch from './inbox-search' diff --git a/shared/chat/inbox/desktop-search-portal.tsx b/shared/chat/inbox/desktop-search-portal.tsx index f22e7b08b059..b87162237491 100644 --- a/shared/chat/inbox/desktop-search-portal.tsx +++ b/shared/chat/inbox/desktop-search-portal.tsx @@ -1,6 +1,6 @@ import * as React from 'react' -let portalNode: HTMLDivElement | null = null +let portalNode: HTMLElement | null = null const listeners = new Set<() => void>() const notify = () => { @@ -17,7 +17,7 @@ const subscribe = (listener: () => void) => { export const useDesktopInboxSearchPortalNode = () => React.useSyncExternalStore(subscribe, () => portalNode, () => null) -export const setDesktopInboxSearchPortalNode = (node: HTMLDivElement | null) => { +export const setDesktopInboxSearchPortalNode = (node: HTMLElement | null) => { if (portalNode === node) { return } diff --git a/shared/chat/routes.tsx b/shared/chat/routes.tsx index cf5b5b0c599c..e8f343416ed1 100644 --- a/shared/chat/routes.tsx +++ b/shared/chat/routes.tsx @@ -11,8 +11,9 @@ import {useModalHeaderState} from '@/stores/modal-header' import {ModalTitle} from '@/teams/common' import inboxGetOptions from './inbox/get-options' import inboxAndConvoGetOptions from './inbox-and-conversation-get-options' -import {defineRouteMap} from '@/constants/types/router' +import {defineRouteMap, withRouteParams} from '@/constants/types/router' import type {BlockModalContext} from './blocking/block-modal' +import type {ChatRootRouteParams} from './inbox-and-conversation' const Convo = React.lazy(async () => import('./conversation/container')) type ChatBlockingRouteParams = { @@ -36,6 +37,7 @@ type ChatShowNewTeamDialogRouteParams = { const emptyChatBlockingRouteParams: ChatBlockingRouteParams = {} const emptyChatSearchBotsRouteParams: ChatSearchBotsRouteParams = {} const emptyChatShowNewTeamDialogRouteParams: ChatShowNewTeamDialogRouteParams = {} +const emptyChatRootRouteParams: ChatRootRouteParams = {} const PDFShareButton = ({url}: {url?: string}) => { const showShareActionSheet = useConfigState(s => s.dispatch.defer.showShareActionSheet) @@ -123,18 +125,22 @@ export const newRoutes = defineRouteMap({ }, chatRoot: Chat.isSplit ? { - ...Chat.makeChatScreen(React.lazy(async () => import('./inbox-and-conversation')), { - getOptions: inboxAndConvoGetOptions, - skipProvider: true, - }), - initialParams: {}, + ...withRouteParams( + Chat.makeChatScreen(React.lazy(async () => import('./inbox-and-conversation')), { + getOptions: inboxAndConvoGetOptions, + skipProvider: true, + }) + ), + initialParams: emptyChatRootRouteParams, } : { - ...Chat.makeChatScreen(React.lazy(async () => import('./inbox/defer-loading')), { - getOptions: inboxGetOptions, - skipProvider: true, - }), - initialParams: {}, + ...withRouteParams( + Chat.makeChatScreen(React.lazy(async () => import('./inbox/defer-loading')), { + getOptions: inboxGetOptions, + skipProvider: true, + }) + ), + initialParams: emptyChatRootRouteParams, }, }) From bb18a833220522d0dc31998ba9ae2f72dbda9520 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 10:40:01 -0400 Subject: [PATCH 21/59] WIP --- shared/chat/inbox-and-conversation.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 shared/chat/inbox-and-conversation.tsx diff --git a/shared/chat/inbox-and-conversation.tsx b/shared/chat/inbox-and-conversation.tsx new file mode 100644 index 000000000000..5b70671c542c --- /dev/null +++ b/shared/chat/inbox-and-conversation.tsx @@ -0,0 +1,8 @@ +import * as C from '@/constants' +import Desktop from './inbox-and-conversation.desktop' +import Native from './inbox-and-conversation.native' + +const InboxAndConversation = C.isMobile ? Native : Desktop + +export default InboxAndConversation +export type {ChatRootRouteParams, InboxAndConversationProps} from './inbox-and-conversation-shared' From da3066ec324f3f3c54680fc73fc67f4752038771 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 10:41:44 -0400 Subject: [PATCH 22/59] WIP --- shared/chat/conversation/input-area/normal/index.tsx | 4 +++- shared/chat/conversation/thread-search-route.ts | 4 ++-- shared/chat/inbox-and-conversation.tsx | 5 ++++- shared/chat/inbox/defer-loading.tsx | 3 ++- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/shared/chat/conversation/input-area/normal/index.tsx b/shared/chat/conversation/input-area/normal/index.tsx index 3c17dbc095e4..09a4b1d4af4c 100644 --- a/shared/chat/conversation/input-area/normal/index.tsx +++ b/shared/chat/conversation/input-area/normal/index.tsx @@ -112,7 +112,9 @@ const doInjectText = (inputRef: React.RefObject, text: string, const ConnectedPlatformInput = function ConnectedPlatformInput() { const route = useRoute | RootRouteProps<'chatRoot'>>() const infoPanelShowing = - route.name === 'chatRoot' && 'infoPanel' in route.params ? !!route.params.infoPanel : false + route.name === 'chatRoot' && !!route.params && 'infoPanel' in route.params + ? !!route.params.infoPanel + : false const uiData = Chat.useChatUIContext( C.useShallow(s => ({ editOrdinal: s.editing, diff --git a/shared/chat/conversation/thread-search-route.ts b/shared/chat/conversation/thread-search-route.ts index 2397a4660d9e..e09128aa450f 100644 --- a/shared/chat/conversation/thread-search-route.ts +++ b/shared/chat/conversation/thread-search-route.ts @@ -9,8 +9,8 @@ export type ThreadSearchRouteProps = { threadSearch?: ThreadSearchRoute } -export const useThreadSearchRoute = () => { +export const useThreadSearchRoute = (): ThreadSearchRoute | undefined => { const route = useRoute | RootRouteProps<'chatRoot'>>() const params = getRouteParamsFromRoute<'chatConversation' | 'chatRoot'>(route) - return params && 'threadSearch' in params ? params.threadSearch : undefined + return params && typeof params === 'object' && 'threadSearch' in params ? params.threadSearch : undefined } diff --git a/shared/chat/inbox-and-conversation.tsx b/shared/chat/inbox-and-conversation.tsx index 5b70671c542c..e18500a7c574 100644 --- a/shared/chat/inbox-and-conversation.tsx +++ b/shared/chat/inbox-and-conversation.tsx @@ -1,8 +1,11 @@ import * as C from '@/constants' import Desktop from './inbox-and-conversation.desktop' import Native from './inbox-and-conversation.native' +import type {InboxAndConversationProps} from './inbox-and-conversation-shared' -const InboxAndConversation = C.isMobile ? Native : Desktop +function InboxAndConversation(props: InboxAndConversationProps) { + return C.isMobile ? : +} export default InboxAndConversation export type {ChatRootRouteParams, InboxAndConversationProps} from './inbox-and-conversation-shared' diff --git a/shared/chat/inbox/defer-loading.tsx b/shared/chat/inbox/defer-loading.tsx index 6aff6067944a..937195845882 100644 --- a/shared/chat/inbox/defer-loading.tsx +++ b/shared/chat/inbox/defer-loading.tsx @@ -1,11 +1,12 @@ import * as React from 'react' import Inbox from '.' import {useIsFocused} from '@react-navigation/core' +import type {ChatRootRouteParams} from '../inbox-and-conversation' // keep track of this even on unmount, else if you background / foreground you'll lose it let _everFocused = false -export default function Deferred() { +export default function Deferred(_props: ChatRootRouteParams) { const [visible, setVisible] = React.useState(_everFocused) const isFocused = useIsFocused() React.useEffect(() => { From 63a864b93c35478c6aa20d75782bb0fb9dedde73 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 10:54:50 -0400 Subject: [PATCH 23/59] WIP --- shared/chat/routes.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/shared/chat/routes.tsx b/shared/chat/routes.tsx index e8f343416ed1..d3021e15e306 100644 --- a/shared/chat/routes.tsx +++ b/shared/chat/routes.tsx @@ -15,6 +15,12 @@ import {defineRouteMap, withRouteParams} from '@/constants/types/router' import type {BlockModalContext} from './blocking/block-modal' import type {ChatRootRouteParams} from './inbox-and-conversation' const Convo = React.lazy(async () => import('./conversation/container')) +const InboxAndConversation = React.lazy>( + async () => import('./inbox-and-conversation') +) +const InboxDeferLoading = React.lazy>( + async () => import('./inbox/defer-loading') +) type ChatBlockingRouteParams = { blockUserByDefault?: boolean @@ -126,7 +132,7 @@ export const newRoutes = defineRouteMap({ chatRoot: Chat.isSplit ? { ...withRouteParams( - Chat.makeChatScreen(React.lazy(async () => import('./inbox-and-conversation')), { + Chat.makeChatScreen(InboxAndConversation, { getOptions: inboxAndConvoGetOptions, skipProvider: true, }) @@ -135,7 +141,7 @@ export const newRoutes = defineRouteMap({ } : { ...withRouteParams( - Chat.makeChatScreen(React.lazy(async () => import('./inbox/defer-loading')), { + Chat.makeChatScreen(InboxDeferLoading, { getOptions: inboxGetOptions, skipProvider: true, }) From 48ab3fee6433607d909f002b3ea2532c425aba6d Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 10:56:05 -0400 Subject: [PATCH 24/59] WIP --- shared/chat/routes.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/shared/chat/routes.tsx b/shared/chat/routes.tsx index d3021e15e306..f5ec04d8def9 100644 --- a/shared/chat/routes.tsx +++ b/shared/chat/routes.tsx @@ -15,12 +15,16 @@ import {defineRouteMap, withRouteParams} from '@/constants/types/router' import type {BlockModalContext} from './blocking/block-modal' import type {ChatRootRouteParams} from './inbox-and-conversation' const Convo = React.lazy(async () => import('./conversation/container')) -const InboxAndConversation = React.lazy>( - async () => import('./inbox-and-conversation') -) -const InboxDeferLoading = React.lazy>( - async () => import('./inbox/defer-loading') -) +const InboxAndConversation = React.lazy(async () => { + const mod = await import('./inbox-and-conversation') + const Screen = (props: ChatRootRouteParams) => + return {default: Screen} +}) +const InboxDeferLoading = React.lazy(async () => { + const mod = await import('./inbox/defer-loading') + const Screen = (props: ChatRootRouteParams) => + return {default: Screen} +}) type ChatBlockingRouteParams = { blockUserByDefault?: boolean From a7a396cdf88abb313826e7ebd0d5cc9d81693004 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 10:58:22 -0400 Subject: [PATCH 25/59] WIP --- shared/chat/routes.tsx | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/shared/chat/routes.tsx b/shared/chat/routes.tsx index f5ec04d8def9..35cbf8168a49 100644 --- a/shared/chat/routes.tsx +++ b/shared/chat/routes.tsx @@ -9,21 +9,24 @@ import {headerNavigationOptions} from './conversation/header-area' import {useConfigState} from '@/stores/config' import {useModalHeaderState} from '@/stores/modal-header' import {ModalTitle} from '@/teams/common' +import type {StaticScreenProps} from '@react-navigation/core' import inboxGetOptions from './inbox/get-options' import inboxAndConvoGetOptions from './inbox-and-conversation-get-options' -import {defineRouteMap, withRouteParams} from '@/constants/types/router' +import {defineRouteMap} from '@/constants/types/router' import type {BlockModalContext} from './blocking/block-modal' import type {ChatRootRouteParams} from './inbox-and-conversation' const Convo = React.lazy(async () => import('./conversation/container')) -const InboxAndConversation = React.lazy(async () => { +const ChatRootScreen = React.lazy(async () => { const mod = await import('./inbox-and-conversation') - const Screen = (props: ChatRootRouteParams) => - return {default: Screen} + return { + default: (p: StaticScreenProps) => , + } }) -const InboxDeferLoading = React.lazy(async () => { +const ChatRootDeferredScreen = React.lazy(async () => { const mod = await import('./inbox/defer-loading') - const Screen = (props: ChatRootRouteParams) => - return {default: Screen} + return { + default: (p: StaticScreenProps) => , + } }) type ChatBlockingRouteParams = { @@ -135,21 +138,13 @@ export const newRoutes = defineRouteMap({ }, chatRoot: Chat.isSplit ? { - ...withRouteParams( - Chat.makeChatScreen(InboxAndConversation, { - getOptions: inboxAndConvoGetOptions, - skipProvider: true, - }) - ), + getOptions: inboxAndConvoGetOptions, + screen: ChatRootScreen, initialParams: emptyChatRootRouteParams, } : { - ...withRouteParams( - Chat.makeChatScreen(InboxDeferLoading, { - getOptions: inboxGetOptions, - skipProvider: true, - }) - ), + getOptions: inboxGetOptions, + screen: ChatRootDeferredScreen, initialParams: emptyChatRootRouteParams, }, }) From 978473949751932b2cf00573771028bb4ec2dc23 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 10 Apr 2026 10:59:22 -0400 Subject: [PATCH 26/59] WIP --- shared/chat/routes.tsx | 135 ++++++++++++++++++++++++++++------------- 1 file changed, 94 insertions(+), 41 deletions(-) diff --git a/shared/chat/routes.tsx b/shared/chat/routes.tsx index 35cbf8168a49..83b0c8aabd02 100644 --- a/shared/chat/routes.tsx +++ b/shared/chat/routes.tsx @@ -19,13 +19,13 @@ const Convo = React.lazy(async () => import('./conversation/container')) const ChatRootScreen = React.lazy(async () => { const mod = await import('./inbox-and-conversation') return { - default: (p: StaticScreenProps) => , + default: (p: StaticScreenProps) => , } }) const ChatRootDeferredScreen = React.lazy(async () => { const mod = await import('./inbox/defer-loading') return { - default: (p: StaticScreenProps) => , + default: (p: StaticScreenProps) => , } }) @@ -55,10 +55,7 @@ const emptyChatRootRouteParams: ChatRootRouteParams = {} const PDFShareButton = ({url}: {url?: string}) => { const showShareActionSheet = useConfigState(s => s.dispatch.defer.showShareActionSheet) return ( - showShareActionSheet?.(url ?? '', '', 'application/pdf')} - /> + showShareActionSheet?.(url ?? '', '', 'application/pdf')} /> ) } @@ -79,16 +76,38 @@ const BotInstallHeaderTitle = () => { const BotInstallHeaderLeft = () => { const {subScreen, inTeam, readOnly, onAction} = useModalHeaderState( - C.useShallow(s => ({inTeam: s.botInTeam, onAction: s.onAction, readOnly: s.botReadOnly, subScreen: s.botSubScreen})) + C.useShallow(s => ({ + inTeam: s.botInTeam, + onAction: s.onAction, + readOnly: s.botReadOnly, + subScreen: s.botSubScreen, + })) ) if (subScreen === 'channels') { - return Back + return ( + + Back + + ) } if (Kb.Styles.isMobile || subScreen === 'install') { - const label = subScreen === 'install' - ? (Kb.Styles.isMobile ? 'Back' : ) - : inTeam || readOnly ? 'Close' : 'Cancel' - return {label} + const label = + subScreen === 'install' ? ( + Kb.Styles.isMobile ? ( + 'Back' + ) : ( + + ) + ) : inTeam || readOnly ? ( + 'Close' + ) : ( + 'Cancel' + ) + return ( + + {label} + + ) } return null } @@ -120,9 +139,17 @@ const SendToChatHeaderLeft = ({canBack}: {canBack?: boolean}) => { const clearModals = C.Router2.clearModals const navigateUp = C.Router2.navigateUp if (canBack) { - return Back + return ( + + Back + + ) } - return Cancel + return ( + + Cancel + + ) } export const newRoutes = defineRouteMap({ @@ -139,13 +166,13 @@ export const newRoutes = defineRouteMap({ chatRoot: Chat.isSplit ? { getOptions: inboxAndConvoGetOptions, - screen: ChatRootScreen, initialParams: emptyChatRootRouteParams, + screen: ChatRootScreen, } : { getOptions: inboxGetOptions, - screen: ChatRootDeferredScreen, initialParams: emptyChatRootRouteParams, + screen: ChatRootDeferredScreen, }, }) @@ -166,7 +193,13 @@ export const newModalRoutes = defineRouteMap({ ...(C.isIOS ? {orientation: 'all', presentation: 'transparentModal'} : {}), headerShown: false, modalStyle: {flex: 1, maxHeight: 9999, width: '100%'}, - overlayStyle: {alignSelf: 'stretch', paddingBottom: 16, paddingLeft: 40, paddingRight: 40, paddingTop: 40}, + overlayStyle: { + alignSelf: 'stretch', + paddingBottom: 16, + paddingLeft: 40, + paddingRight: 40, + paddingTop: 40, + }, safeAreaStyle: {backgroundColor: 'black'}, // true black }, } @@ -176,29 +209,43 @@ export const newModalRoutes = defineRouteMap({ {getOptions: {modalStyle: {height: 660, maxHeight: 660}}} ), chatBlockingModal: { - ...Chat.makeChatScreen(React.lazy(async () => import('./blocking/block-modal')), { - getOptions: { - headerTitle: () => , - }, - }), + ...Chat.makeChatScreen( + React.lazy(async () => import('./blocking/block-modal')), + { + getOptions: { + headerTitle: () => ( + + ), + }, + } + ), initialParams: emptyChatBlockingRouteParams, }, - chatChooseEmoji: Chat.makeChatScreen(React.lazy(async () => import('./emoji-picker/container')), { - getOptions: {headerShown: false}, - }), + chatChooseEmoji: Chat.makeChatScreen( + React.lazy(async () => import('./emoji-picker/container')), + { + getOptions: {headerShown: false}, + } + ), chatConfirmNavigateExternal: Chat.makeChatScreen( React.lazy(async () => import('./punycode-link-warning')), {skipProvider: true} ), - chatConfirmRemoveBot: Chat.makeChatScreen(React.lazy(async () => import('./conversation/bot/confirm')), {canBeNullConvoID: true}), + chatConfirmRemoveBot: Chat.makeChatScreen( + React.lazy(async () => import('./conversation/bot/confirm')), + {canBeNullConvoID: true} + ), chatCreateChannel: Chat.makeChatScreen( React.lazy(async () => import('./create-channel')), {skipProvider: true} ), chatDeleteHistoryWarning: Chat.makeChatScreen(React.lazy(async () => import('./delete-history-warning'))), - chatForwardMsgPick: Chat.makeChatScreen(React.lazy(async () => import('./conversation/fwd-msg')), { - getOptions: {headerTitle: () => }, - }), + chatForwardMsgPick: Chat.makeChatScreen( + React.lazy(async () => import('./conversation/fwd-msg')), + { + getOptions: {headerTitle: () => }, + } + ), chatInfoPanel: Chat.makeChatScreen( React.lazy(async () => import('./conversation/info-panel')), {getOptions: C.isMobile ? undefined : {modalStyle: {height: '80%', width: '80%'}}} @@ -229,19 +276,25 @@ export const newModalRoutes = defineRouteMap({ }) ), chatNewChat, - chatPDF: Chat.makeChatScreen(React.lazy(async () => import('./pdf')), { - getOptions: p => ({ - headerRight: C.isMobile ? () => : undefined, - headerTitle: () => , - modalStyle: {height: '80%', maxHeight: '80%', width: '80%'}, - overlayStyle: {alignSelf: 'stretch'}, - }), - }), + chatPDF: Chat.makeChatScreen( + React.lazy(async () => import('./pdf')), + { + getOptions: p => ({ + headerRight: C.isMobile ? () => : undefined, + headerTitle: () => , + modalStyle: {height: '80%', maxHeight: '80%', width: '80%'}, + overlayStyle: {alignSelf: 'stretch'}, + }), + } + ), chatSearchBots: { - ...Chat.makeChatScreen(React.lazy(async () => import('./conversation/bot/search')), { - canBeNullConvoID: true, - getOptions: {title: 'Add a bot'}, - }), + ...Chat.makeChatScreen( + React.lazy(async () => import('./conversation/bot/search')), + { + canBeNullConvoID: true, + getOptions: {title: 'Add a bot'}, + } + ), initialParams: emptyChatSearchBotsRouteParams, }, chatSendToChat: Chat.makeChatScreen( From db35b24cc449d6d04822bf773907ac66a529d630 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 11:09:30 -0400 Subject: [PATCH 27/59] WIP --- shared/chat/inbox-search/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/shared/chat/inbox-search/index.tsx b/shared/chat/inbox-search/index.tsx index 1b4882aed634..3fdcc764ca8e 100644 --- a/shared/chat/inbox-search/index.tsx +++ b/shared/chat/inbox-search/index.tsx @@ -344,6 +344,7 @@ export default function InboxSearchContainer(ownProps: OwnProps) { section.renderHeader(section)} keyboardShouldPersistTaps="handled" From 7a2c6800974ce9c628327ad37053bc02c82adc3b Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 11:26:20 -0400 Subject: [PATCH 28/59] WIP --- plans/inbox.md | 5 ++ shared/chat/inbox-and-conversation-header.tsx | 14 +--- .../chat/inbox-and-conversation.desktop.tsx | 51 +------------- shared/chat/inbox-search/index.tsx | 15 ++--- shared/chat/inbox/defer-loading.tsx | 4 +- shared/chat/inbox/desktop-search-portal.tsx | 26 -------- shared/chat/inbox/index.d.ts | 6 +- shared/chat/inbox/index.desktop.tsx | 66 ++++++++++++------- shared/chat/inbox/index.native.tsx | 24 +++---- shared/chat/inbox/search-row.tsx | 27 +++----- shared/chat/inbox/use-inbox-search.tsx | 23 ++++++- 11 files changed, 101 insertions(+), 160 deletions(-) delete mode 100644 shared/chat/inbox/desktop-search-portal.tsx diff --git a/plans/inbox.md b/plans/inbox.md index 9f75007d6ba7..39cabee2627a 100644 --- a/plans/inbox.md +++ b/plans/inbox.md @@ -7,6 +7,11 @@ The current inbox search setup uses `shared/chat/inbox/search-state.tsx` as a te 1. Move desktop search out of the navigator header and into the inbox pane so desktop search can be owned by ordinary React state in one tree. 2. Follow with a broader cleanup that unifies native and desktop inbox search ownership and reduces remaining platform divergence where practical. +## Progress + +- [x] Part 1 completed. +- [x] Part 2 completed: desktop and native inbox screens now consume the same route-owned inbox-search controller contract, and inbox-search state is no longer owned by `shared/stores/chat.tsx`. + ## Part 1: Move Desktop Search Out Of The Header Goal: diff --git a/shared/chat/inbox-and-conversation-header.tsx b/shared/chat/inbox-and-conversation-header.tsx index e7fe5aaf7fdc..73819700fbb8 100644 --- a/shared/chat/inbox-and-conversation-header.tsx +++ b/shared/chat/inbox-and-conversation-header.tsx @@ -4,7 +4,6 @@ import * as Kb from '@/common-adapters' import type {StyleOverride} from '@/common-adapters/markdown' import NewChatButton from './inbox/new-chat-button' import type {ChatRootRouteParams} from './inbox-and-conversation' -import {setDesktopInboxSearchPortalNode} from './inbox/desktop-search-portal' import {useRoute, type RouteProp} from '@react-navigation/native' import {useUsersState} from '@/stores/users' import {useCurrentUserState} from '@/stores/current-user' @@ -147,14 +146,7 @@ const Header2 = () => { const leftSide = ( - {!Kb.Styles.isMobile && ( - -

setDesktopInboxSearchPortalNode(node)} - /> - - )} + {!Kb.Styles.isMobile && } ) @@ -355,10 +347,6 @@ const styles = Kb.Styles.styleSheetCreate( }, isMobile: {paddingLeft: Kb.Styles.globalMargins.tiny}, }), - searchPortal: { - height: '100%', - width: '100%', - }, shhIconStyle: {marginLeft: Kb.Styles.globalMargins.xtiny}, }) as const ) diff --git a/shared/chat/inbox-and-conversation.desktop.tsx b/shared/chat/inbox-and-conversation.desktop.tsx index 9d7e6a5b7555..83a2ad54787d 100644 --- a/shared/chat/inbox-and-conversation.desktop.tsx +++ b/shared/chat/inbox-and-conversation.desktop.tsx @@ -1,59 +1,10 @@ -import * as Kb from '@/common-adapters' -import {createPortal} from 'react-dom' import Inbox from './inbox' -import InboxSearch from './inbox-search' import {InboxAndConversationShell, type InboxAndConversationProps} from './inbox-and-conversation-shared' -import {useDesktopInboxSearchPortalNode} from './inbox/desktop-search-portal' -import SearchRow from './inbox/search-row' -import {inboxWidth} from './inbox/row/sizes' import {useInboxSearch} from './inbox/use-inbox-search' export default function InboxAndConversationDesktop(props: InboxAndConversationProps) { - const conversationIDKey = props.conversationIDKey const search = useInboxSearch() - const searchPortalNode = useDesktopInboxSearchPortalNode() - - const leftPane = ( - - {searchPortalNode - ? createPortal( - , - searchPortalNode - ) - : null} - - {search.isSearching ? ( - - ) : ( - - )} - - - ) + const leftPane = return } - -const styles = Kb.Styles.styleSheetCreate( - () => - ({ - inboxBody: { - flex: 1, - minHeight: 0, - }, - inboxPane: { - backgroundColor: Kb.Styles.globalColors.blueGrey, - maxWidth: inboxWidth, - minWidth: inboxWidth, - }, - }) as const -) diff --git a/shared/chat/inbox-search/index.tsx b/shared/chat/inbox-search/index.tsx index 3fdcc764ca8e..5878e393dcb7 100644 --- a/shared/chat/inbox-search/index.tsx +++ b/shared/chat/inbox-search/index.tsx @@ -11,16 +11,11 @@ import type * as T from '@/constants/types' import {Bot} from '../conversation/info-panel/bot' import {TeamAvatar} from '../avatars' import {inboxWidth} from '../inbox/row/sizes' -import {inboxSearchMaxTextMessages} from '../inbox/use-inbox-search' +import {inboxSearchMaxTextMessages, type InboxSearchController} from '../inbox/use-inbox-search' type OwnProps = { header?: React.ReactElement | null - searchInfo: T.Chat.InboxSearchInfo - select: ( - conversationIDKey?: T.Chat.ConversationIDKey, - query?: string, - selectedIndex?: number - ) => void + search: Pick } type NameResult = { @@ -52,7 +47,9 @@ type OpenTeamResult = { type Item = NameResult | TextResult | BotResult | OpenTeamResult export default function InboxSearchContainer(ownProps: OwnProps) { - const {searchInfo: _inboxSearch, select: inboxSearchSelect} = ownProps + const { + search: {searchInfo: _inboxSearch, selectResult}, + } = ownProps const navigateAppend = C.Router2.navigateAppend const onInstallBot = (username: string) => { navigateAppend({name: 'chatInstallBotPick', params: {botUsername: username}}) @@ -62,7 +59,7 @@ export default function InboxSearchContainer(ownProps: OwnProps) { selectedIndex: number, query: string ) => { - inboxSearchSelect(conversationIDKey, query.length > 0 ? query : undefined, selectedIndex) + selectResult(conversationIDKey, query.length > 0 ? query : undefined, selectedIndex) } const {header} = ownProps const {indexPercent, nameResults: _nameResults, nameResultsUnread, nameStatus, textStatus} = _inboxSearch diff --git a/shared/chat/inbox/defer-loading.tsx b/shared/chat/inbox/defer-loading.tsx index 937195845882..04596ee72041 100644 --- a/shared/chat/inbox/defer-loading.tsx +++ b/shared/chat/inbox/defer-loading.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import Inbox from '.' import {useIsFocused} from '@react-navigation/core' import type {ChatRootRouteParams} from '../inbox-and-conversation' +import {useInboxSearch} from './use-inbox-search' // keep track of this even on unmount, else if you background / foreground you'll lose it let _everFocused = false @@ -9,6 +10,7 @@ let _everFocused = false export default function Deferred(_props: ChatRootRouteParams) { const [visible, setVisible] = React.useState(_everFocused) const isFocused = useIsFocused() + const search = useInboxSearch() React.useEffect(() => { _everFocused = _everFocused || isFocused }, [isFocused]) @@ -25,5 +27,5 @@ export default function Deferred(_props: ChatRootRouteParams) { clearTimeout(id) } }, [isFocused, visible]) - return visible ? : null + return visible ? : null } diff --git a/shared/chat/inbox/desktop-search-portal.tsx b/shared/chat/inbox/desktop-search-portal.tsx deleted file mode 100644 index b87162237491..000000000000 --- a/shared/chat/inbox/desktop-search-portal.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import * as React from 'react' - -let portalNode: HTMLElement | null = null -const listeners = new Set<() => void>() - -const notify = () => { - listeners.forEach(listener => listener()) -} - -const subscribe = (listener: () => void) => { - listeners.add(listener) - return () => { - listeners.delete(listener) - } -} - -export const useDesktopInboxSearchPortalNode = () => - React.useSyncExternalStore(subscribe, () => portalNode, () => null) - -export const setDesktopInboxSearchPortalNode = (node: HTMLElement | null) => { - if (portalNode === node) { - return - } - portalNode = node - notify() -} diff --git a/shared/chat/inbox/index.d.ts b/shared/chat/inbox/index.d.ts index 26fe5bcdd677..873a90dfcc6e 100644 --- a/shared/chat/inbox/index.d.ts +++ b/shared/chat/inbox/index.d.ts @@ -1,7 +1,11 @@ import type * as React from 'react' import type {ConversationIDKey} from '@/constants/types/chat' +import type {InboxSearchController} from './use-inbox-search' -type Props = {conversationIDKey?: ConversationIDKey} +type Props = { + conversationIDKey?: ConversationIDKey + search: InboxSearchController +} declare const Inbox: (p: Props) => React.ReactNode export default Inbox diff --git a/shared/chat/inbox/index.desktop.tsx b/shared/chat/inbox/index.desktop.tsx index 019ad39fad18..b729e467399e 100644 --- a/shared/chat/inbox/index.desktop.tsx +++ b/shared/chat/inbox/index.desktop.tsx @@ -13,6 +13,8 @@ import { } from './list-helpers' import BigTeamsDivider from './row/big-teams-divider' import BuildTeam from './row/build-team' +import InboxSearch from '../inbox-search' +import SearchRow from './search-row' import TeamsDivider from './row/teams-divider' import UnreadShortcut from './unread-shortcut' import * as Kb from '@/common-adapters' @@ -20,6 +22,7 @@ import type {LegendListRef} from '@/common-adapters' import {createPortal} from 'react-dom' import {inboxWidth, smallRowHeight, getRowHeight} from './row/sizes' import {makeRow} from './row' +import type {InboxSearchController} from './use-inbox-search' import {useInboxState} from './use-inbox-state' import './inbox.css' @@ -193,10 +196,14 @@ const DragLine = (p: { ) } -type InboxProps = {conversationIDKey?: T.Chat.ConversationIDKey} +type InboxProps = { + conversationIDKey?: T.Chat.ConversationIDKey + search: InboxSearchController +} function Inbox(props: InboxProps) { - const inbox = useInboxState(props.conversationIDKey) + const {conversationIDKey, search} = props + const inbox = useInboxState(conversationIDKey, search.isSearching) const {smallTeamsExpanded, rows, unreadIndices, unreadTotal, inboxNumSmallRows} = inbox const {toggleSmallTeamsExpanded, selectedConversationIDKey, onUntrustedInboxVisible} = inbox const {setInboxNumSmallRows, allowShowFloatingButton} = inbox @@ -276,33 +283,42 @@ function Inbox(props: InboxProps) { return <>{makeRow(item, isSelected)} } - const floatingDivider = showFloating && allowShowFloatingButton && ( + const floatingDivider = !search.isSearching && showFloating && allowShowFloatingButton && ( ) return ( -
- {rows.length ? ( - - ) : null} -
- {floatingDivider || (rows.length === 0 && )} - {showUnread && !showFloating && } + + + {search.isSearching ? ( + + ) : ( +
+ {rows.length ? ( + + ) : null} +
+ )} +
+ {!search.isSearching && (floatingDivider || (rows.length === 0 && ))} + {!search.isSearching && showUnread && !showFloating && ( + + )}
) @@ -311,6 +327,10 @@ function Inbox(props: InboxProps) { const styles = Kb.Styles.styleSheetCreate( () => ({ + body: { + flex: 1, + minHeight: 0, + }, container: Kb.Styles.platformStyles({ isElectron: { backgroundColor: Kb.Styles.globalColors.blueGrey, diff --git a/shared/chat/inbox/index.native.tsx b/shared/chat/inbox/index.native.tsx index 01a4af99abe7..2726b4ff63bd 100644 --- a/shared/chat/inbox/index.native.tsx +++ b/shared/chat/inbox/index.native.tsx @@ -15,7 +15,7 @@ import {Alert} from 'react-native' import type {LegendListRef} from '@/common-adapters' import {makeRow} from './row' import {useOpenedRowState} from './row/opened-row-state' -import {useInboxSearch} from './use-inbox-search' +import type {InboxSearchController} from './use-inbox-search' import {useInboxState} from './use-inbox-state' import {type RowItem, type ViewableItemsData, viewabilityConfig, getItemType, keyExtractor, useUnreadShortcut, useScrollUnbox} from './list-helpers' @@ -50,26 +50,18 @@ const NoChats = (props: {onNewChat: () => void}) => ( ) -type InboxProps = {conversationIDKey?: T.Chat.ConversationIDKey} +type InboxProps = { + conversationIDKey?: T.Chat.ConversationIDKey + search: InboxSearchController +} function Inbox(p: InboxProps) { - const search = useInboxSearch() + const {search} = p const inbox = useInboxState(p.conversationIDKey, search.isSearching) const {onUntrustedInboxVisible, toggleSmallTeamsExpanded, selectedConversationIDKey} = inbox const {unreadIndices, unreadTotal, rows, smallTeamsExpanded, isSearching, allowShowFloatingButton} = inbox const {neverLoaded, onNewChat, inboxNumSmallRows, setInboxNumSmallRows} = inbox - const headComponent = ( - - ) + const headComponent = const listRef = React.useRef(null) const {showFloating, showUnread, unreadCount, scrollToUnread, applyUnreadAndFloating} = @@ -162,7 +154,7 @@ function Inbox(p: InboxProps) { {isSearching ? ( - + ) : ( void - headerContext: 'chat-header' | 'inbox-header' - isSearching: boolean - moveSelectedIndex: (increment: boolean) => void - query: string - select: ( - conversationIDKey?: T.Chat.ConversationIDKey, - query?: string, - selectedIndex?: number - ) => void - setQuery: (query: string) => void - startSearch: () => void + search: Pick< + InboxSearchController, + 'cancelSearch' | 'isSearching' | 'moveSelectedIndex' | 'query' | 'selectResult' | 'setQuery' | 'startSearch' + > + showSearch: boolean } export default function InboxSearchRow(ownProps: OwnProps) { - const {cancelSearch, headerContext, isSearching, moveSelectedIndex, query, select, setQuery, startSearch} = - ownProps + const {search, showSearch} = ownProps + const {cancelSearch, isSearching, moveSelectedIndex, query, selectResult, setQuery, startSearch} = search const chatState = Chat.useChatState( C.useShallow(s => { const hasLoadedEmptyInbox = @@ -45,8 +38,6 @@ export default function InboxSearchRow(ownProps: OwnProps) { setQuery(q) } - const showSearch = headerContext === 'chat-header' ? !C.isTablet : C.isMobile - return ( <> {!!showStartNewChat && ( @@ -58,7 +49,7 @@ export default function InboxSearchRow(ownProps: OwnProps) { onCancelSearch={cancelSearch} onSelectUp={() => moveSelectedIndex(false)} onSelectDown={() => moveSelectedIndex(true)} - onEnsureSelection={select} + onEnsureSelection={selectResult} onQueryChanged={onQueryChanged} query={query} showSearch={showSearch} diff --git a/shared/chat/inbox/use-inbox-search.tsx b/shared/chat/inbox/use-inbox-search.tsx index 2862dea5e16d..ff7bab607fdb 100644 --- a/shared/chat/inbox/use-inbox-search.tsx +++ b/shared/chat/inbox/use-inbox-search.tsx @@ -81,7 +81,24 @@ export const nextInboxSearchSelectedIndex = ( type SearchInfoUpdater = (prev: T.Chat.InboxSearchInfo) => T.Chat.InboxSearchInfo -export function useInboxSearch() { +export type InboxSearchSelect = ( + conversationIDKey?: T.Chat.ConversationIDKey, + query?: string, + selectedIndex?: number +) => void + +export type InboxSearchController = { + cancelSearch: () => void + isSearching: boolean + moveSelectedIndex: (increment: boolean) => void + query: string + searchInfo: T.Chat.InboxSearchInfo + selectResult: InboxSearchSelect + setQuery: (query: string) => void + startSearch: () => void +} + +export function useInboxSearch(): InboxSearchController { const mobileAppState = useConfigState(s => s.mobileAppState) const [isSearching, setIsSearching] = React.useState(false) const [searchInfo, setSearchInfo] = React.useState(makeInboxSearchInfo) @@ -321,7 +338,7 @@ export function useInboxSearch() { })) }, [updateSearchInfo]) - const select = React.useCallback( + const selectResult = React.useCallback( (_conversationIDKey?: T.Chat.ConversationIDKey, q?: string, selectedIndex?: number) => { let conversationIDKey = _conversationIDKey let query = q @@ -404,7 +421,7 @@ export function useInboxSearch() { moveSelectedIndex, query: searchInfo.query, searchInfo, - select, + selectResult, setQuery, startSearch, } From 5637c4972fef747a7b53556d0107289077cbcf46 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 11:28:08 -0400 Subject: [PATCH 29/59] WIP --- shared/chat/inbox-and-conversation.native.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/shared/chat/inbox-and-conversation.native.tsx b/shared/chat/inbox-and-conversation.native.tsx index 8fae9a4df839..062ced1491d9 100644 --- a/shared/chat/inbox-and-conversation.native.tsx +++ b/shared/chat/inbox-and-conversation.native.tsx @@ -1,11 +1,14 @@ import Inbox from './inbox' import {InboxAndConversationShell, type InboxAndConversationProps} from './inbox-and-conversation-shared' +import {useInboxSearch} from './inbox/use-inbox-search' export default function InboxAndConversationNative(props: InboxAndConversationProps) { + const search = useInboxSearch() + return ( } + leftPane={} /> ) } From d4fd1ee70a74cc1dfa0218f3f0355d7df57cff2f Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 11:37:49 -0400 Subject: [PATCH 30/59] WIP --- plans/inbox.md | 39 +++++++++++++++++++ .../chat/inbox-and-conversation.desktop.tsx | 19 ++++++++- shared/chat/inbox/index.desktop.tsx | 15 ++++++- 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/plans/inbox.md b/plans/inbox.md index 39cabee2627a..11c36b7a9c8a 100644 --- a/plans/inbox.md +++ b/plans/inbox.md @@ -6,11 +6,13 @@ The current inbox search setup uses `shared/chat/inbox/search-state.tsx` as a te 1. Move desktop search out of the navigator header and into the inbox pane so desktop search can be owned by ordinary React state in one tree. 2. Follow with a broader cleanup that unifies native and desktop inbox search ownership and reduces remaining platform divergence where practical. +3. Clean up mobile inbox startup so push/saved-thread launches do not need the old deferred inbox mount workaround. ## Progress - [x] Part 1 completed. - [x] Part 2 completed: desktop and native inbox screens now consume the same route-owned inbox-search controller contract, and inbox-search state is no longer owned by `shared/stores/chat.tsx`. +- [ ] Part 3 planned: remove the mobile inbox deferred mount path and gate expensive inbox startup work on focus instead. ## Part 1: Move Desktop Search Out Of The Header @@ -86,6 +88,35 @@ Part 2 boundaries: - Do not refactor inbox virtualization, drag resizing, or unread shortcut behavior unless required to support the shared search controller. - Additional pruning of non-search inbox state in `shared/stores/chat.tsx` is a follow-up, not required for this plan. +## Part 3: Remove Mobile Inbox Deferred Startup Workaround + +Goal: + +- Stop relying on `shared/chat/inbox/defer-loading.tsx` to hide inbox mount work during phone startup from push or saved thread state. +- Keep the startup perf win by preventing expensive inbox refresh work until the inbox is actually focused. +- Remove the old gesture-handler timing workaround rather than preserving it by default. + +Implementation changes: + +- Route phone `chatRoot` directly to the normal inbox screen instead of `shared/chat/inbox/defer-loading.tsx`. +- Move the “do inbox work only when the inbox is actually visible” policy into inbox state ownership, primarily `shared/chat/inbox/use-inbox-state.tsx`. +- On mobile, avoid mount-time `inboxRefresh('componentNeverLoaded')` when `chatRoot` is mounted behind `chatConversation`. +- Keep the existing focus-driven refresh path as the trigger for first inbox load on phone. +- Delete `shared/chat/inbox/defer-loading.tsx` if no longer needed. + +Behavior requirements: + +- Cold start from a push or saved last thread should still open directly to the conversation. +- That startup path should not eagerly trigger inbox refresh work just because `chatRoot` exists under the pushed conversation. +- Opening the inbox afterward should still load inbox data normally. +- Normal non-push startup into chat inbox should still load inbox data without requiring extra navigation steps. + +Part 3 boundaries: + +- Do not change the push deep-link or saved-state startup routing shape unless that becomes necessary after the inbox-side cleanup. +- Do not keep the old 100ms gesture workaround unless it is proven still necessary after the simpler focus-gated approach. +- Do not bundle unrelated inbox row, search, or conversation-screen refactors into this step. + ## Interfaces / Types - `SearchRow` should become prop-driven and stop depending on a dedicated inbox-search store. @@ -113,8 +144,16 @@ Part 2: - Selecting text hits vs conversation hits behaves the same on both platforms. - No inbox-search state remains in `shared/stores/chat.tsx`. +Part 3: + +- Push/saved-thread startup on phone does not eagerly refresh inbox data before the inbox is focused. +- Opening inbox after such a startup still loads inbox rows correctly. +- Normal direct navigation to chat inbox still triggers initial inbox load. +- `shared/chat/inbox/defer-loading.tsx` is removed if nothing still depends on it. + ## Assumptions - Part 1 is intentionally desktop-first and can leave native slightly different until Part 2. - The main value of Part 1 is eliminating the cross-tree desktop search problem so local React ownership becomes possible. - “Unify native and desktop inbox work” means shared search ownership and interface, not necessarily identical screen files or identical list internals. +- Part 3 assumes the deferred inbox wrapper is no longer the right place to solve startup cost; focus-gating inbox refresh work is the preferred simpler model. diff --git a/shared/chat/inbox-and-conversation.desktop.tsx b/shared/chat/inbox-and-conversation.desktop.tsx index 83a2ad54787d..7ae70ead6f07 100644 --- a/shared/chat/inbox-and-conversation.desktop.tsx +++ b/shared/chat/inbox-and-conversation.desktop.tsx @@ -1,10 +1,27 @@ +import * as Kb from '@/common-adapters' import Inbox from './inbox' import {InboxAndConversationShell, type InboxAndConversationProps} from './inbox-and-conversation-shared' +import {inboxWidth} from './inbox/row/sizes' import {useInboxSearch} from './inbox/use-inbox-search' export default function InboxAndConversationDesktop(props: InboxAndConversationProps) { const search = useInboxSearch() - const leftPane = + const leftPane = ( + + + + ) return } + +const styles = Kb.Styles.styleSheetCreate( + () => + ({ + inboxPane: { + backgroundColor: Kb.Styles.globalColors.blueGrey, + maxWidth: inboxWidth, + minWidth: inboxWidth, + }, + }) as const +) diff --git a/shared/chat/inbox/index.desktop.tsx b/shared/chat/inbox/index.desktop.tsx index b729e467399e..4d93a986535d 100644 --- a/shared/chat/inbox/index.desktop.tsx +++ b/shared/chat/inbox/index.desktop.tsx @@ -14,6 +14,7 @@ import { import BigTeamsDivider from './row/big-teams-divider' import BuildTeam from './row/build-team' import InboxSearch from '../inbox-search' +import NewChatButton from './new-chat-button' import SearchRow from './search-row' import TeamsDivider from './row/teams-divider' import UnreadShortcut from './unread-shortcut' @@ -290,7 +291,12 @@ function Inbox(props: InboxProps) { return ( - + + + + + + {search.isSearching ? ( @@ -425,6 +431,13 @@ const styles = Kb.Styles.styleSheetCreate( height: '100%', position: 'relative' as const, }, + topBar: { + alignItems: 'center', + backgroundColor: Kb.Styles.globalColors.blueGrey, + flexShrink: 0, + minHeight: 40, + paddingRight: Kb.Styles.globalMargins.tiny, + }, spacer: { backgroundColor: Kb.Styles.globalColors.blueGrey, bottom: 0, From 463e10dc8832e5be5f593ffc659d1aca70bdd139 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 10 Apr 2026 11:38:40 -0400 Subject: [PATCH 31/59] WIP --- shared/chat/inbox/index.desktop.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/shared/chat/inbox/index.desktop.tsx b/shared/chat/inbox/index.desktop.tsx index 4d93a986535d..8d1d6fb5f414 100644 --- a/shared/chat/inbox/index.desktop.tsx +++ b/shared/chat/inbox/index.desktop.tsx @@ -431,13 +431,6 @@ const styles = Kb.Styles.styleSheetCreate( height: '100%', position: 'relative' as const, }, - topBar: { - alignItems: 'center', - backgroundColor: Kb.Styles.globalColors.blueGrey, - flexShrink: 0, - minHeight: 40, - paddingRight: Kb.Styles.globalMargins.tiny, - }, spacer: { backgroundColor: Kb.Styles.globalColors.blueGrey, bottom: 0, @@ -445,6 +438,13 @@ const styles = Kb.Styles.styleSheetCreate( position: 'absolute', width: '100%', }, + topBar: { + alignItems: 'center', + backgroundColor: Kb.Styles.globalColors.blueGrey, + flexShrink: 0, + minHeight: 40, + paddingRight: Kb.Styles.globalMargins.tiny, + }, }) as const ) From a260b248a9081c4ae86dea9051b2420ed51a962d Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 11:43:01 -0400 Subject: [PATCH 32/59] WIP --- shared/chat/inbox-and-conversation-header.tsx | 2 +- shared/chat/inbox/index.desktop.tsx | 25 ++++++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/shared/chat/inbox-and-conversation-header.tsx b/shared/chat/inbox-and-conversation-header.tsx index 73819700fbb8..bba6eee75529 100644 --- a/shared/chat/inbox-and-conversation-header.tsx +++ b/shared/chat/inbox-and-conversation-header.tsx @@ -147,7 +147,7 @@ const Header2 = () => { const leftSide = ( {!Kb.Styles.isMobile && } - + {!C.isElectron && } ) diff --git a/shared/chat/inbox/index.desktop.tsx b/shared/chat/inbox/index.desktop.tsx index 8d1d6fb5f414..f8a38ac68598 100644 --- a/shared/chat/inbox/index.desktop.tsx +++ b/shared/chat/inbox/index.desktop.tsx @@ -13,9 +13,9 @@ import { } from './list-helpers' import BigTeamsDivider from './row/big-teams-divider' import BuildTeam from './row/build-team' +import ChatFilterRow from './filter-row' import InboxSearch from '../inbox-search' import NewChatButton from './new-chat-button' -import SearchRow from './search-row' import TeamsDivider from './row/teams-divider' import UnreadShortcut from './unread-shortcut' import * as Kb from '@/common-adapters' @@ -287,17 +287,30 @@ function Inbox(props: InboxProps) { const floatingDivider = !search.isSearching && showFloating && allowShowFloatingButton && ( ) + const searchBar = ( + search.moveSelectedIndex(false)} + onSelectDown={() => search.moveSelectedIndex(true)} + onEnsureSelection={search.selectResult} + onQueryChanged={search.setQuery} + query={search.query} + showSearch={true} + startSearch={search.startSearch} + /> + ) return ( - + - + {searchBar} - + {search.isSearching ? ( ) : ( @@ -336,6 +349,7 @@ const styles = Kb.Styles.styleSheetCreate( body: { flex: 1, minHeight: 0, + width: '100%', }, container: Kb.Styles.platformStyles({ isElectron: { @@ -430,6 +444,7 @@ const styles = Kb.Styles.styleSheetCreate( flex: 1, height: '100%', position: 'relative' as const, + width: '100%', }, spacer: { backgroundColor: Kb.Styles.globalColors.blueGrey, @@ -443,7 +458,9 @@ const styles = Kb.Styles.styleSheetCreate( backgroundColor: Kb.Styles.globalColors.blueGrey, flexShrink: 0, minHeight: 40, + paddingLeft: Kb.Styles.globalMargins.tiny, paddingRight: Kb.Styles.globalMargins.tiny, + width: '100%', }, }) as const ) From 58f9703e605135c09bd95469e5bd4fdf7fd04192 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 11:48:07 -0400 Subject: [PATCH 33/59] WIP --- shared/chat/inbox-and-conversation-header.tsx | 14 ++++++- .../chat/inbox-and-conversation.desktop.tsx | 37 ++++++++++++++++++- shared/chat/inbox/index.desktop.tsx | 30 --------------- 3 files changed, 49 insertions(+), 32 deletions(-) diff --git a/shared/chat/inbox-and-conversation-header.tsx b/shared/chat/inbox-and-conversation-header.tsx index bba6eee75529..70a0a3192100 100644 --- a/shared/chat/inbox-and-conversation-header.tsx +++ b/shared/chat/inbox-and-conversation-header.tsx @@ -3,6 +3,7 @@ import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' import type {StyleOverride} from '@/common-adapters/markdown' import NewChatButton from './inbox/new-chat-button' +import {setDesktopInboxSearchPortalNode} from './inbox/desktop-search-portal' import type {ChatRootRouteParams} from './inbox-and-conversation' import {useRoute, type RouteProp} from '@react-navigation/native' import {useUsersState} from '@/stores/users' @@ -146,7 +147,14 @@ const Header2 = () => { const leftSide = ( - {!Kb.Styles.isMobile && } + {!Kb.Styles.isMobile && ( + +
setDesktopInboxSearchPortalNode(node)} + /> + + )} {!C.isElectron && } ) @@ -347,6 +355,10 @@ const styles = Kb.Styles.styleSheetCreate( }, isMobile: {paddingLeft: Kb.Styles.globalMargins.tiny}, }), + searchPortal: { + height: '100%', + width: '100%', + }, shhIconStyle: {marginLeft: Kb.Styles.globalMargins.xtiny}, }) as const ) diff --git a/shared/chat/inbox-and-conversation.desktop.tsx b/shared/chat/inbox-and-conversation.desktop.tsx index 7ae70ead6f07..2aaecbd07b32 100644 --- a/shared/chat/inbox-and-conversation.desktop.tsx +++ b/shared/chat/inbox-and-conversation.desktop.tsx @@ -1,18 +1,46 @@ import * as Kb from '@/common-adapters' +import {createPortal} from 'react-dom' import Inbox from './inbox' import {InboxAndConversationShell, type InboxAndConversationProps} from './inbox-and-conversation-shared' +import {useDesktopInboxSearchPortalNode} from './inbox/desktop-search-portal' +import ChatFilterRow from './inbox/filter-row' +import NewChatButton from './inbox/new-chat-button' import {inboxWidth} from './inbox/row/sizes' import {useInboxSearch} from './inbox/use-inbox-search' export default function InboxAndConversationDesktop(props: InboxAndConversationProps) { const search = useInboxSearch() + const searchPortalNode = useDesktopInboxSearchPortalNode() + const searchBar = ( + + + search.moveSelectedIndex(false)} + onSelectDown={() => search.moveSelectedIndex(true)} + onEnsureSelection={search.selectResult} + onQueryChanged={search.setQuery} + query={search.query} + showSearch={true} + startSearch={search.startSearch} + /> + + + + ) const leftPane = ( ) - return + return ( + <> + {searchPortalNode ? createPortal(searchBar, searchPortalNode) : null} + + + ) } const styles = Kb.Styles.styleSheetCreate( @@ -23,5 +51,12 @@ const styles = Kb.Styles.styleSheetCreate( maxWidth: inboxWidth, minWidth: inboxWidth, }, + topBar: { + alignItems: 'center', + backgroundColor: Kb.Styles.globalColors.blueGrey, + height: '100%', + paddingRight: Kb.Styles.globalMargins.tiny, + width: '100%', + }, }) as const ) diff --git a/shared/chat/inbox/index.desktop.tsx b/shared/chat/inbox/index.desktop.tsx index f8a38ac68598..8a068552ec8e 100644 --- a/shared/chat/inbox/index.desktop.tsx +++ b/shared/chat/inbox/index.desktop.tsx @@ -13,9 +13,7 @@ import { } from './list-helpers' import BigTeamsDivider from './row/big-teams-divider' import BuildTeam from './row/build-team' -import ChatFilterRow from './filter-row' import InboxSearch from '../inbox-search' -import NewChatButton from './new-chat-button' import TeamsDivider from './row/teams-divider' import UnreadShortcut from './unread-shortcut' import * as Kb from '@/common-adapters' @@ -287,29 +285,10 @@ function Inbox(props: InboxProps) { const floatingDivider = !search.isSearching && showFloating && allowShowFloatingButton && ( ) - const searchBar = ( - search.moveSelectedIndex(false)} - onSelectDown={() => search.moveSelectedIndex(true)} - onEnsureSelection={search.selectResult} - onQueryChanged={search.setQuery} - query={search.query} - showSearch={true} - startSearch={search.startSearch} - /> - ) return ( - - - {searchBar} - - - {search.isSearching ? ( @@ -453,15 +432,6 @@ const styles = Kb.Styles.styleSheetCreate( position: 'absolute', width: '100%', }, - topBar: { - alignItems: 'center', - backgroundColor: Kb.Styles.globalColors.blueGrey, - flexShrink: 0, - minHeight: 40, - paddingLeft: Kb.Styles.globalMargins.tiny, - paddingRight: Kb.Styles.globalMargins.tiny, - width: '100%', - }, }) as const ) From e4410bd7023fd098dfc2a3c6d1185136b13669dc Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 11:48:33 -0400 Subject: [PATCH 34/59] WIP --- shared/chat/inbox/desktop-search-portal.tsx | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 shared/chat/inbox/desktop-search-portal.tsx diff --git a/shared/chat/inbox/desktop-search-portal.tsx b/shared/chat/inbox/desktop-search-portal.tsx new file mode 100644 index 000000000000..b87162237491 --- /dev/null +++ b/shared/chat/inbox/desktop-search-portal.tsx @@ -0,0 +1,26 @@ +import * as React from 'react' + +let portalNode: HTMLElement | null = null +const listeners = new Set<() => void>() + +const notify = () => { + listeners.forEach(listener => listener()) +} + +const subscribe = (listener: () => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export const useDesktopInboxSearchPortalNode = () => + React.useSyncExternalStore(subscribe, () => portalNode, () => null) + +export const setDesktopInboxSearchPortalNode = (node: HTMLElement | null) => { + if (portalNode === node) { + return + } + portalNode = node + notify() +} From b04f1a641b1ac5cceb180ecaf594023ef9e08855 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 10 Apr 2026 11:55:50 -0400 Subject: [PATCH 35/59] WIP --- shared/chat/inbox-and-conversation.desktop.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/shared/chat/inbox-and-conversation.desktop.tsx b/shared/chat/inbox-and-conversation.desktop.tsx index 2aaecbd07b32..43ea5d90bcee 100644 --- a/shared/chat/inbox-and-conversation.desktop.tsx +++ b/shared/chat/inbox-and-conversation.desktop.tsx @@ -53,7 +53,6 @@ const styles = Kb.Styles.styleSheetCreate( }, topBar: { alignItems: 'center', - backgroundColor: Kb.Styles.globalColors.blueGrey, height: '100%', paddingRight: Kb.Styles.globalMargins.tiny, width: '100%', From c75fb2ad5d8ca73c1fc04b7ffa961e9ebe12043d Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 11:58:16 -0400 Subject: [PATCH 36/59] WIP --- shared/chat/inbox-and-conversation-header.tsx | 2 +- shared/chat/inbox/index.native.tsx | 39 ++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/shared/chat/inbox-and-conversation-header.tsx b/shared/chat/inbox-and-conversation-header.tsx index 70a0a3192100..b75b566350eb 100644 --- a/shared/chat/inbox-and-conversation-header.tsx +++ b/shared/chat/inbox-and-conversation-header.tsx @@ -155,7 +155,7 @@ const Header2 = () => { /> )} - {!C.isElectron && } + {!C.isElectron && !C.isTablet && } ) diff --git a/shared/chat/inbox/index.native.tsx b/shared/chat/inbox/index.native.tsx index 2726b4ff63bd..9f9a070217a8 100644 --- a/shared/chat/inbox/index.native.tsx +++ b/shared/chat/inbox/index.native.tsx @@ -6,6 +6,8 @@ import {PerfProfiler} from '@/perf/react-profiler' import * as RowSizes from './row/sizes' import BigTeamsDivider from './row/big-teams-divider' import BuildTeam from './row/build-team' +import ChatFilterRow from './filter-row' +import NewChatButton from './new-chat-button' import SearchRow from './search-row' import InboxSearch from '../inbox-search' import TeamsDivider from './row/teams-divider' @@ -58,10 +60,39 @@ type InboxProps = { function Inbox(p: InboxProps) { const {search} = p const inbox = useInboxState(p.conversationIDKey, search.isSearching) + const showEmptyInbox = Chat.useChatState( + s => + s.inboxHasLoaded && + !!s.inboxLayout && + (s.inboxLayout.smallTeams || []).length === 0 && + (s.inboxLayout.bigTeams || []).length === 0 + ) const {onUntrustedInboxVisible, toggleSmallTeamsExpanded, selectedConversationIDKey} = inbox const {unreadIndices, unreadTotal, rows, smallTeamsExpanded, isSearching, allowShowFloatingButton} = inbox const {neverLoaded, onNewChat, inboxNumSmallRows, setInboxNumSmallRows} = inbox - const headComponent = + const showTabletHeader = isSearching || !showEmptyInbox + const headComponent = C.isTablet ? ( + showTabletHeader ? ( + + + search.moveSelectedIndex(false)} + onSelectDown={() => search.moveSelectedIndex(true)} + onEnsureSelection={search.selectResult} + onQueryChanged={search.setQuery} + query={search.query} + showSearch={true} + startSearch={search.startSearch} + /> + + + + ) : null + ) : ( + + ) const listRef = React.useRef(null) const {showFloating, showUnread, unreadCount, scrollToUnread, applyUnreadAndFloating} = @@ -233,6 +264,12 @@ const styles = Kb.Styles.styleSheetCreate( paddingRight: Kb.Styles.globalMargins.small, paddingTop: Kb.Styles.globalMargins.large, }, + tabletHeader: { + alignItems: 'center', + backgroundColor: Kb.Styles.globalColors.blueGrey, + minHeight: 40, + paddingRight: Kb.Styles.globalMargins.tiny, + }, }) as const ) From 39f3e038eaad46374b1ba3a995922023320ca8a0 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 12:14:07 -0400 Subject: [PATCH 37/59] WIP --- shared/chat/inbox-and-conversation-header.tsx | 11 +++-- .../chat/inbox-and-conversation.desktop.tsx | 33 ++------------ shared/chat/inbox-and-conversation.native.tsx | 2 + shared/chat/inbox/header-controls.tsx | 44 +++++++++++++++++++ ...rch-portal.tsx => header-portal-state.tsx} | 16 ++++++- shared/chat/inbox/index.native.tsx | 39 +--------------- .../chat/inbox/use-header-portal.desktop.tsx | 9 ++++ .../chat/inbox/use-header-portal.native.tsx | 21 +++++++++ 8 files changed, 101 insertions(+), 74 deletions(-) create mode 100644 shared/chat/inbox/header-controls.tsx rename shared/chat/inbox/{desktop-search-portal.tsx => header-portal-state.tsx} (50%) create mode 100644 shared/chat/inbox/use-header-portal.desktop.tsx create mode 100644 shared/chat/inbox/use-header-portal.native.tsx diff --git a/shared/chat/inbox-and-conversation-header.tsx b/shared/chat/inbox-and-conversation-header.tsx index b75b566350eb..f739496aef42 100644 --- a/shared/chat/inbox-and-conversation-header.tsx +++ b/shared/chat/inbox-and-conversation-header.tsx @@ -3,7 +3,7 @@ import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' import type {StyleOverride} from '@/common-adapters/markdown' import NewChatButton from './inbox/new-chat-button' -import {setDesktopInboxSearchPortalNode} from './inbox/desktop-search-portal' +import {setInboxHeaderPortalNode, useInboxHeaderPortalContent} from './inbox/header-portal-state' import type {ChatRootRouteParams} from './inbox-and-conversation' import {useRoute, type RouteProp} from '@react-navigation/native' import {useUsersState} from '@/stores/users' @@ -70,6 +70,7 @@ const Header2 = () => { // If it's a one-on-one chat, use the user's fullname as the description const desc = otherInfo?.bio?.replace(/(\r\n|\n|\r)/gm, ' ') || descriptionDecorated const fullName = otherInfo?.fullname + const headerPortalContent = useInboxHeaderPortalContent() const onToggleThreadSearch = () => { toggleThreadSearch() @@ -147,14 +148,16 @@ const Header2 = () => { const leftSide = ( - {!Kb.Styles.isMobile && ( + {C.isTablet ? ( + {headerPortalContent} + ) : !Kb.Styles.isMobile ? (
setDesktopInboxSearchPortalNode(node)} + ref={node => setInboxHeaderPortalNode(node)} /> - )} + ) : null} {!C.isElectron && !C.isTablet && } ) diff --git a/shared/chat/inbox-and-conversation.desktop.tsx b/shared/chat/inbox-and-conversation.desktop.tsx index 43ea5d90bcee..f410c722ffd8 100644 --- a/shared/chat/inbox-and-conversation.desktop.tsx +++ b/shared/chat/inbox-and-conversation.desktop.tsx @@ -1,34 +1,13 @@ import * as Kb from '@/common-adapters' -import {createPortal} from 'react-dom' import Inbox from './inbox' import {InboxAndConversationShell, type InboxAndConversationProps} from './inbox-and-conversation-shared' -import {useDesktopInboxSearchPortalNode} from './inbox/desktop-search-portal' -import ChatFilterRow from './inbox/filter-row' -import NewChatButton from './inbox/new-chat-button' import {inboxWidth} from './inbox/row/sizes' +import useInboxHeaderPortal from './inbox/use-header-portal' import {useInboxSearch} from './inbox/use-inbox-search' export default function InboxAndConversationDesktop(props: InboxAndConversationProps) { const search = useInboxSearch() - const searchPortalNode = useDesktopInboxSearchPortalNode() - const searchBar = ( - - - search.moveSelectedIndex(false)} - onSelectDown={() => search.moveSelectedIndex(true)} - onEnsureSelection={search.selectResult} - onQueryChanged={search.setQuery} - query={search.query} - showSearch={true} - startSearch={search.startSearch} - /> - - - - ) + const headerPortal = useInboxHeaderPortal(search) const leftPane = ( @@ -37,7 +16,7 @@ export default function InboxAndConversationDesktop(props: InboxAndConversationP return ( <> - {searchPortalNode ? createPortal(searchBar, searchPortalNode) : null} + {headerPortal} ) @@ -51,11 +30,5 @@ const styles = Kb.Styles.styleSheetCreate( maxWidth: inboxWidth, minWidth: inboxWidth, }, - topBar: { - alignItems: 'center', - height: '100%', - paddingRight: Kb.Styles.globalMargins.tiny, - width: '100%', - }, }) as const ) diff --git a/shared/chat/inbox-and-conversation.native.tsx b/shared/chat/inbox-and-conversation.native.tsx index 062ced1491d9..6cbb64ade78b 100644 --- a/shared/chat/inbox-and-conversation.native.tsx +++ b/shared/chat/inbox-and-conversation.native.tsx @@ -1,9 +1,11 @@ import Inbox from './inbox' import {InboxAndConversationShell, type InboxAndConversationProps} from './inbox-and-conversation-shared' +import useInboxHeaderPortal from './inbox/use-header-portal' import {useInboxSearch} from './inbox/use-inbox-search' export default function InboxAndConversationNative(props: InboxAndConversationProps) { const search = useInboxSearch() + useInboxHeaderPortal(search) return ( +} + +export default function InboxHeaderControls({search}: Props) { + return ( + + + search.moveSelectedIndex(false)} + onSelectDown={() => search.moveSelectedIndex(true)} + onEnsureSelection={search.selectResult} + onQueryChanged={search.setQuery} + query={search.query} + showSearch={true} + startSearch={search.startSearch} + /> + + + + ) +} + +const styles = Kb.Styles.styleSheetCreate( + () => + ({ + row: { + alignItems: 'center', + height: '100%', + paddingRight: Kb.Styles.globalMargins.tiny, + width: '100%', + }, + }) as const +) diff --git a/shared/chat/inbox/desktop-search-portal.tsx b/shared/chat/inbox/header-portal-state.tsx similarity index 50% rename from shared/chat/inbox/desktop-search-portal.tsx rename to shared/chat/inbox/header-portal-state.tsx index b87162237491..c50c0472c6be 100644 --- a/shared/chat/inbox/desktop-search-portal.tsx +++ b/shared/chat/inbox/header-portal-state.tsx @@ -1,6 +1,7 @@ import * as React from 'react' let portalNode: HTMLElement | null = null +let portalContent: React.ReactNode = null const listeners = new Set<() => void>() const notify = () => { @@ -14,13 +15,24 @@ const subscribe = (listener: () => void) => { } } -export const useDesktopInboxSearchPortalNode = () => +export const useInboxHeaderPortalNode = () => React.useSyncExternalStore(subscribe, () => portalNode, () => null) -export const setDesktopInboxSearchPortalNode = (node: HTMLElement | null) => { +export const useInboxHeaderPortalContent = () => + React.useSyncExternalStore(subscribe, () => portalContent, () => null) + +export const setInboxHeaderPortalNode = (node: HTMLElement | null) => { if (portalNode === node) { return } portalNode = node notify() } + +export const setInboxHeaderPortalContent = (content: React.ReactNode) => { + if (portalContent === content) { + return + } + portalContent = content + notify() +} diff --git a/shared/chat/inbox/index.native.tsx b/shared/chat/inbox/index.native.tsx index 9f9a070217a8..c79038f6f998 100644 --- a/shared/chat/inbox/index.native.tsx +++ b/shared/chat/inbox/index.native.tsx @@ -6,8 +6,6 @@ import {PerfProfiler} from '@/perf/react-profiler' import * as RowSizes from './row/sizes' import BigTeamsDivider from './row/big-teams-divider' import BuildTeam from './row/build-team' -import ChatFilterRow from './filter-row' -import NewChatButton from './new-chat-button' import SearchRow from './search-row' import InboxSearch from '../inbox-search' import TeamsDivider from './row/teams-divider' @@ -60,39 +58,10 @@ type InboxProps = { function Inbox(p: InboxProps) { const {search} = p const inbox = useInboxState(p.conversationIDKey, search.isSearching) - const showEmptyInbox = Chat.useChatState( - s => - s.inboxHasLoaded && - !!s.inboxLayout && - (s.inboxLayout.smallTeams || []).length === 0 && - (s.inboxLayout.bigTeams || []).length === 0 - ) const {onUntrustedInboxVisible, toggleSmallTeamsExpanded, selectedConversationIDKey} = inbox const {unreadIndices, unreadTotal, rows, smallTeamsExpanded, isSearching, allowShowFloatingButton} = inbox const {neverLoaded, onNewChat, inboxNumSmallRows, setInboxNumSmallRows} = inbox - const showTabletHeader = isSearching || !showEmptyInbox - const headComponent = C.isTablet ? ( - showTabletHeader ? ( - - - search.moveSelectedIndex(false)} - onSelectDown={() => search.moveSelectedIndex(true)} - onEnsureSelection={search.selectResult} - onQueryChanged={search.setQuery} - query={search.query} - showSearch={true} - startSearch={search.startSearch} - /> - - - - ) : null - ) : ( - - ) + const headComponent = C.isTablet ? null : const listRef = React.useRef(null) const {showFloating, showUnread, unreadCount, scrollToUnread, applyUnreadAndFloating} = @@ -264,12 +233,6 @@ const styles = Kb.Styles.styleSheetCreate( paddingRight: Kb.Styles.globalMargins.small, paddingTop: Kb.Styles.globalMargins.large, }, - tabletHeader: { - alignItems: 'center', - backgroundColor: Kb.Styles.globalColors.blueGrey, - minHeight: 40, - paddingRight: Kb.Styles.globalMargins.tiny, - }, }) as const ) diff --git a/shared/chat/inbox/use-header-portal.desktop.tsx b/shared/chat/inbox/use-header-portal.desktop.tsx new file mode 100644 index 000000000000..cd2c2a2b5274 --- /dev/null +++ b/shared/chat/inbox/use-header-portal.desktop.tsx @@ -0,0 +1,9 @@ +import {createPortal} from 'react-dom' +import InboxHeaderControls from './header-controls' +import {useInboxHeaderPortalNode} from './header-portal-state' +import type {InboxSearchController} from './use-inbox-search' + +export default function useInboxHeaderPortal(search: InboxSearchController) { + const portalNode = useInboxHeaderPortalNode() + return portalNode ? createPortal(, portalNode) : null +} diff --git a/shared/chat/inbox/use-header-portal.native.tsx b/shared/chat/inbox/use-header-portal.native.tsx new file mode 100644 index 000000000000..a1bb69e9e754 --- /dev/null +++ b/shared/chat/inbox/use-header-portal.native.tsx @@ -0,0 +1,21 @@ +import * as C from '@/constants' +import * as React from 'react' +import InboxHeaderControls from './header-controls' +import {setInboxHeaderPortalContent} from './header-portal-state' +import type {InboxSearchController} from './use-inbox-search' + +export default function useInboxHeaderPortal(search: InboxSearchController) { + const content = C.isTablet ? : null + + React.useEffect(() => { + if (!C.isTablet) { + return + } + setInboxHeaderPortalContent(content) + return () => { + setInboxHeaderPortalContent(null) + } + }, [content]) + + return null +} From ca0f2f2d9fa5462ae64eff756771d06f91f42088 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 12:17:10 -0400 Subject: [PATCH 38/59] WIP --- shared/chat/inbox/filter-row.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/shared/chat/inbox/filter-row.tsx b/shared/chat/inbox/filter-row.tsx index bce9b1bb7de3..4d693383eb52 100644 --- a/shared/chat/inbox/filter-row.tsx +++ b/shared/chat/inbox/filter-row.tsx @@ -104,8 +104,7 @@ function ConversationFilterInput(ownProps: OwnProps) { gap={Kb.Styles.isMobile ? 'small' : showSearch ? 'xtiny' : undefined} style={Kb.Styles.collapseStyles([ styles.containerNotFiltering, - Kb.Styles.isPhone ? null : Kb.Styles.isTablet && showSearch ? null : styles.whiteBg, - !Kb.Styles.isMobile && styles.whiteBg, + !Kb.Styles.isPhone && styles.whiteBg, ])} gapStart={showSearch} gapEnd={showSearch} From 2f035357d5dfbf5ce4b6afe131a0f1eee6ad319e Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 12:18:56 -0400 Subject: [PATCH 39/59] WIP --- shared/chat/inbox/use-header-portal.d.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 shared/chat/inbox/use-header-portal.d.ts diff --git a/shared/chat/inbox/use-header-portal.d.ts b/shared/chat/inbox/use-header-portal.d.ts new file mode 100644 index 000000000000..7c368a6e0392 --- /dev/null +++ b/shared/chat/inbox/use-header-portal.d.ts @@ -0,0 +1,5 @@ +import type * as React from 'react' +import type {InboxSearchController} from './use-inbox-search' + +declare const useInboxHeaderPortal: (search: InboxSearchController) => React.ReactNode +export default useInboxHeaderPortal From b9f1f7b343a62d04b0565ed22caa682884564ed3 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 12:20:29 -0400 Subject: [PATCH 40/59] WIP --- shared/chat/inbox/header-portal-state.tsx | 12 ++++++++---- shared/chat/inbox/use-header-portal.native.tsx | 6 ++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/shared/chat/inbox/header-portal-state.tsx b/shared/chat/inbox/header-portal-state.tsx index c50c0472c6be..0d9a2e2e5e5b 100644 --- a/shared/chat/inbox/header-portal-state.tsx +++ b/shared/chat/inbox/header-portal-state.tsx @@ -1,7 +1,7 @@ import * as React from 'react' let portalNode: HTMLElement | null = null -let portalContent: React.ReactNode = null +let portalContent: React.ReactElement | null = null const listeners = new Set<() => void>() const notify = () => { @@ -16,10 +16,14 @@ const subscribe = (listener: () => void) => { } export const useInboxHeaderPortalNode = () => - React.useSyncExternalStore(subscribe, () => portalNode, () => null) + React.useSyncExternalStore(subscribe, (): HTMLElement | null => portalNode, (): HTMLElement | null => null) export const useInboxHeaderPortalContent = () => - React.useSyncExternalStore(subscribe, () => portalContent, () => null) + React.useSyncExternalStore( + subscribe, + (): React.ReactElement | null => portalContent, + (): React.ReactElement | null => null + ) export const setInboxHeaderPortalNode = (node: HTMLElement | null) => { if (portalNode === node) { @@ -29,7 +33,7 @@ export const setInboxHeaderPortalNode = (node: HTMLElement | null) => { notify() } -export const setInboxHeaderPortalContent = (content: React.ReactNode) => { +export const setInboxHeaderPortalContent = (content: React.ReactElement | null) => { if (portalContent === content) { return } diff --git a/shared/chat/inbox/use-header-portal.native.tsx b/shared/chat/inbox/use-header-portal.native.tsx index a1bb69e9e754..591654d05b27 100644 --- a/shared/chat/inbox/use-header-portal.native.tsx +++ b/shared/chat/inbox/use-header-portal.native.tsx @@ -5,17 +5,15 @@ import {setInboxHeaderPortalContent} from './header-portal-state' import type {InboxSearchController} from './use-inbox-search' export default function useInboxHeaderPortal(search: InboxSearchController) { - const content = C.isTablet ? : null - React.useEffect(() => { if (!C.isTablet) { return } - setInboxHeaderPortalContent(content) + setInboxHeaderPortalContent() return () => { setInboxHeaderPortalContent(null) } - }, [content]) + }, [search]) return null } From 12791c825812c116a0d5579f0fe27bb8c5d75df4 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 12:25:30 -0400 Subject: [PATCH 41/59] WIP --- shared/chat/conversation/info-panel/bot.tsx | 19 +++++++++---- shared/chat/inbox-search/index.tsx | 31 +++++++++++++++------ shared/chat/inbox/use-inbox-search.tsx | 16 +++++++++-- 3 files changed, 49 insertions(+), 17 deletions(-) diff --git a/shared/chat/conversation/info-panel/bot.tsx b/shared/chat/conversation/info-panel/bot.tsx index bc1a38541c0f..6bec1c7efa10 100644 --- a/shared/chat/conversation/info-panel/bot.tsx +++ b/shared/chat/conversation/info-panel/bot.tsx @@ -66,6 +66,7 @@ type BotProps = T.RPCGen.FeaturedBot & { description?: string firstItem?: boolean hideHover?: boolean + isSelected?: boolean showChannelAdd?: boolean showTeamAdd?: boolean conversationIDKey?: T.Chat.ConversationIDKey @@ -74,9 +75,11 @@ type BotProps = T.RPCGen.FeaturedBot & { export const Bot = (props: BotProps) => { const {botAlias, description, botUsername} = props const {ownerTeam, ownerUser} = props - const {onClick, firstItem} = props + const {onClick, firstItem, isSelected} = props const {conversationIDKey, showChannelAdd, showTeamAdd} = props const refreshBotSettings = Chat.useChatContext(s => s.dispatch.refreshBotSettings) + const primaryColor = isSelected ? Kb.Styles.globalColors.white : Kb.Styles.globalColors.black + const secondaryColor = isSelected ? Kb.Styles.globalColors.white : undefined React.useEffect(() => { if (conversationIDKey && showChannelAdd) { // fetch bot settings if trying to show the add to channel button @@ -87,7 +90,7 @@ export const Bot = (props: BotProps) => { const lower = ( {description !== '' && ( - onClick(botUsername)}> + onClick(botUsername)}> {description} )} @@ -97,12 +100,16 @@ export const Bot = (props: BotProps) => { const usernameDisplay = ( - + {botAlias || botUsername} -  • by  + +  • by  + {ownerTeam ? ( - {`${ownerTeam}`} + + {`${ownerTeam}`} + ) : ( { firstItem={!!firstItem} icon={} hideHover={!!props.hideHover} - style={{backgroundColor: Kb.Styles.globalColors.white}} + style={{backgroundColor: isSelected ? Kb.Styles.globalColors.blue : Kb.Styles.globalColors.white}} action={ showTeamAdd ? ( diff --git a/shared/chat/inbox-search/index.tsx b/shared/chat/inbox-search/index.tsx index 5878e393dcb7..39b5b13238ba 100644 --- a/shared/chat/inbox-search/index.tsx +++ b/shared/chat/inbox-search/index.tsx @@ -11,7 +11,11 @@ import type * as T from '@/constants/types' import {Bot} from '../conversation/info-panel/bot' import {TeamAvatar} from '../avatars' import {inboxWidth} from '../inbox/row/sizes' -import {inboxSearchMaxTextMessages, type InboxSearchController} from '../inbox/use-inbox-search' +import { + inboxSearchMaxTextMessages, + inboxSearchPreviewSectionSize, + type InboxSearchController, +} from '../inbox/use-inbox-search' type OwnProps = { header?: React.ReactElement | null @@ -80,10 +84,11 @@ export default function InboxSearchContainer(ownProps: OwnProps) { const toggleCollapseBots = () => setBotsCollapsed(s => !s) const toggleBotsAll = () => setBotsAll(s => !s) - const renderOpenTeams = (h: {item: Item}) => { - const {item} = h + const renderOpenTeams = (h: {item: Item; index: number; section: Section}) => { + const {item, index, section} = h if (item.type !== 'openTeam') return null const {hit} = item + const realIndex = index + section.indexOffset return ( ) } - const renderBots = (h: {item: Item; index: number}) => { - const {item, index} = h + const renderBots = (h: {item: Item; index: number; section: Section}) => { + const {item, index, section} = h if (item.type !== 'bot') return null + const realIndex = index + section.indexOffset return ( - + ) } @@ -280,9 +292,10 @@ export default function InboxSearchContainer(ownProps: OwnProps) { ? [] : openTeamsAll ? _openTeamsResults - : _openTeamsResults.slice(0, 3) + : _openTeamsResults.slice(0, inboxSearchPreviewSectionSize) - const botsResults = botsCollapsed ? [] : botsAll ? _botsResults : _botsResults.slice(0, 3) + const botsResults = + botsCollapsed ? [] : botsAll ? _botsResults : _botsResults.slice(0, inboxSearchPreviewSectionSize) const indexOffset = botsResults.length + openTeamsResults.length + nameResults.length const nameSection: Section = { diff --git a/shared/chat/inbox/use-inbox-search.tsx b/shared/chat/inbox/use-inbox-search.tsx index ff7bab607fdb..3436da054966 100644 --- a/shared/chat/inbox/use-inbox-search.tsx +++ b/shared/chat/inbox/use-inbox-search.tsx @@ -11,6 +11,7 @@ export const inboxSearchMaxTextMessages = 25 export const inboxSearchMaxTextResults = 50 export const inboxSearchMaxNameResults = 7 export const inboxSearchMaxUnreadNameResults = isMobile ? 5 : 10 +export const inboxSearchPreviewSectionSize = 3 export const makeInboxSearchInfo = (): T.Chat.InboxSearchInfo => ({ botsResults: [], @@ -34,11 +35,16 @@ const getInboxSearchSelected = ( ): | undefined | { + botUsername?: string conversationIDKey: T.Chat.ConversationIDKey + openTeamName?: string query?: string } => { const {selectedIndex, nameResults, botsResults, openTeamsResults, textResults} = inboxSearch - const firstTextResultIdx = botsResults.length + openTeamsResults.length + nameResults.length + const visibleBotsResults = botsResults.slice(0, inboxSearchPreviewSectionSize) + const visibleOpenTeamResults = openTeamsResults.slice(0, inboxSearchPreviewSectionSize) + const firstBotResultIdx = nameResults.length + visibleOpenTeamResults.length + const firstTextResultIdx = firstBotResultIdx + visibleBotsResults.length const firstOpenTeamResultIdx = nameResults.length if (selectedIndex < firstOpenTeamResultIdx) { @@ -50,6 +56,8 @@ const getInboxSearchSelected = ( query: undefined, } } + } else if (selectedIndex < firstBotResultIdx) { + return } else if (selectedIndex < firstTextResultIdx) { return } else if (selectedIndex >= firstTextResultIdx) { @@ -69,7 +77,11 @@ export const nextInboxSearchSelectedIndex = ( increment: boolean ) => { const {selectedIndex} = inboxSearch - const totalResults = inboxSearch.nameResults.length + inboxSearch.textResults.length + const totalResults = + inboxSearch.nameResults.length + + Math.min(inboxSearch.openTeamsResults.length, inboxSearchPreviewSectionSize) + + Math.min(inboxSearch.botsResults.length, inboxSearchPreviewSectionSize) + + inboxSearch.textResults.length if (increment && selectedIndex < totalResults - 1) { return selectedIndex + 1 } From 4586a79f45b8cb29a26b9c70bfad512f2539a4e5 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 12:30:11 -0400 Subject: [PATCH 42/59] WIP --- shared/chat/inbox-search/index.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/shared/chat/inbox-search/index.tsx b/shared/chat/inbox-search/index.tsx index 39b5b13238ba..9d40a8ab43ef 100644 --- a/shared/chat/inbox-search/index.tsx +++ b/shared/chat/inbox-search/index.tsx @@ -84,11 +84,11 @@ export default function InboxSearchContainer(ownProps: OwnProps) { const toggleCollapseBots = () => setBotsCollapsed(s => !s) const toggleBotsAll = () => setBotsAll(s => !s) - const renderOpenTeams = (h: {item: Item; index: number; section: Section}) => { - const {item, index, section} = h + const renderOpenTeams: Section['renderItem'] = ({item, index, section}) => { if (item.type !== 'openTeam') return null + const fullSection = section as Section const {hit} = item - const realIndex = index + section.indexOffset + const realIndex = index + fullSection.indexOffset return ( { - const {item, index, section} = h + const renderBots: Section['renderItem'] = ({item, index, section}) => { if (item.type !== 'bot') return null - const realIndex = index + section.indexOffset + const fullSection = section as Section + const realIndex = index + fullSection.indexOffset return ( Date: Fri, 10 Apr 2026 13:07:19 -0400 Subject: [PATCH 43/59] WIP --- plans/inbox.md | 159 -------------------------- shared/chat/inbox/defer-loading.tsx | 31 ----- shared/chat/inbox/root.tsx | 8 ++ shared/chat/inbox/use-inbox-state.tsx | 2 +- shared/chat/routes.tsx | 8 +- shared/constants/init/shared.tsx | 7 +- 6 files changed, 17 insertions(+), 198 deletions(-) delete mode 100644 plans/inbox.md delete mode 100644 shared/chat/inbox/defer-loading.tsx create mode 100644 shared/chat/inbox/root.tsx diff --git a/plans/inbox.md b/plans/inbox.md deleted file mode 100644 index 11c36b7a9c8a..000000000000 --- a/plans/inbox.md +++ /dev/null @@ -1,159 +0,0 @@ -# Inbox Search Refactor - -## Summary - -The current inbox search setup uses `shared/chat/inbox/search-state.tsx` as a temporary bridge because desktop search input is rendered in `shared/chat/inbox-and-conversation-header.tsx`, while inbox search results and inbox list rendering live in the inbox tree. The refactor should happen in two parts: - -1. Move desktop search out of the navigator header and into the inbox pane so desktop search can be owned by ordinary React state in one tree. -2. Follow with a broader cleanup that unifies native and desktop inbox search ownership and reduces remaining platform divergence where practical. -3. Clean up mobile inbox startup so push/saved-thread launches do not need the old deferred inbox mount workaround. - -## Progress - -- [x] Part 1 completed. -- [x] Part 2 completed: desktop and native inbox screens now consume the same route-owned inbox-search controller contract, and inbox-search state is no longer owned by `shared/stores/chat.tsx`. -- [ ] Part 3 planned: remove the mobile inbox deferred mount path and gate expensive inbox startup work on focus instead. - -## Part 1: Move Desktop Search Out Of The Header - -Goal: - -- Remove the cross-tree desktop search split. -- Delete the temporary inbox-search Zustand store/provider after desktop no longer needs it. -- Keep user-visible native behavior unchanged. - -Implementation changes: - -- Remove `` from `shared/chat/inbox-and-conversation-header.tsx`. -- Render the desktop search row at the top of the inbox pane, in the same tree that decides between desktop inbox list and desktop inbox search results. -- Make `shared/chat/inbox-and-conversation.tsx` own desktop search state and search lifecycle: - - `isSearching` - - `query` - - `searchInfo` - - selected result movement - - select / cancel / submit handlers - - active search RPC cancellation and restart -- Convert desktop `SearchRow` to a prop-driven component for search state and actions instead of reading `useInboxSearchState` directly. -- Convert desktop `InboxSearch` to receive search state and handlers from the local owner instead of reading the inbox-search Zustand store. -- Remove `InboxSearchProvider` usage from `shared/chat/inbox-and-conversation.tsx` and `shared/chat/inbox/defer-loading.tsx`. -- Delete `shared/chat/inbox/search-state.tsx` and its test once all consumers are moved off it. - -Behavior requirements: - -- Desktop still supports `Cmd+K`, Escape, Arrow Up/Down, and Enter. -- Desktop still swaps between inbox list and inbox search results the same way it does now. -- Selecting a conversation result closes search. -- Selecting a text hit opens thread search with the query. -- Desktop cancels active search RPCs on new query, unmount, and background. - -Part 1 boundaries: - -- Native keeps search inside the inbox tree as it does today. -- Do not bundle broader inbox row/list cleanup into this step. -- Do not change `convostate`. - -## Part 2: Broader Inbox Cleanup / Native + Desktop Unification - -Goal: - -- Unify inbox search ownership across native and desktop. -- Share one inbox-search controller shape and one search-results rendering path where possible. -- Reduce the remaining “desktop owner vs native owner” split without forcing identical list implementations. - -Implementation changes: - -- Introduce one inbox-search controller hook in the inbox feature layer, owned by the route tree rather than Zustand. -- Use the same controller contract on both platforms: - - `isSearching` - - `query` - - `searchInfo` - - `startSearch` - - `cancelSearch` - - `setQuery` - - `moveSelectedIndex` - - `selectResult` -- Make both `shared/chat/inbox/index.desktop.tsx` and `shared/chat/inbox/index.native.tsx` consume that controller contract. -- Keep platform-specific list behavior where it is genuinely platform-specific: - - desktop drag divider / DOM-specific list behavior - - native mobile list layout behavior -- Keep one inbox-search results component interface so native and desktop no longer depend on different ownership models. -- Keep search lifecycle management in the shared inbox-search hook: - - cancel previous search when query changes - - cancel on unmount - - cancel on background while mounted - -Part 2 boundaries: - -- Do not force desktop and native into one identical inbox screen file. -- Do not refactor inbox virtualization, drag resizing, or unread shortcut behavior unless required to support the shared search controller. -- Additional pruning of non-search inbox state in `shared/stores/chat.tsx` is a follow-up, not required for this plan. - -## Part 3: Remove Mobile Inbox Deferred Startup Workaround - -Goal: - -- Stop relying on `shared/chat/inbox/defer-loading.tsx` to hide inbox mount work during phone startup from push or saved thread state. -- Keep the startup perf win by preventing expensive inbox refresh work until the inbox is actually focused. -- Remove the old gesture-handler timing workaround rather than preserving it by default. - -Implementation changes: - -- Route phone `chatRoot` directly to the normal inbox screen instead of `shared/chat/inbox/defer-loading.tsx`. -- Move the “do inbox work only when the inbox is actually visible” policy into inbox state ownership, primarily `shared/chat/inbox/use-inbox-state.tsx`. -- On mobile, avoid mount-time `inboxRefresh('componentNeverLoaded')` when `chatRoot` is mounted behind `chatConversation`. -- Keep the existing focus-driven refresh path as the trigger for first inbox load on phone. -- Delete `shared/chat/inbox/defer-loading.tsx` if no longer needed. - -Behavior requirements: - -- Cold start from a push or saved last thread should still open directly to the conversation. -- That startup path should not eagerly trigger inbox refresh work just because `chatRoot` exists under the pushed conversation. -- Opening the inbox afterward should still load inbox data normally. -- Normal non-push startup into chat inbox should still load inbox data without requiring extra navigation steps. - -Part 3 boundaries: - -- Do not change the push deep-link or saved-state startup routing shape unless that becomes necessary after the inbox-side cleanup. -- Do not keep the old 100ms gesture workaround unless it is proven still necessary after the simpler focus-gated approach. -- Do not bundle unrelated inbox row, search, or conversation-screen refactors into this step. - -## Interfaces / Types - -- `SearchRow` should become prop-driven and stop depending on a dedicated inbox-search store. -- `InboxSearch` should be fed by the local inbox owner / shared controller hook, not Zustand. -- `T.Chat.InboxSearchInfo` should remain the backing results/status shape unless a later cleanup proves a smaller local type is clearly better. -- No daemon RPC interfaces change. - -## Test Plan - -Part 1: - -- Desktop search opens from the inbox pane, not the navigator header. -- `Cmd+K` focuses the desktop inbox search input. -- Typing updates results and selection behavior still works. -- Escape closes search. -- Enter selects the highlighted result. -- Active inbox search RPC is canceled on query replacement, background, and unmount. -- There are no remaining imports/usages of `useInboxSearchState` or `InboxSearchProvider`. - -Part 2: - -- Desktop and native both use the same inbox-search controller contract. -- Native user-visible behavior stays the same. -- Desktop and native both cancel active search RPCs on unmount/background. -- Selecting text hits vs conversation hits behaves the same on both platforms. -- No inbox-search state remains in `shared/stores/chat.tsx`. - -Part 3: - -- Push/saved-thread startup on phone does not eagerly refresh inbox data before the inbox is focused. -- Opening inbox after such a startup still loads inbox rows correctly. -- Normal direct navigation to chat inbox still triggers initial inbox load. -- `shared/chat/inbox/defer-loading.tsx` is removed if nothing still depends on it. - -## Assumptions - -- Part 1 is intentionally desktop-first and can leave native slightly different until Part 2. -- The main value of Part 1 is eliminating the cross-tree desktop search problem so local React ownership becomes possible. -- “Unify native and desktop inbox work” means shared search ownership and interface, not necessarily identical screen files or identical list internals. -- Part 3 assumes the deferred inbox wrapper is no longer the right place to solve startup cost; focus-gating inbox refresh work is the preferred simpler model. diff --git a/shared/chat/inbox/defer-loading.tsx b/shared/chat/inbox/defer-loading.tsx deleted file mode 100644 index 04596ee72041..000000000000 --- a/shared/chat/inbox/defer-loading.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import * as React from 'react' -import Inbox from '.' -import {useIsFocused} from '@react-navigation/core' -import type {ChatRootRouteParams} from '../inbox-and-conversation' -import {useInboxSearch} from './use-inbox-search' - -// keep track of this even on unmount, else if you background / foreground you'll lose it -let _everFocused = false - -export default function Deferred(_props: ChatRootRouteParams) { - const [visible, setVisible] = React.useState(_everFocused) - const isFocused = useIsFocused() - const search = useInboxSearch() - React.useEffect(() => { - _everFocused = _everFocused || isFocused - }, [isFocused]) - - // work around a bug in gesture handler if we show too quickly when going back from a convo on startup - React.useEffect(() => { - if (!isFocused || visible) { - return - } - const id = setTimeout(() => { - setVisible(true) - }, 100) - return () => { - clearTimeout(id) - } - }, [isFocused, visible]) - return visible ? : null -} diff --git a/shared/chat/inbox/root.tsx b/shared/chat/inbox/root.tsx new file mode 100644 index 000000000000..f17f04c4bcdd --- /dev/null +++ b/shared/chat/inbox/root.tsx @@ -0,0 +1,8 @@ +import Inbox from '.' +import {useInboxSearch} from './use-inbox-search' + +export default function InboxRoot() { + const search = useInboxSearch() + + return +} diff --git a/shared/chat/inbox/use-inbox-state.tsx b/shared/chat/inbox/use-inbox-state.tsx index 193ae8c6b554..77261584044b 100644 --- a/shared/chat/inbox/use-inbox-state.tsx +++ b/shared/chat/inbox/use-inbox-state.tsx @@ -43,7 +43,7 @@ export function useInboxState(conversationIDKey?: string, isSearching = false) { if (!C.isMobile) { Chat.getConvoState(Chat.getSelectedConversation()).dispatch.tabSelected() } - if (!inboxHasLoaded) { + if (!C.isPhone && !inboxHasLoaded) { inboxRefresh('componentNeverLoaded') } }) diff --git a/shared/chat/routes.tsx b/shared/chat/routes.tsx index 83b0c8aabd02..a481bad33ca9 100644 --- a/shared/chat/routes.tsx +++ b/shared/chat/routes.tsx @@ -22,10 +22,10 @@ const ChatRootScreen = React.lazy(async () => { default: (p: StaticScreenProps) => , } }) -const ChatRootDeferredScreen = React.lazy(async () => { - const mod = await import('./inbox/defer-loading') +const ChatRootInboxScreen = React.lazy(async () => { + const mod = await import('./inbox/root') return { - default: (p: StaticScreenProps) => , + default: (_p: StaticScreenProps) => , } }) @@ -172,7 +172,7 @@ export const newRoutes = defineRouteMap({ : { getOptions: inboxGetOptions, initialParams: emptyChatRootRouteParams, - screen: ChatRootDeferredScreen, + screen: ChatRootInboxScreen, }, }) diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 4cbd18e92fdb..9e6f0e1ec2ed 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -37,7 +37,7 @@ import {getSelectedConversation} from '@/constants/chat/common' import * as CryptoRoutes from '@/constants/crypto' import {emitDeepLink} from '@/router-v2/linking' import {ignorePromise} from '../utils' -import {isMobile, serverConfigFileName} from '../platform' +import {isMobile, isPhone, serverConfigFileName} from '../platform' import {storeRegistry} from '@/stores/store-registry' import {useAutoResetState} from '@/stores/autoreset' import {useAvatarState} from '@/common-adapters/avatar/store' @@ -397,8 +397,9 @@ export const initSharedSubscriptions = () => { } const updateChat = async () => { - // On login lets load the untrusted inbox. This helps make some flows easier - if (useCurrentUserState.getState().username) { + // On phone, let the focused inbox screen trigger the first refresh so hidden chatRoot + // mounts behind a pushed conversation do not pay inbox startup cost. + if (!isPhone && useCurrentUserState.getState().username) { const {inboxRefresh} = useChatState.getState().dispatch inboxRefresh('bootstrap') } From b497ed3097b5943ba3caaf6215a1ca5e2d229347 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 13:26:00 -0400 Subject: [PATCH 44/59] WIP --- shared/chat/inbox/header-controls.tsx | 44 ------------- shared/chat/inbox/index.d.ts | 2 +- shared/chat/inbox/index.desktop.tsx | 21 ++++++- shared/chat/inbox/index.native.tsx | 21 ++++++- shared/chat/inbox/root.tsx | 8 --- shared/chat/inbox/search-row.tsx | 63 ++++++++++++------- .../chat/inbox/use-header-portal.desktop.tsx | 6 +- .../chat/inbox/use-header-portal.native.tsx | 6 +- shared/chat/routes.tsx | 25 +++----- 9 files changed, 99 insertions(+), 97 deletions(-) delete mode 100644 shared/chat/inbox/header-controls.tsx delete mode 100644 shared/chat/inbox/root.tsx diff --git a/shared/chat/inbox/header-controls.tsx b/shared/chat/inbox/header-controls.tsx deleted file mode 100644 index b44a4a736067..000000000000 --- a/shared/chat/inbox/header-controls.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import * as Kb from '@/common-adapters' -import ChatFilterRow from './filter-row' -import NewChatButton from './new-chat-button' -import type {InboxSearchController} from './use-inbox-search' - -type Props = { - search: Pick< - InboxSearchController, - 'cancelSearch' | 'isSearching' | 'moveSelectedIndex' | 'query' | 'selectResult' | 'setQuery' | 'startSearch' - > -} - -export default function InboxHeaderControls({search}: Props) { - return ( - - - search.moveSelectedIndex(false)} - onSelectDown={() => search.moveSelectedIndex(true)} - onEnsureSelection={search.selectResult} - onQueryChanged={search.setQuery} - query={search.query} - showSearch={true} - startSearch={search.startSearch} - /> - - - - ) -} - -const styles = Kb.Styles.styleSheetCreate( - () => - ({ - row: { - alignItems: 'center', - height: '100%', - paddingRight: Kb.Styles.globalMargins.tiny, - width: '100%', - }, - }) as const -) diff --git a/shared/chat/inbox/index.d.ts b/shared/chat/inbox/index.d.ts index 873a90dfcc6e..40f155cf43b3 100644 --- a/shared/chat/inbox/index.d.ts +++ b/shared/chat/inbox/index.d.ts @@ -4,7 +4,7 @@ import type {InboxSearchController} from './use-inbox-search' type Props = { conversationIDKey?: ConversationIDKey - search: InboxSearchController + search?: InboxSearchController } declare const Inbox: (p: Props) => React.ReactNode diff --git a/shared/chat/inbox/index.desktop.tsx b/shared/chat/inbox/index.desktop.tsx index 8a068552ec8e..ed5536fcae2d 100644 --- a/shared/chat/inbox/index.desktop.tsx +++ b/shared/chat/inbox/index.desktop.tsx @@ -22,6 +22,7 @@ import {createPortal} from 'react-dom' import {inboxWidth, smallRowHeight, getRowHeight} from './row/sizes' import {makeRow} from './row' import type {InboxSearchController} from './use-inbox-search' +import {useInboxSearch} from './use-inbox-search' import {useInboxState} from './use-inbox-state' import './inbox.css' @@ -196,11 +197,21 @@ const DragLine = (p: { } type InboxProps = { + conversationIDKey?: T.Chat.ConversationIDKey + search?: InboxSearchController +} + +type ControlledInboxProps = { conversationIDKey?: T.Chat.ConversationIDKey search: InboxSearchController } -function Inbox(props: InboxProps) { +function InboxWithSearch(props: {conversationIDKey?: T.Chat.ConversationIDKey}) { + const search = useInboxSearch() + return +} + +function InboxBody(props: ControlledInboxProps) { const {conversationIDKey, search} = props const inbox = useInboxState(conversationIDKey, search.isSearching) const {smallTeamsExpanded, rows, unreadIndices, unreadTotal, inboxNumSmallRows} = inbox @@ -322,6 +333,14 @@ function Inbox(props: InboxProps) { ) } +export default function Inbox(props: InboxProps) { + return props.search ? ( + + ) : ( + + ) +} + const styles = Kb.Styles.styleSheetCreate( () => ({ diff --git a/shared/chat/inbox/index.native.tsx b/shared/chat/inbox/index.native.tsx index c79038f6f998..9c08b45b497e 100644 --- a/shared/chat/inbox/index.native.tsx +++ b/shared/chat/inbox/index.native.tsx @@ -16,6 +16,7 @@ import type {LegendListRef} from '@/common-adapters' import {makeRow} from './row' import {useOpenedRowState} from './row/opened-row-state' import type {InboxSearchController} from './use-inbox-search' +import {useInboxSearch} from './use-inbox-search' import {useInboxState} from './use-inbox-state' import {type RowItem, type ViewableItemsData, viewabilityConfig, getItemType, keyExtractor, useUnreadShortcut, useScrollUnbox} from './list-helpers' @@ -51,11 +52,21 @@ const NoChats = (props: {onNewChat: () => void}) => ( ) type InboxProps = { + conversationIDKey?: T.Chat.ConversationIDKey + search?: InboxSearchController +} + +type ControlledInboxProps = { conversationIDKey?: T.Chat.ConversationIDKey search: InboxSearchController } -function Inbox(p: InboxProps) { +function InboxWithSearch(props: {conversationIDKey?: T.Chat.ConversationIDKey}) { + const search = useInboxSearch() + return +} + +function InboxBody(p: ControlledInboxProps) { const {search} = p const inbox = useInboxState(p.conversationIDKey, search.isSearching) const {onUntrustedInboxVisible, toggleSmallTeamsExpanded, selectedConversationIDKey} = inbox @@ -186,6 +197,14 @@ function Inbox(p: InboxProps) { ) } +export default function Inbox(props: InboxProps) { + return props.search ? ( + + ) : ( + + ) +} + const NoRowsBuildTeam = () => { const isLoading = C.useWaitingState(s => [...s.counts.keys()].some(k => k.startsWith('chat:'))) return isLoading ? null : diff --git a/shared/chat/inbox/root.tsx b/shared/chat/inbox/root.tsx deleted file mode 100644 index f17f04c4bcdd..000000000000 --- a/shared/chat/inbox/root.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import Inbox from '.' -import {useInboxSearch} from './use-inbox-search' - -export default function InboxRoot() { - const search = useInboxSearch() - - return -} diff --git a/shared/chat/inbox/search-row.tsx b/shared/chat/inbox/search-row.tsx index b625e98be191..b1848f022cd1 100644 --- a/shared/chat/inbox/search-row.tsx +++ b/shared/chat/inbox/search-row.tsx @@ -1,6 +1,8 @@ import * as C from '@/constants' import * as Chat from '@/stores/chat' +import * as Kb from '@/common-adapters' import ChatFilterRow from './filter-row' +import NewChatButton from './new-chat-button' import StartNewChat from './row/start-new-chat' import type {InboxSearchController} from './use-inbox-search' @@ -9,11 +11,13 @@ type OwnProps = { InboxSearchController, 'cancelSearch' | 'isSearching' | 'moveSelectedIndex' | 'query' | 'selectResult' | 'setQuery' | 'startSearch' > + forceShowFilter?: boolean showSearch: boolean + showNewChatButton?: boolean } export default function InboxSearchRow(ownProps: OwnProps) { - const {search, showSearch} = ownProps + const {forceShowFilter, search, showNewChatButton, showSearch} = ownProps const {cancelSearch, isSearching, moveSelectedIndex, query, selectResult, setQuery, startSearch} = search const chatState = Chat.useChatState( C.useShallow(s => { @@ -28,34 +32,51 @@ export default function InboxSearchRow(ownProps: OwnProps) { }) ) const {showEmptyInbox} = chatState - const showStartNewChat = !C.isMobile && !isSearching && showEmptyInbox - const showFilter = isSearching || !showEmptyInbox + const showStartNewChat = !showNewChatButton && !C.isMobile && !isSearching && showEmptyInbox + const showFilter = !!forceShowFilter || isSearching || !showEmptyInbox const appendNewChatBuilder = C.Router2.appendNewChatBuilder const navigateUp = C.Router2.navigateUp - const onQueryChanged = (q: string) => { - setQuery(q) + const filter = showFilter ? ( + moveSelectedIndex(false)} + onSelectDown={() => moveSelectedIndex(true)} + onEnsureSelection={selectResult} + onQueryChanged={setQuery} + query={query} + showSearch={showSearch} + startSearch={startSearch} + /> + ) : null + + if (showNewChatButton) { + return ( + + {filter} + + + ) } return ( <> - {!!showStartNewChat && ( - - )} - {!!showFilter && ( - moveSelectedIndex(false)} - onSelectDown={() => moveSelectedIndex(true)} - onEnsureSelection={selectResult} - onQueryChanged={onQueryChanged} - query={query} - showSearch={showSearch} - startSearch={startSearch} - /> - )} + {!!showStartNewChat && } + {filter} ) } + +const styles = Kb.Styles.styleSheetCreate( + () => + ({ + row: { + alignItems: 'center', + height: '100%', + paddingRight: Kb.Styles.globalMargins.tiny, + width: '100%', + }, + }) as const +) diff --git a/shared/chat/inbox/use-header-portal.desktop.tsx b/shared/chat/inbox/use-header-portal.desktop.tsx index cd2c2a2b5274..7f35bee8fe87 100644 --- a/shared/chat/inbox/use-header-portal.desktop.tsx +++ b/shared/chat/inbox/use-header-portal.desktop.tsx @@ -1,9 +1,11 @@ import {createPortal} from 'react-dom' -import InboxHeaderControls from './header-controls' +import SearchRow from './search-row' import {useInboxHeaderPortalNode} from './header-portal-state' import type {InboxSearchController} from './use-inbox-search' export default function useInboxHeaderPortal(search: InboxSearchController) { const portalNode = useInboxHeaderPortalNode() - return portalNode ? createPortal(, portalNode) : null + return portalNode + ? createPortal(, portalNode) + : null } diff --git a/shared/chat/inbox/use-header-portal.native.tsx b/shared/chat/inbox/use-header-portal.native.tsx index 591654d05b27..513d46d013cf 100644 --- a/shared/chat/inbox/use-header-portal.native.tsx +++ b/shared/chat/inbox/use-header-portal.native.tsx @@ -1,6 +1,6 @@ import * as C from '@/constants' import * as React from 'react' -import InboxHeaderControls from './header-controls' +import SearchRow from './search-row' import {setInboxHeaderPortalContent} from './header-portal-state' import type {InboxSearchController} from './use-inbox-search' @@ -9,7 +9,9 @@ export default function useInboxHeaderPortal(search: InboxSearchController) { if (!C.isTablet) { return } - setInboxHeaderPortalContent() + setInboxHeaderPortalContent( + + ) return () => { setInboxHeaderPortalContent(null) } diff --git a/shared/chat/routes.tsx b/shared/chat/routes.tsx index a481bad33ca9..0a92387617a2 100644 --- a/shared/chat/routes.tsx +++ b/shared/chat/routes.tsx @@ -9,25 +9,12 @@ import {headerNavigationOptions} from './conversation/header-area' import {useConfigState} from '@/stores/config' import {useModalHeaderState} from '@/stores/modal-header' import {ModalTitle} from '@/teams/common' -import type {StaticScreenProps} from '@react-navigation/core' import inboxGetOptions from './inbox/get-options' import inboxAndConvoGetOptions from './inbox-and-conversation-get-options' import {defineRouteMap} from '@/constants/types/router' import type {BlockModalContext} from './blocking/block-modal' import type {ChatRootRouteParams} from './inbox-and-conversation' const Convo = React.lazy(async () => import('./conversation/container')) -const ChatRootScreen = React.lazy(async () => { - const mod = await import('./inbox-and-conversation') - return { - default: (p: StaticScreenProps) => , - } -}) -const ChatRootInboxScreen = React.lazy(async () => { - const mod = await import('./inbox/root') - return { - default: (_p: StaticScreenProps) => , - } -}) type ChatBlockingRouteParams = { blockUserByDefault?: boolean @@ -165,14 +152,18 @@ export const newRoutes = defineRouteMap({ }, chatRoot: Chat.isSplit ? { - getOptions: inboxAndConvoGetOptions, + ...Chat.makeChatScreen(React.lazy(async () => import('./inbox-and-conversation')), { + getOptions: inboxAndConvoGetOptions, + skipProvider: true, + }), initialParams: emptyChatRootRouteParams, - screen: ChatRootScreen, } : { - getOptions: inboxGetOptions, + ...Chat.makeChatScreen(React.lazy(async () => import('./inbox')), { + getOptions: inboxGetOptions, + skipProvider: true, + }), initialParams: emptyChatRootRouteParams, - screen: ChatRootInboxScreen, }, }) From e5aaa17731c1ea5ffe4014cc049c809ac3323659 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 13:28:24 -0400 Subject: [PATCH 45/59] WIP --- .../chat/conversation/input-area/normal/index.tsx | 5 +---- shared/chat/inbox-and-conversation.native.tsx | 13 ++++++++----- shared/chat/inbox/index.desktop.tsx | 2 +- shared/chat/inbox/index.native.tsx | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/shared/chat/conversation/input-area/normal/index.tsx b/shared/chat/conversation/input-area/normal/index.tsx index 09a4b1d4af4c..596ad302e84c 100644 --- a/shared/chat/conversation/input-area/normal/index.tsx +++ b/shared/chat/conversation/input-area/normal/index.tsx @@ -111,10 +111,7 @@ const doInjectText = (inputRef: React.RefObject, text: string, const ConnectedPlatformInput = function ConnectedPlatformInput() { const route = useRoute | RootRouteProps<'chatRoot'>>() - const infoPanelShowing = - route.name === 'chatRoot' && !!route.params && 'infoPanel' in route.params - ? !!route.params.infoPanel - : false + const infoPanelShowing = route.name === 'chatRoot' ? !!route.params.infoPanel : false const uiData = Chat.useChatUIContext( C.useShallow(s => ({ editOrdinal: s.editing, diff --git a/shared/chat/inbox-and-conversation.native.tsx b/shared/chat/inbox-and-conversation.native.tsx index 6cbb64ade78b..75bc3fc293b1 100644 --- a/shared/chat/inbox-and-conversation.native.tsx +++ b/shared/chat/inbox-and-conversation.native.tsx @@ -5,12 +5,15 @@ import {useInboxSearch} from './inbox/use-inbox-search' export default function InboxAndConversationNative(props: InboxAndConversationProps) { const search = useInboxSearch() - useInboxHeaderPortal(search) + const headerPortal = useInboxHeaderPortal(search) return ( - } - /> + <> + {headerPortal} + } + /> + ) } diff --git a/shared/chat/inbox/index.desktop.tsx b/shared/chat/inbox/index.desktop.tsx index ed5536fcae2d..8f74175f909b 100644 --- a/shared/chat/inbox/index.desktop.tsx +++ b/shared/chat/inbox/index.desktop.tsx @@ -333,7 +333,7 @@ function InboxBody(props: ControlledInboxProps) { ) } -export default function Inbox(props: InboxProps) { +function Inbox(props: InboxProps) { return props.search ? ( ) : ( diff --git a/shared/chat/inbox/index.native.tsx b/shared/chat/inbox/index.native.tsx index 9c08b45b497e..10b932d1dad9 100644 --- a/shared/chat/inbox/index.native.tsx +++ b/shared/chat/inbox/index.native.tsx @@ -197,7 +197,7 @@ function InboxBody(p: ControlledInboxProps) { ) } -export default function Inbox(props: InboxProps) { +function Inbox(props: InboxProps) { return props.search ? ( ) : ( From 16c658b1cb56e7031924370125f7c4174dd5a169 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 15:04:56 -0400 Subject: [PATCH 46/59] WIP --- shared/stores/convostate.tsx | 9 ++++++- shared/stores/tests/convostate.test.ts | 34 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index d3af476071df..beb259a1bdd5 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -2691,13 +2691,20 @@ const createSlice = }, onMessagesUpdated: messagesUpdated => { if (!messagesUpdated.updates) return + const activelyLookingAtThread = Common.isUserActivelyLookingAtThisThread(get().id) + if (!get().loaded && !activelyLookingAtThread) { + return + } const {username, devicename} = getCurrentUser() const messages = messagesUpdated.updates.flatMap(uimsg => { if (!Message.getMessageID(uimsg)) return [] const message = Message.uiMessageToMessage(get().id, uimsg, username, getLastOrdinal, devicename) return message ? [message] : [] }) - messagesAdd(messages, {why: 'messages updated'}) + if (messages.length === 0) { + return + } + messagesAdd(messages, {markAsRead: activelyLookingAtThread, why: 'messages updated'}) }, openFolder: () => { const meta = get().meta diff --git a/shared/stores/tests/convostate.test.ts b/shared/stores/tests/convostate.test.ts index a2c0e674b491..1c25aff6eecc 100644 --- a/shared/stores/tests/convostate.test.ts +++ b/shared/stores/tests/convostate.test.ts @@ -1,4 +1,5 @@ /// +import * as Common from '../../constants/chat/common' import * as Meta from '../../constants/chat/meta' import * as Message from '../../constants/chat/message' import * as T from '../../constants/types' @@ -15,6 +16,10 @@ jest.mock('../inbox-rows', () => ({ queueInboxRowUpdate: jest.fn(), })) +afterEach(() => { + jest.restoreAllMocks() +}) + const convID = T.Chat.conversationIDToKey(new Uint8Array([1, 2, 3, 4])) const ordinal = T.Chat.numberToOrdinal(10) const msgID = T.Chat.numberToMessageID(101) @@ -263,6 +268,8 @@ test('testing store starts with initial state and helper selectors', () => { }) test('onMessagesUpdated adds messages and recomputes derived thread maps', () => { + jest.spyOn(Common, 'isUserActivelyLookingAtThisThread').mockReturnValue(true) + const store = createStore() const firstMsgID = T.Chat.numberToMessageID(301) const secondMsgID = T.Chat.numberToMessageID(302) @@ -287,6 +294,33 @@ test('onMessagesUpdated adds messages and recomputes derived thread maps', () => expect(store.getState().messageTypeMap.size).toBe(0) }) +test('onMessagesUpdated ignores unopened background conversations', () => { + jest.spyOn(Common, 'isUserActivelyLookingAtThisThread').mockReturnValue(false) + + const store = createStore() + store.getState().dispatch.onMessagesUpdated({ + convID: T.Chat.keyToConversationID(convID), + updates: [makeValidTextUIMessage(T.Chat.numberToMessageID(401), 'background update')], + }) + + expect(store.getState().messageOrdinals).toBeUndefined() + expect(store.getState().messageMap.size).toBe(0) +}) + +test('onMessagesUpdated still applies to unopened active conversations', () => { + jest.spyOn(Common, 'isUserActivelyLookingAtThisThread').mockReturnValue(true) + + const store = createStore() + const msgID = T.Chat.numberToMessageID(402) + store.getState().dispatch.onMessagesUpdated({ + convID: T.Chat.keyToConversationID(convID), + updates: [makeValidTextUIMessage(msgID, 'active update')], + }) + + expect(store.getState().messageOrdinals).toEqual([T.Chat.numberToOrdinal(402)]) + expect(store.getState().messageMap.get(T.Chat.numberToOrdinal(402))?.id).toBe(msgID) +}) + test('message updates refresh derived metadata for the following row', () => { const firstOrdinal = T.Chat.numberToOrdinal(301) const secondOrdinal = T.Chat.numberToOrdinal(302) From ac9e5b4127b5570683aabf77e2a3637cd6079e8d Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 15:06:24 -0400 Subject: [PATCH 47/59] WIP --- shared/chat/conversation/input-area/normal/index.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/shared/chat/conversation/input-area/normal/index.tsx b/shared/chat/conversation/input-area/normal/index.tsx index 596ad302e84c..d61efe95e241 100644 --- a/shared/chat/conversation/input-area/normal/index.tsx +++ b/shared/chat/conversation/input-area/normal/index.tsx @@ -15,7 +15,7 @@ import {FocusContext, ScrollContext} from '@/chat/conversation/normal/context' import type {RefType as InputRef} from './input' import {useCurrentUserState} from '@/stores/current-user' import {useRoute} from '@react-navigation/native' -import type {RootRouteProps} from '@/router-v2/route-params' +import {getRouteParamsFromRoute, type RootRouteProps} from '@/router-v2/route-params' const useHintText = (p: { isExploding: boolean @@ -111,7 +111,8 @@ const doInjectText = (inputRef: React.RefObject, text: string, const ConnectedPlatformInput = function ConnectedPlatformInput() { const route = useRoute | RootRouteProps<'chatRoot'>>() - const infoPanelShowing = route.name === 'chatRoot' ? !!route.params.infoPanel : false + const params = getRouteParamsFromRoute<'chatConversation' | 'chatRoot'>(route) + const infoPanelShowing = !!(params && typeof params === 'object' && 'infoPanel' in params && params.infoPanel) const uiData = Chat.useChatUIContext( C.useShallow(s => ({ editOrdinal: s.editing, From 1b42a3a1b111dc0527b1143b9d0b45cd0605bc0f Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 15:12:33 -0400 Subject: [PATCH 48/59] dont load always --- .agents/skills/react-native-best-practices/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/react-native-best-practices/SKILL.md b/.agents/skills/react-native-best-practices/SKILL.md index d3a297e2d833..4a497ff2462a 100644 --- a/.agents/skills/react-native-best-practices/SKILL.md +++ b/.agents/skills/react-native-best-practices/SKILL.md @@ -1,6 +1,6 @@ --- name: react-native-best-practices -description: "Software Mansion's best practices for production React Native and Expo apps on the New Architecture. MUST USE before writing, reviewing, or debugging ANY code in a React Native or Expo project. If the working directory contains a package.json with react-native, expo, or expo-router as a dependency, this skill applies. Trigger on: any code task in a React Native/Expo project, 'React Native', 'Expo', 'New Architecture', 'Reanimated', 'Gesture Handler', 'react-native-svg', 'ExecuTorch', 'react-native-audio-api', 'react-native-enriched', 'Worklet', 'Fabric', 'TurboModule', 'WebGPU', 'react-native-wgpu', 'TypeGPU', 'GPU shader', 'WGSL', 'svg', 'animation', 'gesture', 'audio', 'rich text', 'AI model', 'multithreading', 'chart', 'vector', 'image filter', 'shared value', 'useSharedValue', 'runOnJS', 'scheduleOnRN', 'thread', 'worklet', or any question involving UI, graphics, native modules, or React Native threading and animation behavior. Also use when a more specific sub-skill matches." +description: "Software Mansion's best practices for production React Native and Expo apps on the New Architecture. Trigger on: any code task in a React Native/Expo project, 'React Native', 'Expo', 'New Architecture', 'Reanimated', 'Gesture Handler', 'react-native-svg', 'ExecuTorch', 'react-native-audio-api', 'react-native-enriched', 'Worklet', 'Fabric', 'TurboModule', 'WebGPU', 'react-native-wgpu', 'TypeGPU', 'GPU shader', 'WGSL', 'svg', 'animation', 'gesture', 'audio', 'rich text', 'AI model', 'multithreading', 'chart', 'vector', 'image filter', 'shared value', 'useSharedValue', 'runOnJS', 'scheduleOnRN', 'thread', 'worklet', or any question involving UI, graphics, native modules, or React Native threading and animation behavior. Also use when a more specific sub-skill matches." license: MIT --- From a675825cceb70c2ddd6efe14f963e43a5813e129 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 15:19:06 -0400 Subject: [PATCH 49/59] WIP --- .../conversation/list-area/index.native.tsx | 4 +- shared/chat/inbox-search/index.tsx | 27 +++- .../chat/inbox/use-header-portal.native.tsx | 17 ++- shared/chat/inbox/use-inbox-search.test.tsx | 21 +++ shared/chat/inbox/use-inbox-search.tsx | 126 ++++++++++++++---- 5 files changed, 161 insertions(+), 34 deletions(-) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 6874f836951a..796de7452993 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -125,9 +125,7 @@ const ConversationList = function ConversationList() { const listRef = React.useRef |*/ FlatList | null>(null) const conversationIDKeyRef = React.useRef(conversationIDKey) - React.useEffect(() => { - conversationIDKeyRef.current = conversationIDKey - }, [conversationIDKey]) + conversationIDKeyRef.current = conversationIDKey const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) const keyExtractor = (ordinal: ItemType) => { return String(ordinal) diff --git a/shared/chat/inbox-search/index.tsx b/shared/chat/inbox-search/index.tsx index 9d40a8ab43ef..05644ac5721d 100644 --- a/shared/chat/inbox-search/index.tsx +++ b/shared/chat/inbox-search/index.tsx @@ -15,11 +15,12 @@ import { inboxSearchMaxTextMessages, inboxSearchPreviewSectionSize, type InboxSearchController, + type InboxSearchVisibleResultCounts, } from '../inbox/use-inbox-search' type OwnProps = { header?: React.ReactElement | null - search: Pick + search: Pick } type NameResult = { @@ -52,7 +53,7 @@ type Item = NameResult | TextResult | BotResult | OpenTeamResult export default function InboxSearchContainer(ownProps: OwnProps) { const { - search: {searchInfo: _inboxSearch, selectResult}, + search: {searchInfo: _inboxSearch, selectResult, setVisibleResultCounts}, } = ownProps const navigateAppend = C.Router2.navigateAppend const onInstallBot = (username: string) => { @@ -298,6 +299,28 @@ export default function InboxSearchContainer(ownProps: OwnProps) { botsCollapsed ? [] : botsAll ? _botsResults : _botsResults.slice(0, inboxSearchPreviewSectionSize) const indexOffset = botsResults.length + openTeamsResults.length + nameResults.length + const visibleResultCounts = React.useMemo( + () => ({ + bots: botsResults.length, + names: nameCollapsed ? 0 : _nameResults.length, + openTeams: openTeamsResults.length, + text: textCollapsed || nameResultsUnread ? 0 : _textResults.length, + }), + [ + botsResults.length, + nameCollapsed, + nameResultsUnread, + openTeamsResults.length, + textCollapsed, + _nameResults.length, + _textResults.length, + ] + ) + + React.useLayoutEffect(() => { + setVisibleResultCounts(visibleResultCounts) + }, [setVisibleResultCounts, visibleResultCounts]) + const nameSection: Section = { data: nameResults, indexOffset: 0, diff --git a/shared/chat/inbox/use-header-portal.native.tsx b/shared/chat/inbox/use-header-portal.native.tsx index 513d46d013cf..513c295b6d2f 100644 --- a/shared/chat/inbox/use-header-portal.native.tsx +++ b/shared/chat/inbox/use-header-portal.native.tsx @@ -5,17 +5,26 @@ import {setInboxHeaderPortalContent} from './header-portal-state' import type {InboxSearchController} from './use-inbox-search' export default function useInboxHeaderPortal(search: InboxSearchController) { + const content = React.useMemo( + () => , + [search] + ) + + React.useEffect(() => { + if (!C.isTablet) { + return + } + setInboxHeaderPortalContent(content) + }, [content]) + React.useEffect(() => { if (!C.isTablet) { return } - setInboxHeaderPortalContent( - - ) return () => { setInboxHeaderPortalContent(null) } - }, [search]) + }, []) return null } diff --git a/shared/chat/inbox/use-inbox-search.test.tsx b/shared/chat/inbox/use-inbox-search.test.tsx index 4ecab6c6f086..88f695f2bf2e 100644 --- a/shared/chat/inbox/use-inbox-search.test.tsx +++ b/shared/chat/inbox/use-inbox-search.test.tsx @@ -28,3 +28,24 @@ test('inbox search selection movement stays within available results', () => { selectedIndex = nextInboxSearchSelectedIndex({...inboxSearch, selectedIndex}, false) expect(selectedIndex).toBe(0) }) + +test('inbox search selection movement respects visible result counts', () => { + const inboxSearch = makeInboxSearchInfo() + inboxSearch.nameResults = [{conversationIDKey: '1'} as any] + inboxSearch.openTeamsResults = new Array(5).fill({name: 'team'}) as any + inboxSearch.botsResults = new Array(5).fill({botUsername: 'bot'}) as any + inboxSearch.textResults = [{conversationIDKey: '2', query: 'needle', time: 1} as any] + + const selectedIndex = nextInboxSearchSelectedIndex( + {...inboxSearch, selectedIndex: 7}, + true, + { + bots: 5, + names: 1, + openTeams: 5, + text: 1, + } + ) + + expect(selectedIndex).toBe(8) +}) diff --git a/shared/chat/inbox/use-inbox-search.tsx b/shared/chat/inbox/use-inbox-search.tsx index 3436da054966..d4b12fcc3cfa 100644 --- a/shared/chat/inbox/use-inbox-search.tsx +++ b/shared/chat/inbox/use-inbox-search.tsx @@ -13,6 +13,13 @@ export const inboxSearchMaxNameResults = 7 export const inboxSearchMaxUnreadNameResults = isMobile ? 5 : 10 export const inboxSearchPreviewSectionSize = 3 +export type InboxSearchVisibleResultCounts = { + bots: number + names: number + openTeams: number + text: number +} + export const makeInboxSearchInfo = (): T.Chat.InboxSearchInfo => ({ botsResults: [], botsResultsSuggested: false, @@ -30,8 +37,44 @@ export const makeInboxSearchInfo = (): T.Chat.InboxSearchInfo => ({ textStatus: 'initial', }) -const getInboxSearchSelected = ( +const getDefaultVisibleResultCounts = ( inboxSearch: T.Immutable +): InboxSearchVisibleResultCounts => ({ + bots: Math.min(inboxSearch.botsResults.length, inboxSearchPreviewSectionSize), + names: inboxSearch.nameResults.length, + openTeams: Math.min(inboxSearch.openTeamsResults.length, inboxSearchPreviewSectionSize), + text: inboxSearch.nameResultsUnread ? 0 : inboxSearch.textResults.length, +}) + +const areVisibleResultCountsEqual = ( + left: InboxSearchVisibleResultCounts, + right: InboxSearchVisibleResultCounts +) => + left.bots === right.bots && + left.names === right.names && + left.openTeams === right.openTeams && + left.text === right.text + +const getTotalVisibleResultCount = (visibleResultCounts: InboxSearchVisibleResultCounts) => + visibleResultCounts.names + + visibleResultCounts.openTeams + + visibleResultCounts.bots + + visibleResultCounts.text + +const clampSelectedIndex = ( + selectedIndex: number, + visibleResultCounts: InboxSearchVisibleResultCounts +) => { + const totalResults = getTotalVisibleResultCount(visibleResultCounts) + if (totalResults <= 0) { + return 0 + } + return Math.min(selectedIndex, totalResults - 1) +} + +const getInboxSearchSelected = ( + inboxSearch: T.Immutable, + visibleResultCounts = getDefaultVisibleResultCounts(inboxSearch) ): | undefined | { @@ -40,12 +83,10 @@ const getInboxSearchSelected = ( openTeamName?: string query?: string } => { - const {selectedIndex, nameResults, botsResults, openTeamsResults, textResults} = inboxSearch - const visibleBotsResults = botsResults.slice(0, inboxSearchPreviewSectionSize) - const visibleOpenTeamResults = openTeamsResults.slice(0, inboxSearchPreviewSectionSize) - const firstBotResultIdx = nameResults.length + visibleOpenTeamResults.length - const firstTextResultIdx = firstBotResultIdx + visibleBotsResults.length - const firstOpenTeamResultIdx = nameResults.length + const {selectedIndex, nameResults, textResults} = inboxSearch + const firstOpenTeamResultIdx = visibleResultCounts.names + const firstBotResultIdx = firstOpenTeamResultIdx + visibleResultCounts.openTeams + const firstTextResultIdx = firstBotResultIdx + visibleResultCounts.bots if (selectedIndex < firstOpenTeamResultIdx) { const maybeNameResults = nameResults[selectedIndex] @@ -74,14 +115,11 @@ const getInboxSearchSelected = ( export const nextInboxSearchSelectedIndex = ( inboxSearch: T.Immutable, - increment: boolean + increment: boolean, + visibleResultCounts = getDefaultVisibleResultCounts(inboxSearch) ) => { const {selectedIndex} = inboxSearch - const totalResults = - inboxSearch.nameResults.length + - Math.min(inboxSearch.openTeamsResults.length, inboxSearchPreviewSectionSize) + - Math.min(inboxSearch.botsResults.length, inboxSearchPreviewSectionSize) + - inboxSearch.textResults.length + const totalResults = getTotalVisibleResultCount(visibleResultCounts) if (increment && selectedIndex < totalResults - 1) { return selectedIndex + 1 } @@ -107,6 +145,7 @@ export type InboxSearchController = { searchInfo: T.Chat.InboxSearchInfo selectResult: InboxSearchSelect setQuery: (query: string) => void + setVisibleResultCounts: (visibleResultCounts: InboxSearchVisibleResultCounts) => void startSearch: () => void } @@ -117,6 +156,9 @@ export function useInboxSearch(): InboxSearchController { const activeSearchIDRef = React.useRef(0) const isSearchingRef = React.useRef(isSearching) const searchInfoRef = React.useRef(searchInfo) + const visibleResultCountsRef = React.useRef( + getDefaultVisibleResultCounts(searchInfo) + ) React.useEffect(() => { isSearchingRef.current = isSearching @@ -148,6 +190,7 @@ export function useInboxSearch(): InboxSearchController { isSearchingRef.current = false const next = makeInboxSearchInfo() searchInfoRef.current = next + visibleResultCountsRef.current = getDefaultVisibleResultCounts(next) setIsSearching(false) setSearchInfo(next) cancelActiveSearch() @@ -343,10 +386,29 @@ export function useInboxSearch(): InboxSearchController { clearSearch() }, [clearSearch]) + const setVisibleResultCounts = React.useCallback( + (visibleResultCounts: InboxSearchVisibleResultCounts) => { + if (areVisibleResultCountsEqual(visibleResultCountsRef.current, visibleResultCounts)) { + return + } + visibleResultCountsRef.current = visibleResultCounts + setSearchInfo(prev => { + const selectedIndex = clampSelectedIndex(prev.selectedIndex, visibleResultCounts) + if (selectedIndex === prev.selectedIndex) { + return prev + } + const next = {...prev, selectedIndex} + searchInfoRef.current = next + return next + }) + }, + [] + ) + const moveSelectedIndex = React.useCallback((increment: boolean) => { updateSearchInfo(prev => ({ ...prev, - selectedIndex: nextInboxSearchSelectedIndex(prev, increment), + selectedIndex: nextInboxSearchSelectedIndex(prev, increment, visibleResultCountsRef.current), })) }, [updateSearchInfo]) @@ -366,7 +428,7 @@ export function useInboxSearch(): InboxSearchController { return } - const selected = getInboxSearchSelected(latestSearchInfo) + const selected = getInboxSearchSelected(latestSearchInfo, visibleResultCountsRef.current) if (!conversationIDKey) { conversationIDKey = selected?.conversationIDKey } @@ -409,6 +471,7 @@ export function useInboxSearch(): InboxSearchController { isSearchingRef.current = true const next = makeInboxSearchInfo() searchInfoRef.current = next + visibleResultCountsRef.current = getDefaultVisibleResultCounts(next) setIsSearching(true) setSearchInfo(next) runSearch('') @@ -427,14 +490,27 @@ export function useInboxSearch(): InboxSearchController { } }, [clearSearch, mobileAppState]) - return { - cancelSearch, - isSearching, - moveSelectedIndex, - query: searchInfo.query, - searchInfo, - selectResult, - setQuery, - startSearch, - } + return React.useMemo( + () => ({ + cancelSearch, + isSearching, + moveSelectedIndex, + query: searchInfo.query, + searchInfo, + selectResult, + setQuery, + setVisibleResultCounts, + startSearch, + }), + [ + cancelSearch, + isSearching, + moveSelectedIndex, + searchInfo, + selectResult, + setQuery, + setVisibleResultCounts, + startSearch, + ] + ) } From fb71d8c90af4013a9e7a8508008bb7ad8464f79c Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 15:22:07 -0400 Subject: [PATCH 50/59] WIP --- shared/chat/conversation/list-area/index.native.tsx | 11 +++++------ shared/chat/inbox/use-inbox-search.tsx | 4 +--- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 796de7452993..8cd709e7e1c7 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -124,8 +124,6 @@ const ConversationList = function ConversationList() { const messageOrdinals = useInvertedMessageOrdinals(listData.messageOrdinals) const listRef = React.useRef |*/ FlatList | null>(null) - const conversationIDKeyRef = React.useRef(conversationIDKey) - conversationIDKeyRef.current = conversationIDKey const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) const keyExtractor = (ordinal: ItemType) => { return String(ordinal) @@ -146,14 +144,15 @@ const ConversationList = function ConversationList() { const numOrdinals = messageOrdinals.length - const [getItemType] = React.useState( - () => (ordinal: T.Chat.Ordinal) => { + const getItemType = React.useCallback( + (ordinal: T.Chat.Ordinal) => { if (!ordinal) { return 'null' } - const convoState = Chat.getConvoState(conversationIDKeyRef.current) + const convoState = Chat.getConvoState(conversationIDKey) return convoState.rowRecycleTypeMap.get(ordinal) ?? convoState.messageTypeMap.get(ordinal) ?? 'text' - } + }, + [conversationIDKey] ) const {scrollToCentered, scrollToBottom, onEndReached} = useScrolling({ diff --git a/shared/chat/inbox/use-inbox-search.tsx b/shared/chat/inbox/use-inbox-search.tsx index d4b12fcc3cfa..45487d0223ce 100644 --- a/shared/chat/inbox/use-inbox-search.tsx +++ b/shared/chat/inbox/use-inbox-search.tsx @@ -156,9 +156,7 @@ export function useInboxSearch(): InboxSearchController { const activeSearchIDRef = React.useRef(0) const isSearchingRef = React.useRef(isSearching) const searchInfoRef = React.useRef(searchInfo) - const visibleResultCountsRef = React.useRef( - getDefaultVisibleResultCounts(searchInfo) - ) + const visibleResultCountsRef = React.useRef(getDefaultVisibleResultCounts(searchInfo)) React.useEffect(() => { isSearchingRef.current = isSearching From ef726f8a3f0b14385ec2bdf4feec9a1622bdabe0 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 16:10:21 -0400 Subject: [PATCH 51/59] WIP --- go/chat/search/indexer.go | 8 +++++++- go/chat/search_test.go | 12 ++++++++++++ shared/chat/inbox-search/index.tsx | 5 ++--- shared/chat/inbox/use-inbox-search.tsx | 2 +- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/go/chat/search/indexer.go b/go/chat/search/indexer.go index ec303f752515..c1e65515ed4d 100644 --- a/go/chat/search/indexer.go +++ b/go/chat/search/indexer.go @@ -867,7 +867,13 @@ func (idx *Indexer) PercentIndexed(ctx context.Context, convID chat1.Conversatio func (idx *Indexer) Clear(ctx context.Context, uid gregor1.UID, convID chat1.ConversationID) (err error) { defer idx.Trace(ctx, &err, "Indexer.Clear uid: %v convID: %v", uid, convID)() - return idx.store.Clear(ctx, uid, convID) + idx.Lock() + store := idx.store + idx.Unlock() + if store == nil { + return nil + } + return store.Clear(ctx, uid, convID) } func (idx *Indexer) OnDbNuke(mctx libkb.MetaContext) (err error) { diff --git a/go/chat/search_test.go b/go/chat/search_test.go index 76d98527a15b..ce711dd314a1 100644 --- a/go/chat/search_test.go +++ b/go/chat/search_test.go @@ -1160,3 +1160,15 @@ func TestSearchIndexerNoDeadlockOnClearDuringAdd(t *testing.T) { require.Fail(t, "indexer did not stop") } } + +func TestSearchIndexerClearWithoutStoreIsSafe(t *testing.T) { + ctx := context.TODO() + ctc := makeChatTestContext(t, "SearchIndexerClearWithoutStore", 1) + defer ctc.cleanup() + g := ctc.world.Tcs[ctc.users()[0].Username].Context() + + indexer := search.NewIndexer(g) + require.NotPanics(t, func() { + require.NoError(t, indexer.Clear(ctx, gregor1.UID(nil), chat1.ConversationID(nil))) + }) +} diff --git a/shared/chat/inbox-search/index.tsx b/shared/chat/inbox-search/index.tsx index 05644ac5721d..1ef40326cb50 100644 --- a/shared/chat/inbox-search/index.tsx +++ b/shared/chat/inbox-search/index.tsx @@ -302,17 +302,16 @@ export default function InboxSearchContainer(ownProps: OwnProps) { const visibleResultCounts = React.useMemo( () => ({ bots: botsResults.length, - names: nameCollapsed ? 0 : _nameResults.length, + names: nameResults.length, openTeams: openTeamsResults.length, text: textCollapsed || nameResultsUnread ? 0 : _textResults.length, }), [ botsResults.length, - nameCollapsed, + nameResults.length, nameResultsUnread, openTeamsResults.length, textCollapsed, - _nameResults.length, _textResults.length, ] ) diff --git a/shared/chat/inbox/use-inbox-search.tsx b/shared/chat/inbox/use-inbox-search.tsx index 45487d0223ce..4e5b6a8b8a03 100644 --- a/shared/chat/inbox/use-inbox-search.tsx +++ b/shared/chat/inbox/use-inbox-search.tsx @@ -41,7 +41,7 @@ const getDefaultVisibleResultCounts = ( inboxSearch: T.Immutable ): InboxSearchVisibleResultCounts => ({ bots: Math.min(inboxSearch.botsResults.length, inboxSearchPreviewSectionSize), - names: inboxSearch.nameResults.length, + names: inboxSearch.nameResults.length || (inboxSearch.nameResultsUnread ? 1 : 0), openTeams: Math.min(inboxSearch.openTeamsResults.length, inboxSearchPreviewSectionSize), text: inboxSearch.nameResultsUnread ? 0 : inboxSearch.textResults.length, }) From dde403eda6500b84b9c4d6aaa90c78fcc203ca51 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 10 Apr 2026 16:38:09 -0400 Subject: [PATCH 52/59] WIP --- shared/chat/inbox/use-inbox-search.tsx | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/shared/chat/inbox/use-inbox-search.tsx b/shared/chat/inbox/use-inbox-search.tsx index 4e5b6a8b8a03..70e74ac12b61 100644 --- a/shared/chat/inbox/use-inbox-search.tsx +++ b/shared/chat/inbox/use-inbox-search.tsx @@ -183,17 +183,25 @@ export function useInboxSearch(): InboxSearchController { ignorePromise(f()) }, []) - const clearSearch = React.useCallback(() => { - activeSearchIDRef.current++ - isSearchingRef.current = false + const resetSearchState = React.useCallback(() => { const next = makeInboxSearchInfo() searchInfoRef.current = next visibleResultCountsRef.current = getDefaultVisibleResultCounts(next) setIsSearching(false) setSearchInfo(next) + }, []) + + const invalidateSearch = React.useCallback(() => { + activeSearchIDRef.current++ + isSearchingRef.current = false cancelActiveSearch() }, [cancelActiveSearch]) + const clearSearch = React.useCallback(() => { + invalidateSearch() + resetSearchState() + }, [invalidateSearch, resetSearchState]) + const isActiveSearch = React.useCallback( (searchID: number) => searchID === activeSearchIDRef.current && isSearchingRef.current, [] @@ -476,11 +484,11 @@ export function useInboxSearch(): InboxSearchController { }, [runSearch]) React.useEffect(() => { - clearSearch() + cancelActiveSearch() return () => { - clearSearch() + invalidateSearch() } - }, [clearSearch]) + }, [cancelActiveSearch, invalidateSearch]) React.useEffect(() => { if (mobileAppState === 'background' && isSearchingRef.current) { From 33025f5b6e142ba559f5967faf15fa2e828ce27b Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Mon, 13 Apr 2026 09:51:59 -0400 Subject: [PATCH 53/59] WIP --- go/chat/search/deadlock_test.go | 233 ++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 go/chat/search/deadlock_test.go diff --git a/go/chat/search/deadlock_test.go b/go/chat/search/deadlock_test.go new file mode 100644 index 000000000000..90c14aa696be --- /dev/null +++ b/go/chat/search/deadlock_test.go @@ -0,0 +1,233 @@ +package search + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/keybase/client/go/chat/globals" + "github.com/keybase/client/go/externalstest" + "github.com/keybase/client/go/kbtest" + "github.com/keybase/client/go/protocol/chat1" + "github.com/keybase/client/go/protocol/gregor1" + "github.com/stretchr/testify/require" +) + +type deadlockTestDiskStorage struct { + clearEntered chan struct{} + clearRelease chan struct{} +} + +func (d *deadlockTestDiskStorage) GetTokenEntry(ctx context.Context, convID chat1.ConversationID, + token string, +) (res *tokenEntry, err error) { + return nil, nil +} + +func (d *deadlockTestDiskStorage) PutTokenEntry(ctx context.Context, convID chat1.ConversationID, + token string, te *tokenEntry, +) error { + return nil +} + +func (d *deadlockTestDiskStorage) RemoveTokenEntry(ctx context.Context, convID chat1.ConversationID, token string) {} + +func (d *deadlockTestDiskStorage) GetAliasEntry(ctx context.Context, alias string) (res *aliasEntry, err error) { + return nil, nil +} + +func (d *deadlockTestDiskStorage) PutAliasEntry(ctx context.Context, alias string, ae *aliasEntry) error { + return nil +} + +func (d *deadlockTestDiskStorage) RemoveAliasEntry(ctx context.Context, alias string) {} + +func (d *deadlockTestDiskStorage) GetMetadata(ctx context.Context, convID chat1.ConversationID) (res *indexMetadata, err error) { + return nil, nil +} + +func (d *deadlockTestDiskStorage) PutMetadata(ctx context.Context, convID chat1.ConversationID, md *indexMetadata) error { + return nil +} + +func (d *deadlockTestDiskStorage) Clear(ctx context.Context, uid gregor1.UID, convID chat1.ConversationID) error { + if d.clearEntered != nil { + select { + case d.clearEntered <- struct{}{}: + default: + } + } + if d.clearRelease != nil { + select { + case <-d.clearRelease: + case <-ctx.Done(): + return ctx.Err() + } + } + return nil +} + +type blockingGetMsgsChatHelper struct { + *kbtest.MockChatHelper + calledCh chan struct{} + releaseCh chan struct{} +} + +func (h *blockingGetMsgsChatHelper) GetMessages(ctx context.Context, uid gregor1.UID, + convID chat1.ConversationID, msgIDs []chat1.MessageID, + resolveSupersedes bool, reason *chat1.GetThreadReason, +) ([]chat1.MessageUnboxed, error) { + select { + case h.calledCh <- struct{}{}: + default: + } + select { + case <-h.releaseCh: + case <-ctx.Done(): + return nil, ctx.Err() + } + return nil, nil +} + +func setupDeadlockTestStore(t *testing.T) (*globals.Context, *store) { + tc := externalstest.SetupTest(t, "search-deadlock", 2) + t.Cleanup(tc.Cleanup) + g := globals.NewContext(tc.G, &globals.ChatContext{}) + uid := gregor1.UID([]byte{1, 2, 3, 4}) + s := newStore(g, uid) + s.diskStorage = &deadlockTestDiskStorage{} + return g, s +} + +func TestSearchDeadlockRegression(t *testing.T) { + t.Run("store add releases lock before superseded fetch", func(t *testing.T) { + ctx := context.TODO() + g, s := setupDeadlockTestStore(t) + calledCh := make(chan struct{}, 1) + releaseCh := make(chan struct{}) + var releaseOnce sync.Once + releaseFetch := func() { + releaseOnce.Do(func() { + close(releaseCh) + }) + } + t.Cleanup(releaseFetch) + g.ExternalG().ChatHelper = &blockingGetMsgsChatHelper{ + MockChatHelper: kbtest.NewMockChatHelper(), + calledCh: calledCh, + releaseCh: releaseCh, + } + + convID := chat1.ConversationID([]byte{ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + }) + editMsg := chat1.NewMessageUnboxedWithValid(chat1.MessageUnboxedValid{ + ClientHeader: chat1.MessageClientHeaderVerified{ + MessageType: chat1.MessageType_EDIT, + Conv: chat1.ConversationIDTriple{ + TopicType: chat1.TopicType_CHAT, + }, + }, + MessageBody: chat1.NewMessageBodyWithEdit(chat1.MessageEdit{ + MessageID: 1, + Body: "hello world", + }), + ServerHeader: chat1.MessageServerHeader{ + MessageID: 2, + }, + }) + + addDone := make(chan error, 1) + go func() { + addDone <- s.Add(ctx, convID, []chat1.MessageUnboxed{editMsg}) + }() + + select { + case <-calledCh: + case <-time.After(10 * time.Second): + require.Fail(t, "store.Add never reached GetMessages") + } + + clearDone := make(chan struct{}) + go func() { + s.ClearMemory() + close(clearDone) + }() + + select { + case <-clearDone: + case <-time.After(5 * time.Second): + releaseFetch() + require.Fail(t, "store.Add held s.Lock while blocked in GetMessages") + } + + releaseFetch() + select { + case err := <-addDone: + require.NoError(t, err) + case <-time.After(10 * time.Second): + require.Fail(t, "store.Add never completed after GetMessages was released") + } + }) + + t.Run("indexer clear releases lock while storage clear is blocked", func(t *testing.T) { + ctx := context.TODO() + tc := externalstest.SetupTest(t, "indexer-clear-lock", 2) + t.Cleanup(tc.Cleanup) + g := globals.NewContext(tc.G, &globals.ChatContext{}) + uid := gregor1.UID([]byte{9, 8, 7, 6}) + convID := chat1.ConversationID([]byte{ + 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, + }) + + idx := NewIndexer(g) + idx.SetUID(uid) + ds := &deadlockTestDiskStorage{ + clearEntered: make(chan struct{}, 1), + clearRelease: make(chan struct{}), + } + var releaseOnce sync.Once + releaseClear := func() { + releaseOnce.Do(func() { + close(ds.clearRelease) + }) + } + t.Cleanup(releaseClear) + idx.store.diskStorage = ds + idx.started = true + + clearDone := make(chan error, 1) + go func() { + clearDone <- idx.Clear(ctx, uid, convID) + }() + + select { + case <-ds.clearEntered: + case <-time.After(10 * time.Second): + require.Fail(t, "Indexer.Clear never reached diskStorage.Clear") + } + + suspendDone := make(chan struct{}) + go func() { + idx.Suspend(ctx) + close(suspendDone) + }() + + select { + case <-suspendDone: + case <-time.After(5 * time.Second): + releaseClear() + require.Fail(t, "Indexer.Clear held idx.Lock while blocked in diskStorage.Clear") + } + idx.Resume(ctx) + + releaseClear() + select { + case err := <-clearDone: + require.NoError(t, err) + case <-time.After(10 * time.Second): + require.Fail(t, "Indexer.Clear never completed after diskStorage.Clear was released") + } + }) +} From 25d80b041425884d07b61a1f8d403069e21a5969 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Mon, 13 Apr 2026 09:52:26 -0400 Subject: [PATCH 54/59] WIP --- go/chat/search_test.go | 1174 ---------------------------------------- 1 file changed, 1174 deletions(-) delete mode 100644 go/chat/search_test.go diff --git a/go/chat/search_test.go b/go/chat/search_test.go deleted file mode 100644 index ce711dd314a1..000000000000 --- a/go/chat/search_test.go +++ /dev/null @@ -1,1174 +0,0 @@ -package chat - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/keybase/client/go/chat/globals" - "github.com/keybase/client/go/chat/search" - "github.com/keybase/client/go/kbtest" - "github.com/keybase/client/go/libkb" - "github.com/keybase/client/go/protocol/chat1" - "github.com/keybase/client/go/protocol/gregor1" - "github.com/keybase/client/go/protocol/keybase1" - "github.com/keybase/client/go/protocol/stellar1" - "github.com/stretchr/testify/require" -) - -// blockingGetMsgsChatHelper wraps MockChatHelper and blocks GetMessages until -// released, so tests can observe locking behavior in store.Add. -type blockingGetMsgsChatHelper struct { - *kbtest.MockChatHelper - calledCh chan struct{} - releaseCh chan struct{} -} - -func (h *blockingGetMsgsChatHelper) GetMessages(ctx context.Context, uid gregor1.UID, - convID chat1.ConversationID, msgIDs []chat1.MessageID, - resolveSupersedes bool, reason *chat1.GetThreadReason, -) ([]chat1.MessageUnboxed, error) { - select { - case h.calledCh <- struct{}{}: - default: - } - select { - case <-h.releaseCh: - case <-ctx.Done(): - } - return nil, nil -} - -func TestChatSearchConvRegexp(t *testing.T) { - runWithMemberTypes(t, func(mt chat1.ConversationMembersType) { - // Only test against IMPTEAMNATIVE. There is a bug in ChatRemoteMock - // with using Pagination Next/Prev and we don't need to triple test - // here. - switch mt { - case chat1.ConversationMembersType_IMPTEAMNATIVE: - default: - return - } - - ctc := makeChatTestContext(t, "SearchRegexp", 2) - defer ctc.cleanup() - users := ctc.users() - u1 := users[0] - u2 := users[1] - - conv := mustCreateConversationForTest(t, ctc, u1, chat1.TopicType_CHAT, - mt, ctc.as(t, u2).user()) - convID := conv.Id - - tc1 := ctc.as(t, u1) - tc2 := ctc.as(t, u2) - - chatUI := kbtest.NewChatUI() - tc1.h.mockChatUI = chatUI - - listener1 := newServerChatListener() - tc1.h.G().NotifyRouter.AddListener(listener1) - listener2 := newServerChatListener() - tc2.h.G().NotifyRouter.AddListener(listener2) - - sendMessage := func(msgBody chat1.MessageBody, user *kbtest.FakeUser) chat1.MessageID { - msgID := mustPostLocalForTest(t, ctc, user, conv, msgBody) - typ, err := msgBody.MessageType() - require.NoError(t, err) - consumeNewMsgRemote(t, listener1, typ) - consumeNewMsgRemote(t, listener2, typ) - return msgID - } - - verifyHit := func(beforeMsgIDs []chat1.MessageID, hitMessageID chat1.MessageID, afterMsgIDs []chat1.MessageID, - matches []chat1.ChatSearchMatch, searchHit chat1.ChatSearchHit, - ) { - _verifyHit := func(searchHit chat1.ChatSearchHit) { - if beforeMsgIDs == nil { - require.Nil(t, searchHit.BeforeMessages) - } else { - require.Equal(t, len(beforeMsgIDs), len(searchHit.BeforeMessages)) - for i, msgID := range beforeMsgIDs { - msg := searchHit.BeforeMessages[i] - t.Logf("msg: %v", msg.Valid()) - require.True(t, msg.IsValid()) - require.Equal(t, msgID, msg.GetMessageID()) - } - } - require.EqualValues(t, hitMessageID, searchHit.HitMessage.Valid().MessageID) - require.Equal(t, matches, searchHit.Matches) - - if afterMsgIDs == nil { - require.Nil(t, searchHit.AfterMessages) - } else { - require.Equal(t, len(afterMsgIDs), len(searchHit.AfterMessages)) - for i, msgID := range afterMsgIDs { - msg := searchHit.AfterMessages[i] - require.True(t, msg.IsValid()) - require.Equal(t, msgID, msg.GetMessageID()) - } - } - } - _verifyHit(searchHit) - select { - case searchHitRes := <-chatUI.SearchHitCb: - _verifyHit(searchHitRes.SearchHit) - case <-time.After(20 * time.Second): - require.Fail(t, "no search result received") - } - } - verifySearchDone := func(numHits int) { - select { - case searchDone := <-chatUI.SearchDoneCb: - require.Equal(t, numHits, searchDone.NumHits) - case <-time.After(20 * time.Second): - require.Fail(t, "no search result received") - } - } - - runSearch := func(query string, isRegex bool, opts chat1.SearchOpts) chat1.SearchRegexpRes { - opts.IsRegex = isRegex - res, err := tc1.chatLocalHandler().SearchRegexp(tc1.startCtx, chat1.SearchRegexpArg{ - ConvID: convID, - Query: query, - Opts: opts, - }) - require.NoError(t, err) - t.Logf("query: %v, searchRes: %+v", query, res) - return res - } - - isRegex := false - opts := chat1.SearchOpts{ - MaxHits: 5, - BeforeContext: 2, - AfterContext: 2, - MaxMessages: 1000, - } - - // Test basic equality match - query := "hi" - msgBody := "hi @here" - msgID1 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ - Body: msgBody, - }), u1) - searchMatch := chat1.ChatSearchMatch{ - StartIndex: 0, - EndIndex: 2, - Match: query, - } - res := runSearch(query, isRegex, opts) - require.Equal(t, 1, len(res.Hits)) - verifyHit(nil, msgID1, nil, []chat1.ChatSearchMatch{searchMatch}, res.Hits[0]) - verifySearchDone(1) - - // Test basic no results - query = "hey" - res = runSearch(query, isRegex, opts) - require.Equal(t, 0, len(res.Hits)) - verifySearchDone(0) - - // Test maxHits - opts.MaxHits = 1 - query = "hi" - msgBody = "hi there" - msgID2 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ - Body: msgBody, - }), u1) - res = runSearch(query, isRegex, opts) - require.Equal(t, 1, len(res.Hits)) - verifyHit([]chat1.MessageID{msgID1}, msgID2, nil, []chat1.ChatSearchMatch{searchMatch}, res.Hits[0]) - verifySearchDone(1) - - opts.MaxHits = 5 - res = runSearch(query, isRegex, opts) - require.Equal(t, 2, len(res.Hits)) - verifyHit([]chat1.MessageID{msgID1}, msgID2, nil, []chat1.ChatSearchMatch{searchMatch}, res.Hits[0]) - verifyHit(nil, msgID1, []chat1.MessageID{msgID2}, []chat1.ChatSearchMatch{searchMatch}, res.Hits[1]) - verifySearchDone(2) - - msgID3 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ - Body: msgBody, - }), u1) - res = runSearch(query, isRegex, opts) - require.Equal(t, 3, len(res.Hits)) - verifyHit([]chat1.MessageID{msgID1, msgID2}, msgID3, nil, []chat1.ChatSearchMatch{searchMatch}, res.Hits[0]) - verifyHit([]chat1.MessageID{msgID1}, msgID2, []chat1.MessageID{msgID3}, []chat1.ChatSearchMatch{searchMatch}, res.Hits[1]) - verifyHit(nil, msgID1, []chat1.MessageID{msgID2, msgID3}, []chat1.ChatSearchMatch{searchMatch}, res.Hits[2]) - verifySearchDone(3) - - // test sentBy - // invalid username - opts.SentBy = u1.Username + "foo" - res = runSearch(query, isRegex, opts) - require.Zero(t, len(res.Hits)) - verifySearchDone(0) - - // send from user2 and make sure we can filter, @mention user1 to test - // SentTo later. - opts.SentBy = u2.Username - msgBody = fmt.Sprintf("hi @%s", u1.Username) - msgID4 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ - Body: msgBody, - }), u2) - res = runSearch(query, isRegex, opts) - require.Equal(t, 1, len(res.Hits)) - verifyHit([]chat1.MessageID{msgID2, msgID3}, msgID4, nil, []chat1.ChatSearchMatch{searchMatch}, res.Hits[0]) - verifySearchDone(1) - opts.SentBy = "" - - // test sentTo - // invalid username - opts.SentTo = u1.Username + "foo" - res = runSearch(query, isRegex, opts) - require.Zero(t, len(res.Hits)) - verifySearchDone(0) - - opts.SentTo = u1.Username - res = runSearch(query, isRegex, opts) - require.Equal(t, 2, len(res.Hits)) - verifyHit([]chat1.MessageID{msgID2, msgID3}, msgID4, nil, []chat1.ChatSearchMatch{searchMatch}, res.Hits[0]) - verifyHit(nil, msgID1, []chat1.MessageID{msgID2, msgID3}, []chat1.ChatSearchMatch{searchMatch}, res.Hits[1]) - verifySearchDone(2) - opts.SentTo = "" - - // test sentBefore/sentAfter - msgRes, err := tc1.chatLocalHandler().GetMessagesLocal(tc1.startCtx, chat1.GetMessagesLocalArg{ - ConversationID: convID, - MessageIDs: []chat1.MessageID{msgID1, msgID4}, - }) - require.NoError(t, err) - require.Equal(t, 2, len(msgRes.Messages)) - msg1 := msgRes.Messages[0] - msg4 := msgRes.Messages[1] - - // nothing sent after msg4 - opts.SentAfter = msg4.Ctime() + 500 - res = runSearch(query, isRegex, opts) - require.Zero(t, len(res.Hits)) - - opts.SentAfter = msg1.Ctime() - res = runSearch(query, isRegex, opts) - require.Equal(t, 4, len(res.Hits)) - - // nothing sent before msg1 - opts.SentAfter = 0 - opts.SentBefore = msg1.Ctime() - 500 - res = runSearch(query, isRegex, opts) - require.Zero(t, len(res.Hits)) - - opts.SentBefore = msg4.Ctime() - res = runSearch(query, isRegex, opts) - require.Equal(t, 4, len(res.Hits)) - - opts.SentBefore = 0 - - // drain the cbs, 8 hits and 4 dones - timeout := 20 * time.Second - for range 8 + 4 { - select { - case <-chatUI.SearchHitCb: - case <-chatUI.SearchDoneCb: - case <-time.After(timeout): - require.Fail(t, "no search result received") - } - } - - query = "edited" - msgBody = "edited" - searchMatch = chat1.ChatSearchMatch{ - StartIndex: 0, - EndIndex: len(msgBody), - Match: msgBody, - } - mustEditMsg(tc2.startCtx, t, ctc, u2, conv, msgID4) - consumeNewMsgRemote(t, listener1, chat1.MessageType_EDIT) - consumeNewMsgRemote(t, listener2, chat1.MessageType_EDIT) - - res = runSearch(query, isRegex, opts) - require.Equal(t, 1, len(res.Hits)) - verifyHit([]chat1.MessageID{msgID2, msgID3}, msgID4, nil, []chat1.ChatSearchMatch{searchMatch}, res.Hits[0]) - verifySearchDone(1) - - // Test delete - mustDeleteMsg(tc2.startCtx, t, ctc, u2, conv, msgID4) - consumeNewMsgRemote(t, listener1, chat1.MessageType_DELETE) - consumeNewMsgRemote(t, listener2, chat1.MessageType_DELETE) - res = runSearch(query, isRegex, opts) - require.Equal(t, 0, len(res.Hits)) - verifySearchDone(0) - - // Test request payment - query = "payment :moneybag:" - msgBody = "payment :moneybag:" - searchMatch = chat1.ChatSearchMatch{ - StartIndex: 0, - EndIndex: len(msgBody), - Match: msgBody, - } - msgID7 := sendMessage(chat1.NewMessageBodyWithRequestpayment(chat1.MessageRequestPayment{ - RequestID: stellar1.KeybaseRequestID("dummy id"), - Note: msgBody, - }), u1) - res = runSearch(query, isRegex, opts) - require.Equal(t, 1, len(res.Hits)) - verifyHit([]chat1.MessageID{msgID2, msgID3}, msgID7, nil, []chat1.ChatSearchMatch{searchMatch}, res.Hits[0]) - verifySearchDone(1) - - // Test regex functionality - isRegex = true - - // Test utf8 - msgBody = `约书亚和约翰屌爆了` - query = `约.*` - searchMatch = chat1.ChatSearchMatch{ - StartIndex: 0, - EndIndex: len(msgBody), - Match: msgBody, - } - msgID8 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ - Body: msgBody, - }), u1) - res = runSearch(query, isRegex, opts) - require.Equal(t, 1, len(res.Hits)) - verifyHit([]chat1.MessageID{msgID3, msgID7}, msgID8, nil, []chat1.ChatSearchMatch{searchMatch}, res.Hits[0]) - verifySearchDone(1) - - msgBody = "hihihi" - query = "hi" - matches := []chat1.ChatSearchMatch{} - startIndex := 0 - for range 3 { - matches = append(matches, chat1.ChatSearchMatch{ - StartIndex: startIndex, - EndIndex: startIndex + 2, - Match: query, - }) - startIndex += 2 - } - - opts.MaxHits = 1 - msgID9 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ - Body: msgBody, - }), u1) - res = runSearch(query, isRegex, opts) - require.Equal(t, 1, len(res.Hits)) - verifyHit([]chat1.MessageID{msgID7, msgID8}, msgID9, nil, matches, res.Hits[0]) - verifySearchDone(1) - - query = "h.*" - lowercase := "abcdefghijklmnopqrstuvwxyz" - for _, char := range lowercase { - sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ - Body: "h." + string(char), - }), u1) - } - opts.MaxHits = len(lowercase) - res = runSearch(query, isRegex, opts) - require.Equal(t, opts.MaxHits, len(res.Hits)) - verifySearchDone(opts.MaxHits) - - // Test maxMessages - opts.MaxMessages = 2 - res = runSearch(query, isRegex, opts) - require.Equal(t, opts.MaxMessages, len(res.Hits)) - verifySearchDone(opts.MaxMessages) - - query = `[A-Z]*` - res = runSearch(query, isRegex, opts) - require.Equal(t, 0, len(res.Hits)) - verifySearchDone(0) - - // Test invalid regex - _, err = tc1.chatLocalHandler().SearchRegexp(tc1.startCtx, chat1.SearchRegexpArg{ - ConvID: convID, - Query: "(", - Opts: chat1.SearchOpts{ - IsRegex: true, - }, - }) - require.Error(t, err) - }) -} - -func TestChatSearchRemoveMsg(t *testing.T) { - useRemoteMock = false - defer func() { useRemoteMock = true }() - ctc := makeChatTestContext(t, "TestChatSearchRemoveMsg", 2) - defer ctc.cleanup() - - users := ctc.users() - ctx := ctc.as(t, users[0]).startCtx - tc := ctc.world.Tcs[users[0].Username] - chatUI := kbtest.NewChatUI() - uid := gregor1.UID(users[0].GetUID().ToBytes()) - ctc.as(t, users[0]).h.mockChatUI = chatUI - conv := mustCreateConversationForTest(t, ctc, users[0], chat1.TopicType_CHAT, - chat1.ConversationMembersType_IMPTEAMNATIVE) - conv1 := mustCreateConversationForTest(t, ctc, users[0], chat1.TopicType_CHAT, - chat1.ConversationMembersType_IMPTEAMNATIVE, users[1]) - - msgID0 := mustPostLocalForTest(t, ctc, users[0], conv, chat1.NewMessageBodyWithText(chat1.MessageText{ - Body: "MIKEMAXIM", - })) - msgID1 := mustPostLocalForTest(t, ctc, users[0], conv, chat1.NewMessageBodyWithText(chat1.MessageText{ - Body: "MIKEMAXIM", - })) - msgID2 := mustPostLocalForTest(t, ctc, users[0], conv1, chat1.NewMessageBodyWithText(chat1.MessageText{ - Body: "MIKEMAXIM", - })) - mustPostLocalForTest(t, ctc, users[0], conv, chat1.NewMessageBodyWithText(chat1.MessageText{ - Body: "CRICKETS", - })) - res, err := ctc.as(t, users[0]).chatLocalHandler().SearchInbox(ctx, chat1.SearchInboxArg{ - Query: "MIKEM", - Opts: chat1.SearchOpts{ - MaxConvsHit: 5, - MaxHits: 5, - }, - }) - require.NoError(t, err) - require.NotNil(t, res.Res) - require.Equal(t, 2, len(res.Res.Hits)) - if res.Res.Hits[0].ConvID.Eq(conv.Id) { - require.Equal(t, 2, len(res.Res.Hits[0].Hits)) - require.Equal(t, 1, len(res.Res.Hits[1].Hits)) - } else { - require.Equal(t, 1, len(res.Res.Hits[0].Hits)) - require.Equal(t, 2, len(res.Res.Hits[1].Hits)) - } - - mustDeleteMsg(ctx, t, ctc, users[0], conv1, msgID2) - - res, err = ctc.as(t, users[0]).chatLocalHandler().SearchInbox(ctx, chat1.SearchInboxArg{ - Query: "MIKEM", - Opts: chat1.SearchOpts{ - MaxConvsHit: 5, - MaxHits: 5, - }, - }) - require.NoError(t, err) - require.NotNil(t, res.Res) - require.Equal(t, 1, len(res.Res.Hits)) - require.Equal(t, 2, len(res.Res.Hits[0].Hits)) - - mustDeleteMsg(ctx, t, ctc, users[0], conv, msgID0) - mustDeleteMsg(ctx, t, ctc, users[0], conv, msgID1) - - hres, err := tc.ChatG.Indexer.(*search.Indexer).GetStoreHits(ctx, uid, conv.Id, "MIKEM") - require.NoError(t, err) - require.Zero(t, len(hres)) -} - -func TestChatSearchInbox(t *testing.T) { - runWithMemberTypes(t, func(mt chat1.ConversationMembersType) { - // Only test against IMPTEAMNATIVE. There is a bug in ChatRemoteMock - // with using Pagination Next/Prev and we don't need to triple test - // here. - switch mt { - case chat1.ConversationMembersType_IMPTEAMNATIVE: - default: - return - } - - ctx := context.TODO() - ctc := makeChatTestContext(t, "SearchInbox", 2) - defer ctc.cleanup() - users := ctc.users() - u1 := users[0] - u2 := users[1] - - tc1 := ctc.as(t, u1) - tc2 := ctc.as(t, u2) - g1 := ctc.world.Tcs[u1.Username].Context() - g2 := ctc.world.Tcs[u2.Username].Context() - uid1 := u1.User.GetUID().ToBytes() - uid2 := u2.User.GetUID().ToBytes() - - chatUI := kbtest.NewChatUI() - tc1.h.mockChatUI = chatUI - - listener1 := newServerChatListener() - tc1.h.G().NotifyRouter.AddListener(listener1) - listener2 := newServerChatListener() - tc2.h.G().NotifyRouter.AddListener(listener2) - - // Create our own Indexer instances so we have access to non-interface methods - indexer1 := search.NewIndexer(g1) - consumeCh1 := make(chan chat1.ConversationID, 100) - reindexCh1 := make(chan chat1.ConversationID, 100) - indexer1.SetConsumeCh(consumeCh1) - indexer1.SetReindexCh(reindexCh1) - indexer1.SetStartSyncDelay(0) - indexer1.SetUID(uid1) - indexer1.SetFlushDelay(100 * time.Millisecond) - indexer1.StartFlushLoop() - indexer1.StartStorageLoop() - // Stop the original - select { - case <-g1.Indexer.Stop(ctx): - case <-time.After(20 * time.Second): - require.Fail(t, "g1 Indexer did not stop") - } - g1.Indexer = indexer1 - - indexer2 := search.NewIndexer(g2) - consumeCh2 := make(chan chat1.ConversationID, 100) - reindexCh2 := make(chan chat1.ConversationID, 100) - indexer2.SetConsumeCh(consumeCh2) - indexer2.SetReindexCh(reindexCh2) - indexer2.SetStartSyncDelay(0) - indexer2.SetUID(uid2) - indexer2.SetFlushDelay(10 * time.Millisecond) - indexer2.StartFlushLoop() - indexer2.StartStorageLoop() - // Stop the original - select { - case <-g2.Indexer.Stop(ctx): - case <-time.After(20 * time.Second): - require.Fail(t, "g2 Indexer did not stop") - } - g2.Indexer = indexer2 - - conv := mustCreateConversationForTest(t, ctc, u1, chat1.TopicType_CHAT, - mt, ctc.as(t, u2).user()) - convID := conv.Id - - // verify zero messages case - fi, err := indexer1.FullyIndexed(ctx, convID) - require.NoError(t, err) - require.True(t, fi) - pi, err := indexer1.PercentIndexed(ctx, convID) - require.NoError(t, err) - require.Equal(t, 100, pi) - - fi, err = indexer2.FullyIndexed(ctx, convID) - require.NoError(t, err) - require.True(t, fi) - pi, err = indexer2.PercentIndexed(ctx, convID) - require.NoError(t, err) - require.Equal(t, 100, pi) - - sendMessage := func(msgBody chat1.MessageBody, user *kbtest.FakeUser) chat1.MessageID { - msgID := mustPostLocalForTest(t, ctc, user, conv, msgBody) - typ, err := msgBody.MessageType() - require.NoError(t, err) - consumeNewMsgRemote(t, listener1, typ) - consumeNewMsgRemote(t, listener2, typ) - return msgID - } - - verifyHit := func(_ chat1.ConversationID, beforeMsgIDs []chat1.MessageID, hitMessageID chat1.MessageID, - afterMsgIDs []chat1.MessageID, matches []chat1.ChatSearchMatch, searchHit chat1.ChatSearchHit, - ) { - if beforeMsgIDs == nil { - require.Nil(t, searchHit.BeforeMessages) - } else { - require.Equal(t, len(beforeMsgIDs), len(searchHit.BeforeMessages)) - for i, msgID := range beforeMsgIDs { - msg := searchHit.BeforeMessages[i] - require.True(t, msg.IsValid()) - require.Equal(t, msgID, msg.GetMessageID()) - } - } - require.EqualValues(t, hitMessageID, searchHit.HitMessage.Valid().MessageID) - require.Equal(t, matches, searchHit.Matches) - - if afterMsgIDs == nil { - require.Nil(t, searchHit.AfterMessages) - } else { - require.Equal(t, len(afterMsgIDs), len(searchHit.AfterMessages)) - for i, msgID := range afterMsgIDs { - msg := searchHit.AfterMessages[i] - require.True(t, msg.IsValid()) - require.Equal(t, msgID, msg.GetMessageID()) - } - } - } - verifySearchDone := func(numHits int, delegated bool) { - select { - case <-chatUI.InboxSearchConvHitsCb: - case <-time.After(20 * time.Second): - require.Fail(t, "no name hits") - } - select { - case searchDone := <-chatUI.InboxSearchDoneCb: - require.Equal(t, numHits, searchDone.Res.NumHits) - numConvs := 1 - if numHits == 0 { - numConvs = 0 - } - require.Equal(t, numConvs, searchDone.Res.NumConvs) - if delegated { - require.True(t, searchDone.Res.Delegated) - } else { - require.Equal(t, 100, searchDone.Res.PercentIndexed) - } - case <-time.After(20 * time.Second): - require.Fail(t, "no search result received") - } - } - - verifyIndexConsumption := func(ch chan chat1.ConversationID) { - select { - case id := <-ch: - require.Equal(t, convID, id) - case <-time.After(5 * time.Second): - require.Fail(t, "indexer didn't consume") - } - } - - verifyIndexNoConsumption := func(ch chan chat1.ConversationID) { - select { - case <-ch: - require.Fail(t, "indexer reindexed") - default: - } - } - - verifyIndex := func() { - t.Logf("verify user 1 index") - verifyIndexConsumption(consumeCh1) - t.Logf("verify user 2 index") - verifyIndexConsumption(consumeCh2) - } - - runSearch := func(query string, opts chat1.SearchOpts, expectedReindex bool) *chat1.ChatSearchInboxResults { - res, err := tc1.chatLocalHandler().SearchInbox(tc1.startCtx, chat1.SearchInboxArg{ - Query: query, - Opts: opts, - }) - require.NoError(t, err) - t.Logf("query: %v, searchRes: %+v", query, res) - if expectedReindex { - verifyIndexConsumption(reindexCh1) - } else { - verifyIndexNoConsumption(reindexCh1) - } - return res.Res - } - - opts := chat1.SearchOpts{ - MaxHits: 5, - BeforeContext: 2, - AfterContext: 2, - MaxMessages: 1000, - MaxNameConvs: 1, - } - - // Test basic equality match - msgBody := "hello, byE" - msgID1 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ - Body: msgBody, - }), u1) - - queries := []string{"hello", "hello, ByE"} - matches := []chat1.ChatSearchMatch{ - { - StartIndex: 0, - EndIndex: 5, - Match: "hello", - }, - { - StartIndex: 0, - EndIndex: 10, - Match: "hello, byE", - }, - } - for i, query := range queries { - res := runSearch(query, opts, false /* expectedReindex */) - require.Equal(t, 1, len(res.Hits)) - convHit := res.Hits[0] - require.Equal(t, convID, convHit.ConvID) - require.Equal(t, 1, len(convHit.Hits)) - verifyHit(convID, nil, msgID1, nil, []chat1.ChatSearchMatch{matches[i]}, convHit.Hits[0]) - verifySearchDone(1, false) - } - - // We get a hit but without any highlighting highlighting fails - query := "hell bye" - res := runSearch(query, opts, false /* expectedReindex */) - require.Equal(t, 1, len(res.Hits)) - convHit := res.Hits[0] - verifyHit(convID, nil, msgID1, nil, nil, convHit.Hits[0]) - verifySearchDone(1, false) - - // Test basic no results - query = "hey" - res = runSearch(query, opts, false /* expectedReindex*/) - require.Equal(t, 0, len(res.Hits)) - verifySearchDone(0, false) - - // Test maxHits - opts.MaxHits = 1 - query = "hello" - searchMatch := chat1.ChatSearchMatch{ - StartIndex: 0, - EndIndex: len(query), - Match: query, - } - msgID2 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ - Body: msgBody, - }), u1) - verifyIndex() - - res = runSearch(query, opts, false /* expectedReindex*/) - require.Equal(t, 1, len(res.Hits)) - convHit = res.Hits[0] - require.Equal(t, convID, convHit.ConvID) - require.Equal(t, 1, len(convHit.Hits)) - verifyHit(convID, []chat1.MessageID{msgID1}, msgID2, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) - verifySearchDone(1, false) - - opts.MaxHits = 5 - res = runSearch(query, opts, false /* expectedReindex*/) - require.Equal(t, 1, len(res.Hits)) - convHit = res.Hits[0] - require.Equal(t, convID, convHit.ConvID) - require.Equal(t, 2, len(convHit.Hits)) - verifyHit(convID, []chat1.MessageID{msgID1}, msgID2, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) - verifyHit(convID, nil, msgID1, []chat1.MessageID{msgID2}, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[1]) - verifySearchDone(2, false) - - msgID3 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ - Body: msgBody, - }), u1) - - verifyIndex() - - res = runSearch(query, opts, false /* expectedReindex*/) - require.Equal(t, 1, len(res.Hits)) - convHit = res.Hits[0] - require.Equal(t, convID, convHit.ConvID) - require.Equal(t, 3, len(convHit.Hits)) - verifyHit(convID, []chat1.MessageID{msgID1, msgID2}, msgID3, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) - verifyHit(convID, []chat1.MessageID{msgID1}, msgID2, []chat1.MessageID{msgID3}, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[1]) - verifyHit(convID, nil, msgID1, []chat1.MessageID{msgID2, msgID3}, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[2]) - verifySearchDone(3, false) - - // test sentBy - // invalid username - opts.SentBy = u1.Username + "foo" - res = runSearch(query, opts, false /* expectedReindex*/) - require.Zero(t, len(res.Hits)) - verifySearchDone(0, false) - - // send from user2 and make sure we can filter - opts.SentBy = u2.Username - msgBody = "hello" - query = "hello" - msgID4 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ - Body: msgBody, - }), u2) - verifyIndex() - - res = runSearch(query, opts, false /* expectedReindex*/) - require.Equal(t, 1, len(res.Hits)) - convHit = res.Hits[0] - require.Equal(t, convID, convHit.ConvID) - require.Equal(t, 1, len(convHit.Hits)) - verifyHit(convID, []chat1.MessageID{msgID2, msgID3}, msgID4, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) - verifySearchDone(1, false) - opts.SentBy = "" - - // test sentBefore/sentAfter - msgRes, err := tc1.chatLocalHandler().GetMessagesLocal(tc1.startCtx, chat1.GetMessagesLocalArg{ - ConversationID: convID, - MessageIDs: []chat1.MessageID{msgID1, msgID4}, - }) - require.NoError(t, err) - require.Equal(t, 2, len(msgRes.Messages)) - msg1 := msgRes.Messages[0] - msg4 := msgRes.Messages[1] - - // nothing sent after msg4 - opts.SentAfter = msg4.Ctime() + 500 - res = runSearch(query, opts, false /* expectedReindex*/) - require.Zero(t, len(res.Hits)) - verifySearchDone(0, false) - - opts.SentAfter = msg1.Ctime() - res = runSearch(query, opts, false /* expectedReindex*/) - require.Equal(t, 1, len(res.Hits)) - require.Equal(t, 4, len(res.Hits[0].Hits)) - verifySearchDone(4, false) - - // nothing sent before msg1 - opts.SentAfter = 0 - opts.SentBefore = msg1.Ctime() - 500 - res = runSearch(query, opts, false /* expectedReindex*/) - require.Zero(t, len(res.Hits)) - verifySearchDone(0, false) - - opts.SentBefore = msg4.Ctime() - res = runSearch(query, opts, false /* expectedReindex*/) - require.Equal(t, 1, len(res.Hits)) - require.Equal(t, 4, len(res.Hits[0].Hits)) - verifySearchDone(4, false) - opts.SentBefore = 0 - - // Test edit - query = "edited" - msgBody = "edited" - searchMatch = chat1.ChatSearchMatch{ - StartIndex: 0, - EndIndex: len(msgBody), - Match: msgBody, - } - mustEditMsg(tc2.startCtx, t, ctc, u2, conv, msgID4) - consumeNewMsgRemote(t, listener1, chat1.MessageType_EDIT) - consumeNewMsgRemote(t, listener2, chat1.MessageType_EDIT) - verifyIndex() - - res = runSearch(query, opts, false /* expectedReindex*/) - t.Logf("%+v", res) - require.Equal(t, 1, len(res.Hits)) - convHit = res.Hits[0] - require.Equal(t, convID, convHit.ConvID) - require.Equal(t, 1, len(convHit.Hits)) - verifyHit(convID, []chat1.MessageID{msgID2, msgID3}, msgID4, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) - verifySearchDone(1, false) - - // Test delete - mustDeleteMsg(tc2.startCtx, t, ctc, u2, conv, msgID4) - consumeNewMsgRemote(t, listener1, chat1.MessageType_DELETE) - consumeNewMsgRemote(t, listener2, chat1.MessageType_DELETE) - verifyIndex() - - res = runSearch(query, opts, false /* expectedReindex*/) - require.Equal(t, 0, len(res.Hits)) - verifySearchDone(0, false) - - // Test request payment - query = "payment :moneybag:" - msgBody = "payment :moneybag:" - searchMatch = chat1.ChatSearchMatch{ - StartIndex: 0, - EndIndex: len(msgBody), - Match: msgBody, - } - msgID7 := sendMessage(chat1.NewMessageBodyWithRequestpayment(chat1.MessageRequestPayment{ - RequestID: stellar1.KeybaseRequestID("dummy id"), - Note: msgBody, - }), u1) - verifyIndex() - - res = runSearch(query, opts, false /* expectedReindex*/) - require.Equal(t, 1, len(res.Hits)) - convHit = res.Hits[0] - require.Equal(t, convID, convHit.ConvID) - require.Equal(t, 1, len(convHit.Hits)) - verifyHit(convID, []chat1.MessageID{msgID2, msgID3}, msgID7, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) - verifySearchDone(1, false) - - // Test utf8 - msgBody = `约书亚和约翰屌爆了` - query = `约书亚和约翰屌爆了` - searchMatch = chat1.ChatSearchMatch{ - StartIndex: 0, - EndIndex: len(msgBody), - Match: msgBody, - } - msgID8 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ - Body: msgBody, - }), u1) - // NOTE other prefixes are cut off since they exceed the max length - verifyIndex() - res = runSearch(query, opts, false /* expectedReindex*/) - require.Equal(t, 1, len(res.Hits)) - convHit = res.Hits[0] - require.Equal(t, convID, convHit.ConvID) - require.Equal(t, 1, len(convHit.Hits)) - verifyHit(convID, []chat1.MessageID{msgID3, msgID7}, msgID8, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) - verifySearchDone(1, false) - - // DB nuke, ensure that we reindex after the search - _, err = g1.LocalChatDb.Nuke() - require.NoError(t, indexer1.OnDbNuke(libkb.NewMetaContext(context.TODO(), g1.ExternalG()))) - require.NoError(t, err) - opts.ReindexMode = chat1.ReIndexingMode_PRESEARCH_SYNC // force reindex so we're fully up to date. - res = runSearch(query, opts, true /* expectedReindex*/) - require.Equal(t, 1, len(res.Hits)) - convHit = res.Hits[0] - require.Equal(t, convID, convHit.ConvID) - require.Equal(t, 1, len(convHit.Hits)) - verifyHit(convID, []chat1.MessageID{msgID3, msgID7}, msgID8, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) - verifySearchDone(1, false) - verifyIndex() - - // since our index is full, we shouldn't fire off any calls to get messages - runSearch(query, opts, false /* expectedReindex*/) - verifySearchDone(1, false) - - // Verify POSTSEARCH_SYNC - ictx := globals.CtxAddIdentifyMode(ctx, keybase1.TLFIdentifyBehavior_CHAT_SKIP, nil) - _, err = g1.LocalChatDb.Nuke() - require.NoError(t, err) - require.NoError(t, indexer1.OnDbNuke(libkb.NewMetaContext(context.TODO(), g1.ExternalG()))) - err = indexer1.SelectiveSync(ictx) - require.NoError(t, err) - opts.ReindexMode = chat1.ReIndexingMode_POSTSEARCH_SYNC - res = runSearch(query, opts, true /* expectedReindex*/) - require.Equal(t, 1, len(res.Hits)) - convHit = res.Hits[0] - require.Equal(t, convID, convHit.ConvID) - require.Equal(t, 1, len(convHit.Hits)) - verifyHit(convID, []chat1.MessageID{msgID3, msgID7}, msgID8, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) - verifySearchDone(1, false) - verifyIndex() - - // since our index is full, we shouldn't fire off any calls to get messages - runSearch(query, opts, false /* expectedReindex*/) - verifySearchDone(1, false) - - // Test prefix searching - query = "pay" - searchMatch = chat1.ChatSearchMatch{ - StartIndex: 0, - EndIndex: 3, - Match: "pay", - } - res = runSearch(query, opts, false /* expectedReindex*/) - require.Equal(t, 1, len(res.Hits)) - convHit = res.Hits[0] - require.Equal(t, convID, convHit.ConvID) - require.Equal(t, 1, len(convHit.Hits)) - verifyHit(convID, []chat1.MessageID{msgID2, msgID3}, msgID7, []chat1.MessageID{msgID8}, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) - verifySearchDone(1, false) - - query = "payments" - res = runSearch(query, opts, false /* expectedReindex*/) - require.Equal(t, 0, len(res.Hits)) - verifySearchDone(0, false) - - // Test deletehistory - mustDeleteHistory(tc2.startCtx, t, ctc, u2, conv, msgID8+1) - consumeNewMsgRemote(t, listener1, chat1.MessageType_DELETEHISTORY) - consumeNewMsgRemote(t, listener2, chat1.MessageType_DELETEHISTORY) - verifyIndex() - - // test sentTo - msgBody = "hello @" + u1.Username - query = "hello" - searchMatch = chat1.ChatSearchMatch{ - StartIndex: 0, - EndIndex: 5, - Match: "hello", - } - msgID10 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ - Body: msgBody, - }), u2) - - // invalid username - opts.SentTo = u1.Username + "foo" - res = runSearch(query, opts, false /* expectedReindex*/) - require.Zero(t, len(res.Hits)) - verifySearchDone(0, false) - - opts.SentTo = u1.Username - res = runSearch(query, opts, false /* expectedReindex*/) - require.Equal(t, 1, len(res.Hits)) - convHit = res.Hits[0] - require.Equal(t, convID, convHit.ConvID) - require.Equal(t, 1, len(convHit.Hits)) - verifyHit(convID, []chat1.MessageID{}, msgID10, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) - verifySearchDone(1, false) - opts.SentTo = "" - - // Test canceling sync loop - syncLoopCh := make(chan struct{}) - indexer1.SetSyncLoopCh(syncLoopCh) - indexer1.StartSyncLoop() - waitForFail := func() bool { - for range 5 { - indexer1.CancelSync(ctx) - select { - case <-time.After(2 * time.Second): - case <-syncLoopCh: - return true - } - } - return false - } - require.True(t, waitForFail()) - indexer1.PokeSync(ctx) - require.True(t, waitForFail()) - - // test search delegation with a specific conv - // delegate on queries shorter than search.MinTokenLength - opts.ConvID = &convID - // delegate if a single conv is not fully indexed - query = "hello" - _, err = g1.LocalChatDb.Nuke() - require.NoError(t, err) - require.NoError(t, indexer1.OnDbNuke(libkb.NewMetaContext(context.TODO(), g1.ExternalG()))) - res = runSearch(query, opts, false /* expectedReindex*/) - require.Equal(t, 1, len(res.Hits)) - convHit = res.Hits[0] - require.Equal(t, convID, convHit.ConvID) - require.Equal(t, 1, len(convHit.Hits)) - verifyHit(convID, []chat1.MessageID{}, msgID10, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) - verifySearchDone(1, true) - - // delegate on regexp searches - query = "/hello/" - res = runSearch(query, opts, false /* expectedReindex*/) - require.Equal(t, 1, len(res.Hits)) - convHit = res.Hits[0] - require.Equal(t, convID, convHit.ConvID) - require.Equal(t, 1, len(convHit.Hits)) - verifyHit(convID, []chat1.MessageID{}, msgID10, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) - verifySearchDone(1, true) - - query = "hi" - searchMatch = chat1.ChatSearchMatch{ - StartIndex: 0, - EndIndex: 2, - Match: "hi", - } - msgID11 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ - Body: query, - }), u1) - - res = runSearch(query, opts, false /* expectedReindex*/) - require.Equal(t, 1, len(res.Hits)) - convHit = res.Hits[0] - require.Equal(t, convID, convHit.ConvID) - require.Equal(t, 1, len(convHit.Hits)) - verifyHit(convID, []chat1.MessageID{msgID10}, msgID11, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) - verifySearchDone(1, true) - - err = indexer1.Clear(ctx, uid1, convID) - require.NoError(t, err) - pi, err = indexer1.PercentIndexed(ctx, convID) - require.NoError(t, err) - require.Zero(t, pi) - - err = indexer2.Clear(ctx, uid2, convID) - require.NoError(t, err) - pi, err = indexer2.PercentIndexed(ctx, convID) - require.NoError(t, err) - require.Zero(t, pi) - }) -} - -// TestSearchIndexerNoDeadlockOnClearDuringAdd verifies that Indexer.Clear and -// Indexer.Suspend do not deadlock when store.Add is simultaneously blocked in -// ChatHelper.GetMessages while processing an EDIT message. -// -// Before the fix, store.Add held s.Lock() during the ChatHelper.GetMessages -// call, which blocked store.ClearMemory. Indexer.Clear also held idx.Lock() -// during the entire Clear operation, which blocked Indexer.Suspend. -func TestSearchIndexerNoDeadlockOnClearDuringAdd(t *testing.T) { - ctx := context.TODO() - ctc := makeChatTestContext(t, "SearchIndexerDeadlock", 1) - defer ctc.cleanup() - users := ctc.users() - u1 := users[0] - g1 := ctc.world.Tcs[u1.Username].Context() - uid1 := gregor1.UID(u1.User.GetUID().ToBytes()) - - calledCh := make(chan struct{}, 1) - releaseCh := make(chan struct{}) - g1.ExternalG().ChatHelper = &blockingGetMsgsChatHelper{ - MockChatHelper: kbtest.NewMockChatHelper(), - calledCh: calledCh, - releaseCh: releaseCh, - } - - indexer := search.NewIndexer(g1) - indexer.SetUID(uid1) - consumeCh := make(chan chat1.ConversationID, 1) - indexer.SetConsumeCh(consumeCh) - select { - case <-g1.Indexer.Stop(ctx): - case <-time.After(10 * time.Second): - require.Fail(t, "original indexer did not stop") - } - g1.Indexer = indexer - indexer.StartStorageLoop() - - convID := chat1.ConversationID([]byte{ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, - }) - editMsg := chat1.NewMessageUnboxedWithValid(chat1.MessageUnboxedValid{ - ClientHeader: chat1.MessageClientHeaderVerified{ - MessageType: chat1.MessageType_EDIT, - Conv: chat1.ConversationIDTriple{ - TopicType: chat1.TopicType_CHAT, - }, - }, - MessageBody: chat1.NewMessageBodyWithEdit(chat1.MessageEdit{ - MessageID: 1, - Body: "hello world", - }), - ServerHeader: chat1.MessageServerHeader{ - MessageID: 2, - }, - }) - - // Dispatch the EDIT message. storageLoop will call store.Add, which now - // pre-fetches superseded messages outside s.Lock() before acquiring it. - require.NoError(t, indexer.Add(ctx, convID, []chat1.MessageUnboxed{editMsg})) - - // Wait for store.Add to reach the ChatHelper.GetMessages call. - select { - case <-calledCh: - case <-time.After(10 * time.Second): - require.Fail(t, "GetMessages was never called") - } - - // With store.Add blocked in GetMessages (outside s.Lock()), Clear must be - // able to acquire s.Lock() and complete. Before the fix it would deadlock. - clearDone := make(chan struct{}) - go func() { - defer close(clearDone) - require.NoError(t, indexer.Clear(ctx, uid1, convID)) - }() - select { - case <-clearDone: - case <-time.After(5 * time.Second): - require.Fail(t, "Indexer.Clear deadlocked while store.Add was blocked in GetMessages") - } - - // Suspend must also complete; before the fix, Indexer.Clear held idx.Lock() - // during the entire store.Clear, blocking Suspend indefinitely. - suspendDone := make(chan struct{}) - go func() { - defer close(suspendDone) - indexer.Suspend(ctx) - }() - select { - case <-suspendDone: - case <-time.After(5 * time.Second): - require.Fail(t, "Indexer.Suspend deadlocked while store.Add was blocked in GetMessages") - } - indexer.Resume(ctx) - - // Release the blocked GetMessages so store.Add can finish and the indexer - // can shut down cleanly. - close(releaseCh) - select { - case <-consumeCh: - case <-time.After(10 * time.Second): - require.Fail(t, "store.Add never completed after GetMessages was released") - } - select { - case <-indexer.Stop(ctx): - case <-time.After(10 * time.Second): - require.Fail(t, "indexer did not stop") - } -} - -func TestSearchIndexerClearWithoutStoreIsSafe(t *testing.T) { - ctx := context.TODO() - ctc := makeChatTestContext(t, "SearchIndexerClearWithoutStore", 1) - defer ctc.cleanup() - g := ctc.world.Tcs[ctc.users()[0].Username].Context() - - indexer := search.NewIndexer(g) - require.NotPanics(t, func() { - require.NoError(t, indexer.Clear(ctx, gregor1.UID(nil), chat1.ConversationID(nil))) - }) -} From 964d430d8c494f4de92402705bcb0ef3c2e53d2a Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Mon, 13 Apr 2026 09:53:03 -0400 Subject: [PATCH 55/59] WIP --- go/chat/search_test.go | 1031 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1031 insertions(+) create mode 100644 go/chat/search_test.go diff --git a/go/chat/search_test.go b/go/chat/search_test.go new file mode 100644 index 000000000000..501099d7d620 --- /dev/null +++ b/go/chat/search_test.go @@ -0,0 +1,1031 @@ +package chat + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/keybase/client/go/chat/globals" + "github.com/keybase/client/go/chat/search" + "github.com/keybase/client/go/kbtest" + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/protocol/chat1" + "github.com/keybase/client/go/protocol/gregor1" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/keybase/client/go/protocol/stellar1" + "github.com/stretchr/testify/require" +) + +func TestChatSearchConvRegexp(t *testing.T) { + runWithMemberTypes(t, func(mt chat1.ConversationMembersType) { + // Only test against IMPTEAMNATIVE. There is a bug in ChatRemoteMock + // with using Pagination Next/Prev and we don't need to triple test + // here. + switch mt { + case chat1.ConversationMembersType_IMPTEAMNATIVE: + default: + return + } + + ctc := makeChatTestContext(t, "SearchRegexp", 2) + defer ctc.cleanup() + users := ctc.users() + u1 := users[0] + u2 := users[1] + + conv := mustCreateConversationForTest(t, ctc, u1, chat1.TopicType_CHAT, + mt, ctc.as(t, u2).user()) + convID := conv.Id + + tc1 := ctc.as(t, u1) + tc2 := ctc.as(t, u2) + + chatUI := kbtest.NewChatUI() + tc1.h.mockChatUI = chatUI + + listener1 := newServerChatListener() + tc1.h.G().NotifyRouter.AddListener(listener1) + listener2 := newServerChatListener() + tc2.h.G().NotifyRouter.AddListener(listener2) + + sendMessage := func(msgBody chat1.MessageBody, user *kbtest.FakeUser) chat1.MessageID { + msgID := mustPostLocalForTest(t, ctc, user, conv, msgBody) + typ, err := msgBody.MessageType() + require.NoError(t, err) + consumeNewMsgRemote(t, listener1, typ) + consumeNewMsgRemote(t, listener2, typ) + return msgID + } + + verifyHit := func(beforeMsgIDs []chat1.MessageID, hitMessageID chat1.MessageID, afterMsgIDs []chat1.MessageID, + matches []chat1.ChatSearchMatch, searchHit chat1.ChatSearchHit, + ) { + _verifyHit := func(searchHit chat1.ChatSearchHit) { + if beforeMsgIDs == nil { + require.Nil(t, searchHit.BeforeMessages) + } else { + require.Equal(t, len(beforeMsgIDs), len(searchHit.BeforeMessages)) + for i, msgID := range beforeMsgIDs { + msg := searchHit.BeforeMessages[i] + t.Logf("msg: %v", msg.Valid()) + require.True(t, msg.IsValid()) + require.Equal(t, msgID, msg.GetMessageID()) + } + } + require.EqualValues(t, hitMessageID, searchHit.HitMessage.Valid().MessageID) + require.Equal(t, matches, searchHit.Matches) + + if afterMsgIDs == nil { + require.Nil(t, searchHit.AfterMessages) + } else { + require.Equal(t, len(afterMsgIDs), len(searchHit.AfterMessages)) + for i, msgID := range afterMsgIDs { + msg := searchHit.AfterMessages[i] + require.True(t, msg.IsValid()) + require.Equal(t, msgID, msg.GetMessageID()) + } + } + } + _verifyHit(searchHit) + select { + case searchHitRes := <-chatUI.SearchHitCb: + _verifyHit(searchHitRes.SearchHit) + case <-time.After(20 * time.Second): + require.Fail(t, "no search result received") + } + } + verifySearchDone := func(numHits int) { + select { + case searchDone := <-chatUI.SearchDoneCb: + require.Equal(t, numHits, searchDone.NumHits) + case <-time.After(20 * time.Second): + require.Fail(t, "no search result received") + } + } + + runSearch := func(query string, isRegex bool, opts chat1.SearchOpts) chat1.SearchRegexpRes { + opts.IsRegex = isRegex + res, err := tc1.chatLocalHandler().SearchRegexp(tc1.startCtx, chat1.SearchRegexpArg{ + ConvID: convID, + Query: query, + Opts: opts, + }) + require.NoError(t, err) + t.Logf("query: %v, searchRes: %+v", query, res) + return res + } + + isRegex := false + opts := chat1.SearchOpts{ + MaxHits: 5, + BeforeContext: 2, + AfterContext: 2, + MaxMessages: 1000, + } + + // Test basic equality match + query := "hi" + msgBody := "hi @here" + msgID1 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ + Body: msgBody, + }), u1) + searchMatch := chat1.ChatSearchMatch{ + StartIndex: 0, + EndIndex: 2, + Match: query, + } + res := runSearch(query, isRegex, opts) + require.Equal(t, 1, len(res.Hits)) + verifyHit(nil, msgID1, nil, []chat1.ChatSearchMatch{searchMatch}, res.Hits[0]) + verifySearchDone(1) + + // Test basic no results + query = "hey" + res = runSearch(query, isRegex, opts) + require.Equal(t, 0, len(res.Hits)) + verifySearchDone(0) + + // Test maxHits + opts.MaxHits = 1 + query = "hi" + msgBody = "hi there" + msgID2 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ + Body: msgBody, + }), u1) + res = runSearch(query, isRegex, opts) + require.Equal(t, 1, len(res.Hits)) + verifyHit([]chat1.MessageID{msgID1}, msgID2, nil, []chat1.ChatSearchMatch{searchMatch}, res.Hits[0]) + verifySearchDone(1) + + opts.MaxHits = 5 + res = runSearch(query, isRegex, opts) + require.Equal(t, 2, len(res.Hits)) + verifyHit([]chat1.MessageID{msgID1}, msgID2, nil, []chat1.ChatSearchMatch{searchMatch}, res.Hits[0]) + verifyHit(nil, msgID1, []chat1.MessageID{msgID2}, []chat1.ChatSearchMatch{searchMatch}, res.Hits[1]) + verifySearchDone(2) + + msgID3 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ + Body: msgBody, + }), u1) + res = runSearch(query, isRegex, opts) + require.Equal(t, 3, len(res.Hits)) + verifyHit([]chat1.MessageID{msgID1, msgID2}, msgID3, nil, []chat1.ChatSearchMatch{searchMatch}, res.Hits[0]) + verifyHit([]chat1.MessageID{msgID1}, msgID2, []chat1.MessageID{msgID3}, []chat1.ChatSearchMatch{searchMatch}, res.Hits[1]) + verifyHit(nil, msgID1, []chat1.MessageID{msgID2, msgID3}, []chat1.ChatSearchMatch{searchMatch}, res.Hits[2]) + verifySearchDone(3) + + // test sentBy + // invalid username + opts.SentBy = u1.Username + "foo" + res = runSearch(query, isRegex, opts) + require.Zero(t, len(res.Hits)) + verifySearchDone(0) + + // send from user2 and make sure we can filter, @mention user1 to test + // SentTo later. + opts.SentBy = u2.Username + msgBody = fmt.Sprintf("hi @%s", u1.Username) + msgID4 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ + Body: msgBody, + }), u2) + res = runSearch(query, isRegex, opts) + require.Equal(t, 1, len(res.Hits)) + verifyHit([]chat1.MessageID{msgID2, msgID3}, msgID4, nil, []chat1.ChatSearchMatch{searchMatch}, res.Hits[0]) + verifySearchDone(1) + opts.SentBy = "" + + // test sentTo + // invalid username + opts.SentTo = u1.Username + "foo" + res = runSearch(query, isRegex, opts) + require.Zero(t, len(res.Hits)) + verifySearchDone(0) + + opts.SentTo = u1.Username + res = runSearch(query, isRegex, opts) + require.Equal(t, 2, len(res.Hits)) + verifyHit([]chat1.MessageID{msgID2, msgID3}, msgID4, nil, []chat1.ChatSearchMatch{searchMatch}, res.Hits[0]) + verifyHit(nil, msgID1, []chat1.MessageID{msgID2, msgID3}, []chat1.ChatSearchMatch{searchMatch}, res.Hits[1]) + verifySearchDone(2) + opts.SentTo = "" + + // test sentBefore/sentAfter + msgRes, err := tc1.chatLocalHandler().GetMessagesLocal(tc1.startCtx, chat1.GetMessagesLocalArg{ + ConversationID: convID, + MessageIDs: []chat1.MessageID{msgID1, msgID4}, + }) + require.NoError(t, err) + require.Equal(t, 2, len(msgRes.Messages)) + msg1 := msgRes.Messages[0] + msg4 := msgRes.Messages[1] + + // nothing sent after msg4 + opts.SentAfter = msg4.Ctime() + 500 + res = runSearch(query, isRegex, opts) + require.Zero(t, len(res.Hits)) + + opts.SentAfter = msg1.Ctime() + res = runSearch(query, isRegex, opts) + require.Equal(t, 4, len(res.Hits)) + + // nothing sent before msg1 + opts.SentAfter = 0 + opts.SentBefore = msg1.Ctime() - 500 + res = runSearch(query, isRegex, opts) + require.Zero(t, len(res.Hits)) + + opts.SentBefore = msg4.Ctime() + res = runSearch(query, isRegex, opts) + require.Equal(t, 4, len(res.Hits)) + + opts.SentBefore = 0 + + // drain the cbs, 8 hits and 4 dones + timeout := 20 * time.Second + for i := 0; i < 8+4; i++ { + select { + case <-chatUI.SearchHitCb: + case <-chatUI.SearchDoneCb: + case <-time.After(timeout): + require.Fail(t, "no search result received") + } + } + + query = "edited" + msgBody = "edited" + searchMatch = chat1.ChatSearchMatch{ + StartIndex: 0, + EndIndex: len(msgBody), + Match: msgBody, + } + mustEditMsg(tc2.startCtx, t, ctc, u2, conv, msgID4) + consumeNewMsgRemote(t, listener1, chat1.MessageType_EDIT) + consumeNewMsgRemote(t, listener2, chat1.MessageType_EDIT) + + res = runSearch(query, isRegex, opts) + require.Equal(t, 1, len(res.Hits)) + verifyHit([]chat1.MessageID{msgID2, msgID3}, msgID4, nil, []chat1.ChatSearchMatch{searchMatch}, res.Hits[0]) + verifySearchDone(1) + + // Test delete + mustDeleteMsg(tc2.startCtx, t, ctc, u2, conv, msgID4) + consumeNewMsgRemote(t, listener1, chat1.MessageType_DELETE) + consumeNewMsgRemote(t, listener2, chat1.MessageType_DELETE) + res = runSearch(query, isRegex, opts) + require.Equal(t, 0, len(res.Hits)) + verifySearchDone(0) + + // Test request payment + query = "payment :moneybag:" + msgBody = "payment :moneybag:" + searchMatch = chat1.ChatSearchMatch{ + StartIndex: 0, + EndIndex: len(msgBody), + Match: msgBody, + } + msgID7 := sendMessage(chat1.NewMessageBodyWithRequestpayment(chat1.MessageRequestPayment{ + RequestID: stellar1.KeybaseRequestID("dummy id"), + Note: msgBody, + }), u1) + res = runSearch(query, isRegex, opts) + require.Equal(t, 1, len(res.Hits)) + verifyHit([]chat1.MessageID{msgID2, msgID3}, msgID7, nil, []chat1.ChatSearchMatch{searchMatch}, res.Hits[0]) + verifySearchDone(1) + + // Test regex functionality + isRegex = true + + // Test utf8 + msgBody = `约书亚和约翰屌爆了` + query = `约.*` + searchMatch = chat1.ChatSearchMatch{ + StartIndex: 0, + EndIndex: len(msgBody), + Match: msgBody, + } + msgID8 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ + Body: msgBody, + }), u1) + res = runSearch(query, isRegex, opts) + require.Equal(t, 1, len(res.Hits)) + verifyHit([]chat1.MessageID{msgID3, msgID7}, msgID8, nil, []chat1.ChatSearchMatch{searchMatch}, res.Hits[0]) + verifySearchDone(1) + + msgBody = "hihihi" + query = "hi" + matches := []chat1.ChatSearchMatch{} + startIndex := 0 + for i := 0; i < 3; i++ { + matches = append(matches, chat1.ChatSearchMatch{ + StartIndex: startIndex, + EndIndex: startIndex + 2, + Match: query, + }) + startIndex += 2 + } + + opts.MaxHits = 1 + msgID9 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ + Body: msgBody, + }), u1) + res = runSearch(query, isRegex, opts) + require.Equal(t, 1, len(res.Hits)) + verifyHit([]chat1.MessageID{msgID7, msgID8}, msgID9, nil, matches, res.Hits[0]) + verifySearchDone(1) + + query = "h.*" + lowercase := "abcdefghijklmnopqrstuvwxyz" + for _, char := range lowercase { + sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ + Body: "h." + string(char), + }), u1) + } + opts.MaxHits = len(lowercase) + res = runSearch(query, isRegex, opts) + require.Equal(t, opts.MaxHits, len(res.Hits)) + verifySearchDone(opts.MaxHits) + + // Test maxMessages + opts.MaxMessages = 2 + res = runSearch(query, isRegex, opts) + require.Equal(t, opts.MaxMessages, len(res.Hits)) + verifySearchDone(opts.MaxMessages) + + query = `[A-Z]*` + res = runSearch(query, isRegex, opts) + require.Equal(t, 0, len(res.Hits)) + verifySearchDone(0) + + // Test invalid regex + _, err = tc1.chatLocalHandler().SearchRegexp(tc1.startCtx, chat1.SearchRegexpArg{ + ConvID: convID, + Query: "(", + Opts: chat1.SearchOpts{ + IsRegex: true, + }, + }) + require.Error(t, err) + }) +} + +func TestChatSearchRemoveMsg(t *testing.T) { + useRemoteMock = false + defer func() { useRemoteMock = true }() + ctc := makeChatTestContext(t, "TestChatSearchRemoveMsg", 2) + defer ctc.cleanup() + + users := ctc.users() + ctx := ctc.as(t, users[0]).startCtx + tc := ctc.world.Tcs[users[0].Username] + chatUI := kbtest.NewChatUI() + uid := gregor1.UID(users[0].GetUID().ToBytes()) + ctc.as(t, users[0]).h.mockChatUI = chatUI + conv := mustCreateConversationForTest(t, ctc, users[0], chat1.TopicType_CHAT, + chat1.ConversationMembersType_IMPTEAMNATIVE) + conv1 := mustCreateConversationForTest(t, ctc, users[0], chat1.TopicType_CHAT, + chat1.ConversationMembersType_IMPTEAMNATIVE, users[1]) + + msgID0 := mustPostLocalForTest(t, ctc, users[0], conv, chat1.NewMessageBodyWithText(chat1.MessageText{ + Body: "MIKEMAXIM", + })) + msgID1 := mustPostLocalForTest(t, ctc, users[0], conv, chat1.NewMessageBodyWithText(chat1.MessageText{ + Body: "MIKEMAXIM", + })) + msgID2 := mustPostLocalForTest(t, ctc, users[0], conv1, chat1.NewMessageBodyWithText(chat1.MessageText{ + Body: "MIKEMAXIM", + })) + mustPostLocalForTest(t, ctc, users[0], conv, chat1.NewMessageBodyWithText(chat1.MessageText{ + Body: "CRICKETS", + })) + res, err := ctc.as(t, users[0]).chatLocalHandler().SearchInbox(ctx, chat1.SearchInboxArg{ + Query: "MIKEM", + Opts: chat1.SearchOpts{ + MaxConvsHit: 5, + MaxHits: 5, + }, + }) + require.NoError(t, err) + require.NotNil(t, res.Res) + require.Equal(t, 2, len(res.Res.Hits)) + if res.Res.Hits[0].ConvID.Eq(conv.Id) { + require.Equal(t, 2, len(res.Res.Hits[0].Hits)) + require.Equal(t, 1, len(res.Res.Hits[1].Hits)) + } else { + require.Equal(t, 1, len(res.Res.Hits[0].Hits)) + require.Equal(t, 2, len(res.Res.Hits[1].Hits)) + } + + mustDeleteMsg(ctx, t, ctc, users[0], conv1, msgID2) + + res, err = ctc.as(t, users[0]).chatLocalHandler().SearchInbox(ctx, chat1.SearchInboxArg{ + Query: "MIKEM", + Opts: chat1.SearchOpts{ + MaxConvsHit: 5, + MaxHits: 5, + }, + }) + require.NoError(t, err) + require.NotNil(t, res.Res) + require.Equal(t, 1, len(res.Res.Hits)) + require.Equal(t, 2, len(res.Res.Hits[0].Hits)) + + mustDeleteMsg(ctx, t, ctc, users[0], conv, msgID0) + mustDeleteMsg(ctx, t, ctc, users[0], conv, msgID1) + + hres, err := tc.ChatG.Indexer.(*search.Indexer).GetStoreHits(ctx, uid, conv.Id, "MIKEM") + require.NoError(t, err) + require.Zero(t, len(hres)) +} + +func TestChatSearchInbox(t *testing.T) { + runWithMemberTypes(t, func(mt chat1.ConversationMembersType) { + // Only test against IMPTEAMNATIVE. There is a bug in ChatRemoteMock + // with using Pagination Next/Prev and we don't need to triple test + // here. + switch mt { + case chat1.ConversationMembersType_IMPTEAMNATIVE: + default: + return + } + + ctx := context.TODO() + ctc := makeChatTestContext(t, "SearchInbox", 2) + defer ctc.cleanup() + users := ctc.users() + u1 := users[0] + u2 := users[1] + + tc1 := ctc.as(t, u1) + tc2 := ctc.as(t, u2) + g1 := ctc.world.Tcs[u1.Username].Context() + g2 := ctc.world.Tcs[u2.Username].Context() + uid1 := u1.User.GetUID().ToBytes() + uid2 := u2.User.GetUID().ToBytes() + + chatUI := kbtest.NewChatUI() + tc1.h.mockChatUI = chatUI + + listener1 := newServerChatListener() + tc1.h.G().NotifyRouter.AddListener(listener1) + listener2 := newServerChatListener() + tc2.h.G().NotifyRouter.AddListener(listener2) + + // Create our own Indexer instances so we have access to non-interface methods + indexer1 := search.NewIndexer(g1) + consumeCh1 := make(chan chat1.ConversationID, 100) + reindexCh1 := make(chan chat1.ConversationID, 100) + indexer1.SetConsumeCh(consumeCh1) + indexer1.SetReindexCh(reindexCh1) + indexer1.SetStartSyncDelay(0) + indexer1.SetUID(uid1) + indexer1.SetFlushDelay(100 * time.Millisecond) + indexer1.StartFlushLoop() + indexer1.StartStorageLoop() + // Stop the original + select { + case <-g1.Indexer.Stop(ctx): + case <-time.After(20 * time.Second): + require.Fail(t, "g1 Indexer did not stop") + } + g1.Indexer = indexer1 + + indexer2 := search.NewIndexer(g2) + consumeCh2 := make(chan chat1.ConversationID, 100) + reindexCh2 := make(chan chat1.ConversationID, 100) + indexer2.SetConsumeCh(consumeCh2) + indexer2.SetReindexCh(reindexCh2) + indexer2.SetStartSyncDelay(0) + indexer2.SetUID(uid2) + indexer2.SetFlushDelay(10 * time.Millisecond) + indexer2.StartFlushLoop() + indexer2.StartStorageLoop() + // Stop the original + select { + case <-g2.Indexer.Stop(ctx): + case <-time.After(20 * time.Second): + require.Fail(t, "g2 Indexer did not stop") + } + g2.Indexer = indexer2 + + conv := mustCreateConversationForTest(t, ctc, u1, chat1.TopicType_CHAT, + mt, ctc.as(t, u2).user()) + convID := conv.Id + + // verify zero messages case + fi, err := indexer1.FullyIndexed(ctx, convID) + require.NoError(t, err) + require.True(t, fi) + pi, err := indexer1.PercentIndexed(ctx, convID) + require.NoError(t, err) + require.Equal(t, 100, pi) + + fi, err = indexer2.FullyIndexed(ctx, convID) + require.NoError(t, err) + require.True(t, fi) + pi, err = indexer2.PercentIndexed(ctx, convID) + require.NoError(t, err) + require.Equal(t, 100, pi) + + sendMessage := func(msgBody chat1.MessageBody, user *kbtest.FakeUser) chat1.MessageID { + msgID := mustPostLocalForTest(t, ctc, user, conv, msgBody) + typ, err := msgBody.MessageType() + require.NoError(t, err) + consumeNewMsgRemote(t, listener1, typ) + consumeNewMsgRemote(t, listener2, typ) + return msgID + } + + verifyHit := func(_ chat1.ConversationID, beforeMsgIDs []chat1.MessageID, hitMessageID chat1.MessageID, + afterMsgIDs []chat1.MessageID, matches []chat1.ChatSearchMatch, searchHit chat1.ChatSearchHit, + ) { + if beforeMsgIDs == nil { + require.Nil(t, searchHit.BeforeMessages) + } else { + require.Equal(t, len(beforeMsgIDs), len(searchHit.BeforeMessages)) + for i, msgID := range beforeMsgIDs { + msg := searchHit.BeforeMessages[i] + require.True(t, msg.IsValid()) + require.Equal(t, msgID, msg.GetMessageID()) + } + } + require.EqualValues(t, hitMessageID, searchHit.HitMessage.Valid().MessageID) + require.Equal(t, matches, searchHit.Matches) + + if afterMsgIDs == nil { + require.Nil(t, searchHit.AfterMessages) + } else { + require.Equal(t, len(afterMsgIDs), len(searchHit.AfterMessages)) + for i, msgID := range afterMsgIDs { + msg := searchHit.AfterMessages[i] + require.True(t, msg.IsValid()) + require.Equal(t, msgID, msg.GetMessageID()) + } + } + } + verifySearchDone := func(numHits int, delegated bool) { + select { + case <-chatUI.InboxSearchConvHitsCb: + case <-time.After(20 * time.Second): + require.Fail(t, "no name hits") + } + select { + case searchDone := <-chatUI.InboxSearchDoneCb: + require.Equal(t, numHits, searchDone.Res.NumHits) + numConvs := 1 + if numHits == 0 { + numConvs = 0 + } + require.Equal(t, numConvs, searchDone.Res.NumConvs) + if delegated { + require.True(t, searchDone.Res.Delegated) + } else { + require.Equal(t, 100, searchDone.Res.PercentIndexed) + } + case <-time.After(20 * time.Second): + require.Fail(t, "no search result received") + } + } + + verifyIndexConsumption := func(ch chan chat1.ConversationID) { + select { + case id := <-ch: + require.Equal(t, convID, id) + case <-time.After(5 * time.Second): + require.Fail(t, "indexer didn't consume") + } + } + + verifyIndexNoConsumption := func(ch chan chat1.ConversationID) { + select { + case <-ch: + require.Fail(t, "indexer reindexed") + default: + } + } + + verifyIndex := func() { + t.Logf("verify user 1 index") + verifyIndexConsumption(consumeCh1) + t.Logf("verify user 2 index") + verifyIndexConsumption(consumeCh2) + } + + runSearch := func(query string, opts chat1.SearchOpts, expectedReindex bool) *chat1.ChatSearchInboxResults { + res, err := tc1.chatLocalHandler().SearchInbox(tc1.startCtx, chat1.SearchInboxArg{ + Query: query, + Opts: opts, + }) + require.NoError(t, err) + t.Logf("query: %v, searchRes: %+v", query, res) + if expectedReindex { + verifyIndexConsumption(reindexCh1) + } else { + verifyIndexNoConsumption(reindexCh1) + } + return res.Res + } + + opts := chat1.SearchOpts{ + MaxHits: 5, + BeforeContext: 2, + AfterContext: 2, + MaxMessages: 1000, + MaxNameConvs: 1, + } + + // Test basic equality match + msgBody := "hello, byE" + msgID1 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ + Body: msgBody, + }), u1) + + queries := []string{"hello", "hello, ByE"} + matches := []chat1.ChatSearchMatch{ + { + StartIndex: 0, + EndIndex: 5, + Match: "hello", + }, + { + StartIndex: 0, + EndIndex: 10, + Match: "hello, byE", + }, + } + for i, query := range queries { + res := runSearch(query, opts, false /* expectedReindex */) + require.Equal(t, 1, len(res.Hits)) + convHit := res.Hits[0] + require.Equal(t, convID, convHit.ConvID) + require.Equal(t, 1, len(convHit.Hits)) + verifyHit(convID, nil, msgID1, nil, []chat1.ChatSearchMatch{matches[i]}, convHit.Hits[0]) + verifySearchDone(1, false) + } + + // We get a hit but without any highlighting highlighting fails + query := "hell bye" + res := runSearch(query, opts, false /* expectedReindex */) + require.Equal(t, 1, len(res.Hits)) + convHit := res.Hits[0] + verifyHit(convID, nil, msgID1, nil, nil, convHit.Hits[0]) + verifySearchDone(1, false) + + // Test basic no results + query = "hey" + res = runSearch(query, opts, false /* expectedReindex*/) + require.Equal(t, 0, len(res.Hits)) + verifySearchDone(0, false) + + // Test maxHits + opts.MaxHits = 1 + query = "hello" + searchMatch := chat1.ChatSearchMatch{ + StartIndex: 0, + EndIndex: len(query), + Match: query, + } + msgID2 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ + Body: msgBody, + }), u1) + verifyIndex() + + res = runSearch(query, opts, false /* expectedReindex*/) + require.Equal(t, 1, len(res.Hits)) + convHit = res.Hits[0] + require.Equal(t, convID, convHit.ConvID) + require.Equal(t, 1, len(convHit.Hits)) + verifyHit(convID, []chat1.MessageID{msgID1}, msgID2, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) + verifySearchDone(1, false) + + opts.MaxHits = 5 + res = runSearch(query, opts, false /* expectedReindex*/) + require.Equal(t, 1, len(res.Hits)) + convHit = res.Hits[0] + require.Equal(t, convID, convHit.ConvID) + require.Equal(t, 2, len(convHit.Hits)) + verifyHit(convID, []chat1.MessageID{msgID1}, msgID2, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) + verifyHit(convID, nil, msgID1, []chat1.MessageID{msgID2}, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[1]) + verifySearchDone(2, false) + + msgID3 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ + Body: msgBody, + }), u1) + + verifyIndex() + + res = runSearch(query, opts, false /* expectedReindex*/) + require.Equal(t, 1, len(res.Hits)) + convHit = res.Hits[0] + require.Equal(t, convID, convHit.ConvID) + require.Equal(t, 3, len(convHit.Hits)) + verifyHit(convID, []chat1.MessageID{msgID1, msgID2}, msgID3, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) + verifyHit(convID, []chat1.MessageID{msgID1}, msgID2, []chat1.MessageID{msgID3}, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[1]) + verifyHit(convID, nil, msgID1, []chat1.MessageID{msgID2, msgID3}, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[2]) + verifySearchDone(3, false) + + // test sentBy + // invalid username + opts.SentBy = u1.Username + "foo" + res = runSearch(query, opts, false /* expectedReindex*/) + require.Zero(t, len(res.Hits)) + verifySearchDone(0, false) + + // send from user2 and make sure we can filter + opts.SentBy = u2.Username + msgBody = "hello" + query = "hello" + msgID4 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ + Body: msgBody, + }), u2) + verifyIndex() + + res = runSearch(query, opts, false /* expectedReindex*/) + require.Equal(t, 1, len(res.Hits)) + convHit = res.Hits[0] + require.Equal(t, convID, convHit.ConvID) + require.Equal(t, 1, len(convHit.Hits)) + verifyHit(convID, []chat1.MessageID{msgID2, msgID3}, msgID4, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) + verifySearchDone(1, false) + opts.SentBy = "" + + // test sentBefore/sentAfter + msgRes, err := tc1.chatLocalHandler().GetMessagesLocal(tc1.startCtx, chat1.GetMessagesLocalArg{ + ConversationID: convID, + MessageIDs: []chat1.MessageID{msgID1, msgID4}, + }) + require.NoError(t, err) + require.Equal(t, 2, len(msgRes.Messages)) + msg1 := msgRes.Messages[0] + msg4 := msgRes.Messages[1] + + // nothing sent after msg4 + opts.SentAfter = msg4.Ctime() + 500 + res = runSearch(query, opts, false /* expectedReindex*/) + require.Zero(t, len(res.Hits)) + verifySearchDone(0, false) + + opts.SentAfter = msg1.Ctime() + res = runSearch(query, opts, false /* expectedReindex*/) + require.Equal(t, 1, len(res.Hits)) + require.Equal(t, 4, len(res.Hits[0].Hits)) + verifySearchDone(4, false) + + // nothing sent before msg1 + opts.SentAfter = 0 + opts.SentBefore = msg1.Ctime() - 500 + res = runSearch(query, opts, false /* expectedReindex*/) + require.Zero(t, len(res.Hits)) + verifySearchDone(0, false) + + opts.SentBefore = msg4.Ctime() + res = runSearch(query, opts, false /* expectedReindex*/) + require.Equal(t, 1, len(res.Hits)) + require.Equal(t, 4, len(res.Hits[0].Hits)) + verifySearchDone(4, false) + opts.SentBefore = 0 + + // Test edit + query = "edited" + msgBody = "edited" + searchMatch = chat1.ChatSearchMatch{ + StartIndex: 0, + EndIndex: len(msgBody), + Match: msgBody, + } + mustEditMsg(tc2.startCtx, t, ctc, u2, conv, msgID4) + consumeNewMsgRemote(t, listener1, chat1.MessageType_EDIT) + consumeNewMsgRemote(t, listener2, chat1.MessageType_EDIT) + verifyIndex() + + res = runSearch(query, opts, false /* expectedReindex*/) + t.Logf("%+v", res) + require.Equal(t, 1, len(res.Hits)) + convHit = res.Hits[0] + require.Equal(t, convID, convHit.ConvID) + require.Equal(t, 1, len(convHit.Hits)) + verifyHit(convID, []chat1.MessageID{msgID2, msgID3}, msgID4, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) + verifySearchDone(1, false) + + // Test delete + mustDeleteMsg(tc2.startCtx, t, ctc, u2, conv, msgID4) + consumeNewMsgRemote(t, listener1, chat1.MessageType_DELETE) + consumeNewMsgRemote(t, listener2, chat1.MessageType_DELETE) + verifyIndex() + + res = runSearch(query, opts, false /* expectedReindex*/) + require.Equal(t, 0, len(res.Hits)) + verifySearchDone(0, false) + + // Test request payment + query = "payment :moneybag:" + msgBody = "payment :moneybag:" + searchMatch = chat1.ChatSearchMatch{ + StartIndex: 0, + EndIndex: len(msgBody), + Match: msgBody, + } + msgID7 := sendMessage(chat1.NewMessageBodyWithRequestpayment(chat1.MessageRequestPayment{ + RequestID: stellar1.KeybaseRequestID("dummy id"), + Note: msgBody, + }), u1) + verifyIndex() + + res = runSearch(query, opts, false /* expectedReindex*/) + require.Equal(t, 1, len(res.Hits)) + convHit = res.Hits[0] + require.Equal(t, convID, convHit.ConvID) + require.Equal(t, 1, len(convHit.Hits)) + verifyHit(convID, []chat1.MessageID{msgID2, msgID3}, msgID7, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) + verifySearchDone(1, false) + + // Test utf8 + msgBody = `约书亚和约翰屌爆了` + query = `约书亚和约翰屌爆了` + searchMatch = chat1.ChatSearchMatch{ + StartIndex: 0, + EndIndex: len(msgBody), + Match: msgBody, + } + msgID8 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ + Body: msgBody, + }), u1) + // NOTE other prefixes are cut off since they exceed the max length + verifyIndex() + res = runSearch(query, opts, false /* expectedReindex*/) + require.Equal(t, 1, len(res.Hits)) + convHit = res.Hits[0] + require.Equal(t, convID, convHit.ConvID) + require.Equal(t, 1, len(convHit.Hits)) + verifyHit(convID, []chat1.MessageID{msgID3, msgID7}, msgID8, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) + verifySearchDone(1, false) + + // DB nuke, ensure that we reindex after the search + _, err = g1.LocalChatDb.Nuke() + require.NoError(t, indexer1.OnDbNuke(libkb.NewMetaContext(context.TODO(), g1.ExternalG()))) + require.NoError(t, err) + opts.ReindexMode = chat1.ReIndexingMode_PRESEARCH_SYNC // force reindex so we're fully up to date. + res = runSearch(query, opts, true /* expectedReindex*/) + require.Equal(t, 1, len(res.Hits)) + convHit = res.Hits[0] + require.Equal(t, convID, convHit.ConvID) + require.Equal(t, 1, len(convHit.Hits)) + verifyHit(convID, []chat1.MessageID{msgID3, msgID7}, msgID8, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) + verifySearchDone(1, false) + verifyIndex() + + // since our index is full, we shouldn't fire off any calls to get messages + runSearch(query, opts, false /* expectedReindex*/) + verifySearchDone(1, false) + + // Verify POSTSEARCH_SYNC + ictx := globals.CtxAddIdentifyMode(ctx, keybase1.TLFIdentifyBehavior_CHAT_SKIP, nil) + _, err = g1.LocalChatDb.Nuke() + require.NoError(t, err) + require.NoError(t, indexer1.OnDbNuke(libkb.NewMetaContext(context.TODO(), g1.ExternalG()))) + err = indexer1.SelectiveSync(ictx) + require.NoError(t, err) + opts.ReindexMode = chat1.ReIndexingMode_POSTSEARCH_SYNC + res = runSearch(query, opts, true /* expectedReindex*/) + require.Equal(t, 1, len(res.Hits)) + convHit = res.Hits[0] + require.Equal(t, convID, convHit.ConvID) + require.Equal(t, 1, len(convHit.Hits)) + verifyHit(convID, []chat1.MessageID{msgID3, msgID7}, msgID8, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) + verifySearchDone(1, false) + verifyIndex() + + // since our index is full, we shouldn't fire off any calls to get messages + runSearch(query, opts, false /* expectedReindex*/) + verifySearchDone(1, false) + + // Test prefix searching + query = "pay" + searchMatch = chat1.ChatSearchMatch{ + StartIndex: 0, + EndIndex: 3, + Match: "pay", + } + res = runSearch(query, opts, false /* expectedReindex*/) + require.Equal(t, 1, len(res.Hits)) + convHit = res.Hits[0] + require.Equal(t, convID, convHit.ConvID) + require.Equal(t, 1, len(convHit.Hits)) + verifyHit(convID, []chat1.MessageID{msgID2, msgID3}, msgID7, []chat1.MessageID{msgID8}, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) + verifySearchDone(1, false) + + query = "payments" + res = runSearch(query, opts, false /* expectedReindex*/) + require.Equal(t, 0, len(res.Hits)) + verifySearchDone(0, false) + + // Test deletehistory + mustDeleteHistory(tc2.startCtx, t, ctc, u2, conv, msgID8+1) + consumeNewMsgRemote(t, listener1, chat1.MessageType_DELETEHISTORY) + consumeNewMsgRemote(t, listener2, chat1.MessageType_DELETEHISTORY) + verifyIndex() + + // test sentTo + msgBody = "hello @" + u1.Username + query = "hello" + searchMatch = chat1.ChatSearchMatch{ + StartIndex: 0, + EndIndex: 5, + Match: "hello", + } + msgID10 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ + Body: msgBody, + }), u2) + + // invalid username + opts.SentTo = u1.Username + "foo" + res = runSearch(query, opts, false /* expectedReindex*/) + require.Zero(t, len(res.Hits)) + verifySearchDone(0, false) + + opts.SentTo = u1.Username + res = runSearch(query, opts, false /* expectedReindex*/) + require.Equal(t, 1, len(res.Hits)) + convHit = res.Hits[0] + require.Equal(t, convID, convHit.ConvID) + require.Equal(t, 1, len(convHit.Hits)) + verifyHit(convID, []chat1.MessageID{}, msgID10, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) + verifySearchDone(1, false) + opts.SentTo = "" + + // Test canceling sync loop + syncLoopCh := make(chan struct{}) + indexer1.SetSyncLoopCh(syncLoopCh) + indexer1.StartSyncLoop() + waitForFail := func() bool { + for i := 0; i < 5; i++ { + indexer1.CancelSync(ctx) + select { + case <-time.After(2 * time.Second): + case <-syncLoopCh: + return true + } + } + return false + } + require.True(t, waitForFail()) + indexer1.PokeSync(ctx) + require.True(t, waitForFail()) + + // test search delegation with a specific conv + // delegate on queries shorter than search.MinTokenLength + opts.ConvID = &convID + // delegate if a single conv is not fully indexed + query = "hello" + _, err = g1.LocalChatDb.Nuke() + require.NoError(t, err) + require.NoError(t, indexer1.OnDbNuke(libkb.NewMetaContext(context.TODO(), g1.ExternalG()))) + res = runSearch(query, opts, false /* expectedReindex*/) + require.Equal(t, 1, len(res.Hits)) + convHit = res.Hits[0] + require.Equal(t, convID, convHit.ConvID) + require.Equal(t, 1, len(convHit.Hits)) + verifyHit(convID, []chat1.MessageID{}, msgID10, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) + verifySearchDone(1, true) + + // delegate on regexp searches + query = "/hello/" + res = runSearch(query, opts, false /* expectedReindex*/) + require.Equal(t, 1, len(res.Hits)) + convHit = res.Hits[0] + require.Equal(t, convID, convHit.ConvID) + require.Equal(t, 1, len(convHit.Hits)) + verifyHit(convID, []chat1.MessageID{}, msgID10, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) + verifySearchDone(1, true) + + query = "hi" + searchMatch = chat1.ChatSearchMatch{ + StartIndex: 0, + EndIndex: 2, + Match: "hi", + } + msgID11 := sendMessage(chat1.NewMessageBodyWithText(chat1.MessageText{ + Body: query, + }), u1) + + res = runSearch(query, opts, false /* expectedReindex*/) + require.Equal(t, 1, len(res.Hits)) + convHit = res.Hits[0] + require.Equal(t, convID, convHit.ConvID) + require.Equal(t, 1, len(convHit.Hits)) + verifyHit(convID, []chat1.MessageID{msgID10}, msgID11, nil, []chat1.ChatSearchMatch{searchMatch}, convHit.Hits[0]) + verifySearchDone(1, true) + + err = indexer1.Clear(ctx, uid1, convID) + require.NoError(t, err) + pi, err = indexer1.PercentIndexed(ctx, convID) + require.NoError(t, err) + require.Zero(t, pi) + + err = indexer2.Clear(ctx, uid2, convID) + require.NoError(t, err) + pi, err = indexer2.PercentIndexed(ctx, convID) + require.NoError(t, err) + require.Zero(t, pi) + }) +} From 58df718f55eaa613379861ca37067f407aaf0413 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Mon, 13 Apr 2026 09:53:43 -0400 Subject: [PATCH 56/59] WIP --- go/chat/search_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/go/chat/search_test.go b/go/chat/search_test.go index 501099d7d620..6b0647d9f726 100644 --- a/go/chat/search_test.go +++ b/go/chat/search_test.go @@ -243,7 +243,7 @@ func TestChatSearchConvRegexp(t *testing.T) { // drain the cbs, 8 hits and 4 dones timeout := 20 * time.Second - for i := 0; i < 8+4; i++ { + for range 8 + 4 { select { case <-chatUI.SearchHitCb: case <-chatUI.SearchDoneCb: @@ -316,7 +316,7 @@ func TestChatSearchConvRegexp(t *testing.T) { query = "hi" matches := []chat1.ChatSearchMatch{} startIndex := 0 - for i := 0; i < 3; i++ { + for range 3 { matches = append(matches, chat1.ChatSearchMatch{ StartIndex: startIndex, EndIndex: startIndex + 2, @@ -958,7 +958,7 @@ func TestChatSearchInbox(t *testing.T) { indexer1.SetSyncLoopCh(syncLoopCh) indexer1.StartSyncLoop() waitForFail := func() bool { - for i := 0; i < 5; i++ { + for range 5 { indexer1.CancelSync(ctx) select { case <-time.After(2 * time.Second): From 93342b8d95dff343163e388a01eb3950d0ebf106 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Mon, 13 Apr 2026 09:58:13 -0400 Subject: [PATCH 57/59] WIP --- shared/chat/conversation/list-area/index.native.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index 8cd709e7e1c7..52d94423a5b2 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -262,6 +262,7 @@ const ConversationList = function ConversationList() { Date: Mon, 13 Apr 2026 10:25:35 -0400 Subject: [PATCH 58/59] WIP --- go/chat/search/deadlock_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/go/chat/search/deadlock_test.go b/go/chat/search/deadlock_test.go index 90c14aa696be..773aab687308 100644 --- a/go/chat/search/deadlock_test.go +++ b/go/chat/search/deadlock_test.go @@ -31,7 +31,8 @@ func (d *deadlockTestDiskStorage) PutTokenEntry(ctx context.Context, convID chat return nil } -func (d *deadlockTestDiskStorage) RemoveTokenEntry(ctx context.Context, convID chat1.ConversationID, token string) {} +func (d *deadlockTestDiskStorage) RemoveTokenEntry(ctx context.Context, convID chat1.ConversationID, token string) { +} func (d *deadlockTestDiskStorage) GetAliasEntry(ctx context.Context, alias string) (res *aliasEntry, err error) { return nil, nil From e84e6bdbaed82af4397e2bec1bf7417ab7a3e351 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 13 Apr 2026 12:12:38 -0400 Subject: [PATCH 59/59] update some deps (#29140) --- shared/desktop/electron-sums.mts | 12 +- shared/ios/Podfile.lock | 92 ++--- shared/package.json | 52 +-- shared/yarn.lock | 617 +++++++++++++++---------------- 4 files changed, 386 insertions(+), 387 deletions(-) diff --git a/shared/desktop/electron-sums.mts b/shared/desktop/electron-sums.mts index a4dbad7c941a..ad38ea8f72fa 100644 --- a/shared/desktop/electron-sums.mts +++ b/shared/desktop/electron-sums.mts @@ -1,10 +1,10 @@ // Generated with: ./extract-electron-shasums.sh {ver} // prettier-ignore export const electronChecksums = { - 'electron-v41.1.1-darwin-arm64.zip': '522cbb4b4fc8cd3db07fbae03534483fd7c6e2df59534e1e52a7c724efe0b125', - 'electron-v41.1.1-darwin-x64.zip': '791fb22b34647faebb2dcc5bd6688c40f90014d35bf27125cfcebbc8c2b83edd', - 'electron-v41.1.1-linux-arm64.zip': '747eb4e60382b5a9f2725488a9ed2c6f92f563ceb14c9682118ef1b03b062b09', - 'electron-v41.1.1-linux-x64.zip': '37d9a6874aa60cba4931ced5099ff40704ae4833da75da63f45286e59dcbd923', - 'electron-v41.1.1-win32-x64.zip': '1259809991d6c0914f51ae12829abdacedeca76f5be9f7f55347f8bf0b632a2e', - 'hunspell_dictionaries.zip': 'db0ba05210f63467f01a1b696109764e5357bba4d181baa5c6fba6ac175b7f37', + 'electron-v41.2.0-darwin-arm64.zip': 'e018684f96c873415fbea4713fc7db96b6d1e2bd3db4513e2b8c1887ec83a719', + 'electron-v41.2.0-darwin-x64.zip': 'fb3750bcfccc0146065708bf065288252da02489d51414a6d5b77d04f94a3f2a', + 'electron-v41.2.0-linux-arm64.zip': 'f8983c877df8f2b93c76d35e45af9df82c9eb5f294b183f8fe5930e5155fdc4e', + 'electron-v41.2.0-linux-x64.zip': 'fb0b31f5bb2b248d571c08ab57437c08a69b57f63ccdf9e55d6692b6132848d4', + 'electron-v41.2.0-win32-x64.zip': 'f6ccc690836fcc380199c6af7307e378cbdea73bd757a0d229200df7fc8e92d7', + 'hunspell_dictionaries.zip': '105e5ac2716269180697608ebe19944e8fc63111b7d77f2cfd25af7cc71eb252', } diff --git a/shared/ios/Podfile.lock b/shared/ios/Podfile.lock index fc7e57fe4e2a..c6e02a06f90c 100644 --- a/shared/ios/Podfile.lock +++ b/shared/ios/Podfile.lock @@ -1,9 +1,9 @@ PODS: - boost (1.84.0) - DoubleConversion (1.1.6) - - EXConstants (55.0.12): + - EXConstants (55.0.14): - ExpoModulesCore - - Expo (55.0.12): + - Expo (55.0.15): - boost - DoubleConversion - ExpoModulesCore @@ -34,27 +34,27 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - ExpoAsset (55.0.13): + - ExpoAsset (55.0.15): - ExpoModulesCore - - ExpoAudio (55.0.12): + - ExpoAudio (55.0.13): - ExpoModulesCore - - ExpoCamera (55.0.14): + - ExpoCamera (55.0.15): - ExpoModulesCore - ZXingObjC/OneD - ZXingObjC/PDF417 - - ExpoClipboard (55.0.12): + - ExpoClipboard (55.0.13): - ExpoModulesCore - - ExpoContacts (55.0.12): + - ExpoContacts (55.0.13): - ExpoModulesCore - - ExpoDocumentPicker (55.0.12): + - ExpoDocumentPicker (55.0.13): - ExpoModulesCore - ExpoDomWebView (55.0.5): - ExpoModulesCore - - ExpoFileSystem (55.0.15): + - ExpoFileSystem (55.0.16): - ExpoModulesCore - ExpoFont (55.0.6): - ExpoModulesCore - - ExpoHaptics (55.0.13): + - ExpoHaptics (55.0.14): - ExpoModulesCore - ExpoImage (55.0.8): - ExpoModulesCore @@ -63,22 +63,22 @@ PODS: - SDWebImageAVIFCoder (~> 0.11.0) - SDWebImageSVGCoder (~> 1.7.0) - SDWebImageWebPCoder (~> 0.14.6) - - ExpoImagePicker (55.0.17): + - ExpoImagePicker (55.0.18): - ExpoModulesCore - ExpoKeepAwake (55.0.6): - ExpoModulesCore - - ExpoLocalization (55.0.12): + - ExpoLocalization (55.0.13): - ExpoModulesCore - - ExpoLocation (55.1.7): + - ExpoLocation (55.1.8): - ExpoModulesCore - ExpoLogBox (55.0.10): - React-Core - - ExpoMailComposer (55.0.12): + - ExpoMailComposer (55.0.13): - ExpoModulesCore - - ExpoMediaLibrary (55.0.13): + - ExpoMediaLibrary (55.0.14): - ExpoModulesCore - React-Core - - ExpoModulesCore (55.0.21): + - ExpoModulesCore (55.0.22): - boost - DoubleConversion - ExpoModulesJSI @@ -109,19 +109,19 @@ PODS: - RNWorklets - SocketRocket - Yoga - - ExpoModulesJSI (55.0.21): + - ExpoModulesJSI (55.0.22): - hermes-engine - React-Core - React-runtimescheduler - ReactCommon - - ExpoScreenCapture (55.0.12): + - ExpoScreenCapture (55.0.13): - ExpoModulesCore - - ExpoSMS (55.0.12): + - ExpoSMS (55.0.13): - ExpoModulesCore - - ExpoTaskManager (55.0.13): + - ExpoTaskManager (55.0.14): - ExpoModulesCore - UMAppLoader - - ExpoVideo (55.0.14): + - ExpoVideo (55.0.15): - ExpoModulesCore - fast_float (8.0.0) - FBLazyVector (0.83.4) @@ -2119,7 +2119,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - react-native-keyboard-controller (1.21.4): + - react-native-keyboard-controller (1.21.5): - boost - DoubleConversion - fast_float @@ -2137,7 +2137,7 @@ PODS: - React-graphics - React-ImageManager - React-jsi - - react-native-keyboard-controller/common (= 1.21.4) + - react-native-keyboard-controller/common (= 1.21.5) - React-NativeModulesApple - React-RCTFabric - React-renderercss @@ -2148,7 +2148,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - react-native-keyboard-controller/common (1.21.4): + - react-native-keyboard-controller/common (1.21.5): - boost - DoubleConversion - fast_float @@ -3598,32 +3598,32 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb - EXConstants: 97e4a5b18e38331acce952f0e4a6817b0418408f - Expo: 2b076215e30ccfdc6e38139c83b946c208573eab - ExpoAsset: 3636e70a874487efd0a677f6c064dbc9fc8f174b - ExpoAudio: 4909b976000ae98a23fc1db5d2da4a791f56f96c - ExpoCamera: 333b38b5c8d675dc86654d0f076b135bca967b2f - ExpoClipboard: 7c8c987388fcde07e799050a8c7a7d84ece2c919 - ExpoContacts: 4df2c12bb1a19b6110ecd2f474d5ab2296d6d19a - ExpoDocumentPicker: 65f97a909454df0cdf6152910eca64e847f1c4dc + EXConstants: bfe4ae4e5d882e2e0b130e049665d0af4f4cc1b8 + Expo: d57311f70b1a65d9fd054036bf8749e58e4ecd49 + ExpoAsset: dc4f25f84886120f82b23233bba563ea7afa88f5 + ExpoAudio: effd4eb58abee67050f79e8764fe1078daea39c8 + ExpoCamera: 5f6ae5fd7365ceb741168a71eeaa5b65e556c672 + ExpoClipboard: 5d1b0cd2686406f21e616f2d9b3431259dee2e6a + ExpoContacts: f893ee6893bfcbb8bcd0be12a0c8472a8ea7b9c3 + ExpoDocumentPicker: be59b82799ae30811e3f37a7521d6622baa63a19 ExpoDomWebView: 2b2fbd9a07de8790569257cbf9dfdaa31cf95c70 - ExpoFileSystem: d40374bc7b6e990e2640e5dc3350fc83b1d74a40 + ExpoFileSystem: 310d367cccbd30b9bda13c5865fe3d8d581dcf2a ExpoFont: cdd7a1d574a376fa003c713eff49e0a4df8672c7 - ExpoHaptics: 0acad494faee522cfdef7521cc2cabafb9fe8a70 + ExpoHaptics: 679f09dc37d5981e619bc197732007a3334e80b8 ExpoImage: ef931bba1fd3e907c2262216d17eb21095c9ac2b - ExpoImagePicker: 26e747154466fe76f68d6f3deac7e318881d0101 + ExpoImagePicker: ce50d0bf7d27d1a822b08a84bea9bfc0b3924557 ExpoKeepAwake: a1baf9810a2dee1905aa9bdd336852598f7220e9 - ExpoLocalization: 473986270d56c83f821d7ffde5c5b7689802e5cf - ExpoLocation: 61835877870dec5c6aa422a8878fa32f81c8789d + ExpoLocalization: c5cd7fa65c797d3a2f1adbd1fd4c601c524fd677 + ExpoLocation: ba5fff1510a7f123abf620fc2242db2fc87eba0f ExpoLogBox: a3de999775d423ac9cb85d24bd47628e5392761f - ExpoMailComposer: ad1b5b7adc48bbf17a3c27bdac31943128e2d9a8 - ExpoMediaLibrary: aaa75ab892d1b361e3590cf0ebea7c09e91249ed - ExpoModulesCore: 25971dcbaa4d5f5497be1d397da3a821333db61d - ExpoModulesJSI: 35c08bc40ba154ccc65af926c8e6629f33a67aad - ExpoScreenCapture: ba04f7d695fe93c160169c6fb28aeba9d1bdd457 - ExpoSMS: 6f2a5add65b56970a57f8243944410c6009298c3 - ExpoTaskManager: 563a5b3625ceea4957221b531aa11ff8300842e0 - ExpoVideo: c6e02ea12931b773f1d70d1ec6409e5ba451f4e4 + ExpoMailComposer: bb63854e80400563d4a1537f1c9fadf7c8e70ab1 + ExpoMediaLibrary: d6b22096da42dea0b5a68fd3eef96761bbf592c4 + ExpoModulesCore: dabdee4a8ff65794a7099878f022ea9453138877 + ExpoModulesJSI: 936d7b87f07a959f739ac5127ccafeeb8d36ccb8 + ExpoScreenCapture: bcbb78db8311c51553ce6178c43e52bef0654c2a + ExpoSMS: cd74cf9d83be085384481c47bfe7240baba70cb6 + ExpoTaskManager: cd4cb9405637f52e26c8a54c1cfe7b974bc9ac2d + ExpoVideo: 434d1e32486309359b8eff4880d636fd8c0c0ba1 fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 FBLazyVector: 82d1d7996af4c5850242966eb81e73f9a6dfab1e fmt: 530618a01105dae0fa3a2f27c81ae11fa8f67eac @@ -3673,7 +3673,7 @@ SPEC CHECKSUMS: React-Mapbuffer: 0b0d3c3074187e72d8a6e8cacce120cb24581565 React-microtasksnativemodule: 175741856a8f6a31e20b973cb784db023256b259 react-native-kb: 47269c30b862f82a1556f88cc6f00dbee91a9a98 - react-native-keyboard-controller: 78f861c4dc73887b4cb7173a37e01f1634c9a58a + react-native-keyboard-controller: d639be66fcdcb95d69de1e3f08bb650cf42f88a6 react-native-netinfo: 9fad4eedfec9840a10e73ac4591ea1158523309b react-native-safe-area-context: eda63a662750758c1fdd7e719c9f1026c8d161cb react-native-webview: 83c663c5bdf1357d3e7c00986260cb888ea0e328 diff --git a/shared/package.json b/shared/package.json index a98547f52e17..5de15b52057a 100644 --- a/shared/package.json +++ b/shared/package.json @@ -78,7 +78,7 @@ "private": true, "dependencies": { "@callstack/liquid-glass": "0.7.1", - "@gorhom/bottom-sheet": "5.2.8", + "@gorhom/bottom-sheet": "5.2.9", "@gorhom/portal": "1.0.14", "@khanacademy/simple-markdown": "2.2.2", "@legendapp/list": "3.0.0-beta.43", @@ -93,25 +93,25 @@ "date-fns": "4.1.0", "emoji-datasource-apple": "16.0.0", "emoji-regex": "10.6.0", - "expo": "55.0.12", - "expo-asset": "55.0.13", - "expo-audio": "55.0.12", - "expo-camera": "55.0.14", - "expo-clipboard": "55.0.12", - "expo-contacts": "55.0.12", - "expo-document-picker": "55.0.12", - "expo-file-system": "55.0.15", - "expo-haptics": "55.0.13", + "expo": "55.0.15", + "expo-asset": "55.0.15", + "expo-audio": "55.0.13", + "expo-camera": "55.0.15", + "expo-clipboard": "55.0.13", + "expo-contacts": "55.0.13", + "expo-document-picker": "55.0.13", + "expo-file-system": "55.0.16", + "expo-haptics": "55.0.14", "expo-image": "55.0.8", - "expo-image-picker": "55.0.17", - "expo-localization": "55.0.12", - "expo-location": "55.1.7", - "expo-mail-composer": "55.0.12", - "expo-media-library": "55.0.13", - "expo-screen-capture": "55.0.12", - "expo-sms": "55.0.12", - "expo-task-manager": "55.0.13", - "expo-video": "55.0.14", + "expo-image-picker": "55.0.18", + "expo-localization": "55.0.13", + "expo-location": "55.1.8", + "expo-mail-composer": "55.0.13", + "expo-media-library": "55.0.14", + "expo-screen-capture": "55.0.13", + "expo-sms": "55.0.13", + "expo-task-manager": "55.0.14", + "expo-video": "55.0.15", "google-libphonenumber": "3.2.44", "immer": "11.1.4", "lodash": "4.18.1", @@ -123,7 +123,7 @@ "react-native": "0.83.4", "react-native-gesture-handler": "3.0.0-beta.2", "react-native-kb": "file:../rnmodules/react-native-kb", - "react-native-keyboard-controller": "1.21.4", + "react-native-keyboard-controller": "1.21.5", "react-native-reanimated": "4.3.0", "react-native-safe-area-context": "5.7.0", "react-native-screens": "4.24.0", @@ -155,7 +155,7 @@ "@types/react-dom": "19.2.3", "@types/react-measure": "2.0.12", "@types/webpack-env": "1.18.8", - "@typescript/native-preview": "7.0.0-dev.20260407.1", + "@typescript/native-preview": "7.0.0-dev.20260413.1", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.2", "@welldone-software/why-did-you-render": "10.0.1", @@ -164,10 +164,10 @@ "babel-plugin-module-resolver": "5.0.3", "babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-native-web": "0.21.2", - "babel-preset-expo": "55.0.16", + "babel-preset-expo": "55.0.17", "cross-env": "10.1.0", "css-loader": "7.1.4", - "electron": "41.1.1", + "electron": "41.2.0", "eslint": "9.39.2", "eslint-plugin-deprecation": "3.0.0", "eslint-plugin-promise": "7.2.1", @@ -181,15 +181,15 @@ "json5": "2.2.3", "null-loader": "4.0.1", "patch-package": "8.0.1", - "prettier": "3.8.1", + "prettier": "3.8.2", "react-refresh": "0.18.0", "react-scan": "0.5.3", "rimraf": "6.1.3", "style-loader": "4.0.0", "terser-webpack-plugin": "5.4.0", "typescript": "6.0.2", - "typescript-eslint": "8.58.0", - "webpack": "5.105.4", + "typescript-eslint": "8.58.1", + "webpack": "5.106.1", "webpack-cli": "7.0.2", "webpack-dev-server": "5.2.3", "webpack-merge": "6.0.1" diff --git a/shared/yarn.lock b/shared/yarn.lock index ad2081d18e7e..6c98c476b411 100644 --- a/shared/yarn.lock +++ b/shared/yarn.lock @@ -1412,13 +1412,13 @@ integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== "@eslint/config-array@^0.21.1": - version "0.21.1" - resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.1.tgz#7d1b0060fea407f8301e932492ba8c18aff29713" - integrity sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA== + version "0.21.2" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.2.tgz#f29e22057ad5316cf23836cee9a34c81fffcb7e6" + integrity sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw== dependencies: "@eslint/object-schema" "^2.1.7" debug "^4.3.1" - minimatch "^3.1.2" + minimatch "^3.1.5" "@eslint/config-helpers@^0.4.2": version "0.4.2" @@ -1435,9 +1435,9 @@ "@types/json-schema" "^7.0.15" "@eslint/eslintrc@^3.3.1": - version "3.3.4" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.4.tgz#e402b1920f7c1f5a15342caa432b1348cacbb641" - integrity sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ== + version "3.3.5" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz#c131793cfc1a7b96f24a83e0a8bbd4b881558c60" + integrity sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg== dependencies: ajv "^6.14.0" debug "^4.3.2" @@ -1446,7 +1446,7 @@ ignore "^5.2.0" import-fresh "^3.2.1" js-yaml "^4.1.1" - minimatch "^3.1.3" + minimatch "^3.1.5" strip-json-comments "^3.1.1" "@eslint/js@9.39.2": @@ -1467,27 +1467,27 @@ "@eslint/core" "^0.17.0" levn "^0.4.1" -"@expo/cli@55.0.22": - version "55.0.22" - resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-55.0.22.tgz#bc04021d3ae1816627af84d31464e85a5cabd691" - integrity sha512-tq6lkS50edbfbKGUkgUmrOZ6JwRZrQY1fFVTrrtakkMFIbNtMTsImFsDpV8nstQM88DvsA9hb2W5cxRStPtIWw== +"@expo/cli@55.0.24": + version "55.0.24" + resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-55.0.24.tgz#58e8591b537bb4ba2750bf8ba0e0e4084b82f122" + integrity sha512-Z6Xh0WNTg1LvoZQ77zO3snF2cFiv1xf0VguDlwTL1Ql87oMOp30f7mjl9jeaSHqoWkgiAbmxgCKKIGjVX/keiA== dependencies: "@expo/code-signing-certificates" "^0.0.6" - "@expo/config" "~55.0.13" + "@expo/config" "~55.0.15" "@expo/config-plugins" "~55.0.8" "@expo/devcert" "^1.2.1" "@expo/env" "~2.1.1" - "@expo/image-utils" "^0.8.12" + "@expo/image-utils" "^0.8.13" "@expo/json-file" "^10.0.13" "@expo/log-box" "55.0.10" "@expo/metro" "~55.0.0" - "@expo/metro-config" "~55.0.14" + "@expo/metro-config" "~55.0.16" "@expo/osascript" "^2.4.2" "@expo/package-manager" "^1.10.4" "@expo/plist" "^0.5.2" - "@expo/prebuild-config" "^55.0.13" - "@expo/require-utils" "^55.0.3" - "@expo/router-server" "^55.0.13" + "@expo/prebuild-config" "^55.0.15" + "@expo/require-utils" "^55.0.4" + "@expo/router-server" "^55.0.14" "@expo/schema-utils" "^55.0.3" "@expo/spawn-async" "^1.7.2" "@expo/ws-tunnel" "^1.0.1" @@ -1503,12 +1503,12 @@ compression "^1.7.4" connect "^3.7.0" debug "^4.3.4" - dnssd-advertise "^1.1.3" + dnssd-advertise "^1.1.4" expo-server "^55.0.7" - fetch-nodeshim "^0.4.6" + fetch-nodeshim "^0.4.10" getenv "^2.0.0" glob "^13.0.0" - lan-network "^0.2.0" + lan-network "^0.2.1" multitars "^0.2.3" node-forge "^1.3.3" npm-package-arg "^11.0.0" @@ -1561,19 +1561,18 @@ resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-55.0.5.tgz#731ce3e95866254e18977c0026ebab8a00dd6e10" integrity sha512-sCmSUZG4mZ/ySXvfyyBdhjivz8Q539X1NondwDdYG7s3SBsk+wsgPJzYsqgAG/P9+l0xWjUD2F+kQ1cAJ6NNLg== -"@expo/config@~55.0.13": - version "55.0.13" - resolved "https://registry.yarnpkg.com/@expo/config/-/config-55.0.13.tgz#9ecd4b51527ef3e6e67512cd402d93a18b346461" - integrity sha512-mO6le0JXEk7whsIb5E7rT36wOtdcLRFlApc7eLCOyu24uQUvWKk00HSEPVjiOuMd7EgYz/8JBPCA+Rb96uNjIg== +"@expo/config@~55.0.15": + version "55.0.15" + resolved "https://registry.yarnpkg.com/@expo/config/-/config-55.0.15.tgz#6e6aa54f8f0f1883117d43d0e407a2e504c90618" + integrity sha512-lHc0ELIQ8126jYOMZpLv3WIuvordW98jFg5aT/J1/12n2ycuXu01XLZkJsdw0avO34cusUYb1It+MvY8JiMduA== dependencies: "@expo/config-plugins" "~55.0.8" "@expo/config-types" "^55.0.5" "@expo/json-file" "^10.0.13" - "@expo/require-utils" "^55.0.3" + "@expo/require-utils" "^55.0.4" deepmerge "^4.3.1" getenv "^2.0.0" glob "^13.0.0" - resolve-from "^5.0.0" resolve-workspace-root "^2.0.0" semver "^7.6.0" slugify "^1.3.4" @@ -1624,17 +1623,17 @@ resolve-from "^5.0.0" semver "^7.6.0" -"@expo/image-utils@^0.8.12": - version "0.8.12" - resolved "https://registry.yarnpkg.com/@expo/image-utils/-/image-utils-0.8.12.tgz#56e34b9555745ad4d11c972fe0d1ce71c7c64c41" - integrity sha512-3KguH7kyKqq7pNwLb9j6BBdD/bjmNwXZG/HPWT6GWIXbwrvAJt2JNyYTP5agWJ8jbbuys1yuCzmkX+TU6rmI7A== +"@expo/image-utils@^0.8.13": + version "0.8.13" + resolved "https://registry.yarnpkg.com/@expo/image-utils/-/image-utils-0.8.13.tgz#c7476352af9f576440e5ec8201c2f75f090a4804" + integrity sha512-1I//yBQeTY6p0u1ihqGNDAr35EbSG8uFEupFrIF0jd++h9EWH33521yZJU1yE+mwGlzCb61g3ehu78siMhXBlA== dependencies: + "@expo/require-utils" "^55.0.4" "@expo/spawn-async" "^1.7.2" chalk "^4.0.0" getenv "^2.0.0" jimp-compact "0.16.1" parse-png "^2.1.0" - resolve-from "^5.0.0" semver "^7.6.0" "@expo/json-file@^10.0.13", "@expo/json-file@~10.0.13": @@ -1645,12 +1644,12 @@ "@babel/code-frame" "^7.20.0" json5 "^2.2.3" -"@expo/local-build-cache-provider@55.0.9": - version "55.0.9" - resolved "https://registry.yarnpkg.com/@expo/local-build-cache-provider/-/local-build-cache-provider-55.0.9.tgz#9ef129178dff60f458b67bb0a7592957cbabda58" - integrity sha512-MbRqLuZCzfxkiWMbNy5Kxx3ivji8b0W4DshXEwD5XZlfRrVb8CdShztpNM3UR6IiKJUqFQp6BmCjAx90ptIyWg== +"@expo/local-build-cache-provider@55.0.11": + version "55.0.11" + resolved "https://registry.yarnpkg.com/@expo/local-build-cache-provider/-/local-build-cache-provider-55.0.11.tgz#26178937e6df1b310ecf1d5b156c6b87bb8a2fae" + integrity sha512-rJ4RTCrkeKaXaido/bVyhl90ZRtVTOEbj59F1PWVjIEIVgjdlfc1J3VD9v7hEsbf/+8Tbr/PgvWhT6Visi5sLQ== dependencies: - "@expo/config" "~55.0.13" + "@expo/config" "~55.0.15" chalk "^4.1.2" "@expo/log-box@55.0.10": @@ -1662,15 +1661,15 @@ anser "^1.4.9" stacktrace-parser "^0.1.10" -"@expo/metro-config@55.0.14", "@expo/metro-config@~55.0.14": - version "55.0.14" - resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-55.0.14.tgz#422aaf40d2e6476fe04c34359da7a0b1ff406b17" - integrity sha512-s9tD8eTANTEh9j0mHreMYNRzfxfqc0dpfCbJ0oi3S2X11T75xQifppABKBGvzntw3nZ6O/QRJZykomXnLe8u0A== +"@expo/metro-config@55.0.16", "@expo/metro-config@~55.0.16": + version "55.0.16" + resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-55.0.16.tgz#c77b44a650c04eac87d20cb4d604cd8ec6b9c139" + integrity sha512-JaWDw0dmYZ5pOqA+3/Efvl8JzCVgWQVPogHFjTRC5azUgAsFV+T7moOaZTSgg4d+5TjFZjZbMZg4SUomE7LiGg== dependencies: "@babel/code-frame" "^7.20.0" "@babel/core" "^7.20.0" "@babel/generator" "^7.20.5" - "@expo/config" "~55.0.13" + "@expo/config" "~55.0.15" "@expo/env" "~2.1.1" "@expo/json-file" "~10.0.13" "@expo/metro" "~55.0.0" @@ -1735,15 +1734,15 @@ base64-js "^1.5.1" xmlbuilder "^15.1.1" -"@expo/prebuild-config@^55.0.13": - version "55.0.13" - resolved "https://registry.yarnpkg.com/@expo/prebuild-config/-/prebuild-config-55.0.13.tgz#7c8ea88a6576f8deccdae8e5d0844187974a6529" - integrity sha512-3a0vS6dHhVEs8B9Sqz6OIdCZ52S7SWuvLxNTQ+LE66g8OJ5b8xW6kGSCK0Z2bWBFoYfAbZzitLaBi8oBKOVqkw== +"@expo/prebuild-config@^55.0.15": + version "55.0.15" + resolved "https://registry.yarnpkg.com/@expo/prebuild-config/-/prebuild-config-55.0.15.tgz#398989f15db8979e162aa0cdad39c9032f6d040c" + integrity sha512-UcCzVhVBE42UbY5U3t/q1Rk2fSFW/B50LJpB6oFpXhImJfvLKu7ayOFU9XcHd38K89i4GqSia/xXuxQvu4RUBg== dependencies: - "@expo/config" "~55.0.13" + "@expo/config" "~55.0.15" "@expo/config-plugins" "~55.0.8" "@expo/config-types" "^55.0.5" - "@expo/image-utils" "^0.8.12" + "@expo/image-utils" "^0.8.13" "@expo/json-file" "^10.0.13" "@react-native/normalize-colors" "0.83.4" debug "^4.3.1" @@ -1751,19 +1750,19 @@ semver "^7.6.0" xml2js "0.6.0" -"@expo/require-utils@^55.0.3": - version "55.0.3" - resolved "https://registry.yarnpkg.com/@expo/require-utils/-/require-utils-55.0.3.tgz#4f7c37ce49e374939b6a7e22736741b105434385" - integrity sha512-TS1m5tW45q4zoaTlt6DwmdYHxvFTIxoLrTHKOFrIirHIqIXnHCzpceg8wumiBi+ZXSaGY2gobTbfv+WVhJY6Fw== +"@expo/require-utils@^55.0.4": + version "55.0.4" + resolved "https://registry.yarnpkg.com/@expo/require-utils/-/require-utils-55.0.4.tgz#cd474a8997ba6ecfa43d084a7f17bde0cb854179" + integrity sha512-JAANvXqV7MOysWeVWgaiDzikoyDjJWOV/ulOW60Zb3kXJfrx2oZOtGtDXDFKD1mXuahQgoM5QOjuZhF7gFRNjA== dependencies: "@babel/code-frame" "^7.20.0" "@babel/core" "^7.25.2" "@babel/plugin-transform-modules-commonjs" "^7.24.8" -"@expo/router-server@^55.0.13": - version "55.0.13" - resolved "https://registry.yarnpkg.com/@expo/router-server/-/router-server-55.0.13.tgz#c270e3936e4b2a89ca074f69e59328a08c7105cb" - integrity sha512-AoxfxJYkAIMey8YqAohFovp4M4DjzoCDH9ampVN/ZKt+bzXkTIFmWEinQ5mpMfHdfIWaumvxQbohgoo6D5xUZA== +"@expo/router-server@^55.0.14": + version "55.0.14" + resolved "https://registry.yarnpkg.com/@expo/router-server/-/router-server-55.0.14.tgz#2ec98ecb6cd1bdaf70803919e6a9bcb06170248f" + integrity sha512-YJjbeLMLp+ZjCnajHI+jEppNzXY372K0u4I4fLKGnA/loFX14aouDsg4tqZVGlZx6NUpnN8Bb3Tmw2BLTXT5Qw== dependencies: debug "^4.3.4" @@ -1808,10 +1807,10 @@ chalk "^4.1.0" js-yaml "^4.1.0" -"@gorhom/bottom-sheet@5.2.8": - version "5.2.8" - resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.2.8.tgz#25e49122c30ffe83d3813b3bcf3dec39f3359aeb" - integrity sha512-+N27SMpbBxXZQ/IA2nlEV6RGxL/qSFHKfdFKcygvW+HqPG5jVNb1OqehLQsGfBP+Up42i0gW5ppI+DhpB7UCzA== +"@gorhom/bottom-sheet@5.2.9": + version "5.2.9" + resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.2.9.tgz#57d26ab8a4a881bb4be8fd45a4b9539929c9f198" + integrity sha512-YwieCsEnTQnN2QW4VBKfCGszzxaw2ID7FydusEgqo7qB817fZ45N88kptcuNwZFnnauCjdyzKdrVBWmLmpl9oQ== dependencies: "@gorhom/portal" "1.0.14" invariant "^2.2.4" @@ -3638,16 +3637,16 @@ dependencies: "@types/node" "*" -"@typescript-eslint/eslint-plugin@8.58.0": - version "8.58.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz#ad40e492f1931f46da1bd888e52b9e56df9063aa" - integrity sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg== +"@typescript-eslint/eslint-plugin@8.58.1": + version "8.58.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz#cb53038b83d165ca0ef96d67d875efbd56c50fa8" + integrity sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ== dependencies: "@eslint-community/regexpp" "^4.12.2" - "@typescript-eslint/scope-manager" "8.58.0" - "@typescript-eslint/type-utils" "8.58.0" - "@typescript-eslint/utils" "8.58.0" - "@typescript-eslint/visitor-keys" "8.58.0" + "@typescript-eslint/scope-manager" "8.58.1" + "@typescript-eslint/type-utils" "8.58.1" + "@typescript-eslint/utils" "8.58.1" + "@typescript-eslint/visitor-keys" "8.58.1" ignore "^7.0.5" natural-compare "^1.4.0" ts-api-utils "^2.5.0" @@ -3666,15 +3665,15 @@ natural-compare "^1.4.0" ts-api-utils "^2.4.0" -"@typescript-eslint/parser@8.58.0": - version "8.58.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.58.0.tgz#da04ece1967b6c2fe8f10c3473dabf3825795ef7" - integrity sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA== +"@typescript-eslint/parser@8.58.1": + version "8.58.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.58.1.tgz#0943eca522ac408bcdd649882c3d95b10ff00f62" + integrity sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw== dependencies: - "@typescript-eslint/scope-manager" "8.58.0" - "@typescript-eslint/types" "8.58.0" - "@typescript-eslint/typescript-estree" "8.58.0" - "@typescript-eslint/visitor-keys" "8.58.0" + "@typescript-eslint/scope-manager" "8.58.1" + "@typescript-eslint/types" "8.58.1" + "@typescript-eslint/typescript-estree" "8.58.1" + "@typescript-eslint/visitor-keys" "8.58.1" debug "^4.4.3" "@typescript-eslint/parser@^8.36.0": @@ -3697,13 +3696,13 @@ "@typescript-eslint/types" "^8.56.1" debug "^4.4.3" -"@typescript-eslint/project-service@8.58.0": - version "8.58.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.58.0.tgz#66ceda0aabf7427aec3e2713fa43eb278dead2aa" - integrity sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg== +"@typescript-eslint/project-service@8.58.1": + version "8.58.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.58.1.tgz#c78781b1ca1ec1e7bc6522efba89318c6d249feb" + integrity sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g== dependencies: - "@typescript-eslint/tsconfig-utils" "^8.58.0" - "@typescript-eslint/types" "^8.58.0" + "@typescript-eslint/tsconfig-utils" "^8.58.1" + "@typescript-eslint/types" "^8.58.1" debug "^4.4.3" "@typescript-eslint/scope-manager@7.18.0": @@ -3722,23 +3721,23 @@ "@typescript-eslint/types" "8.56.1" "@typescript-eslint/visitor-keys" "8.56.1" -"@typescript-eslint/scope-manager@8.58.0": - version "8.58.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz#e304142775e49a1b7ac3c8bf2536714447c72cab" - integrity sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ== +"@typescript-eslint/scope-manager@8.58.1": + version "8.58.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz#35168f561bab4e3fd10dd6b03e8b83c157479211" + integrity sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w== dependencies: - "@typescript-eslint/types" "8.58.0" - "@typescript-eslint/visitor-keys" "8.58.0" + "@typescript-eslint/types" "8.58.1" + "@typescript-eslint/visitor-keys" "8.58.1" "@typescript-eslint/tsconfig-utils@8.56.1", "@typescript-eslint/tsconfig-utils@^8.56.1": version "8.56.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz#1afa830b0fada5865ddcabdc993b790114a879b7" integrity sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ== -"@typescript-eslint/tsconfig-utils@8.58.0", "@typescript-eslint/tsconfig-utils@^8.58.0": - version "8.58.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz#c5a8edb21f31e0fdee565724e1b984171c559482" - integrity sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A== +"@typescript-eslint/tsconfig-utils@8.58.1", "@typescript-eslint/tsconfig-utils@^8.58.1": + version "8.58.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz#eb16792c579300c7bfb3c74b0f5e1dfbb0a2454d" + integrity sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw== "@typescript-eslint/type-utils@8.56.1": version "8.56.1" @@ -3751,14 +3750,14 @@ debug "^4.4.3" ts-api-utils "^2.4.0" -"@typescript-eslint/type-utils@8.58.0": - version "8.58.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz#ce0e72cd967ffbbe8de322db6089bd4374be352f" - integrity sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg== +"@typescript-eslint/type-utils@8.58.1": + version "8.58.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz#b21085a233087bde94c92ba6f5b4dfb77ca56730" + integrity sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w== dependencies: - "@typescript-eslint/types" "8.58.0" - "@typescript-eslint/typescript-estree" "8.58.0" - "@typescript-eslint/utils" "8.58.0" + "@typescript-eslint/types" "8.58.1" + "@typescript-eslint/typescript-estree" "8.58.1" + "@typescript-eslint/utils" "8.58.1" debug "^4.4.3" ts-api-utils "^2.5.0" @@ -3772,10 +3771,10 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.56.1.tgz#975e5942bf54895291337c91b9191f6eb0632ab9" integrity sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw== -"@typescript-eslint/types@8.58.0", "@typescript-eslint/types@^8.58.0": - version "8.58.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.58.0.tgz#e94ae7abdc1c6530e71183c1007b61fa93112a5a" - integrity sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww== +"@typescript-eslint/types@8.58.1", "@typescript-eslint/types@^8.58.1": + version "8.58.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.58.1.tgz#9dfb4723fcd2b13737d8b03d941354cf73190313" + integrity sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw== "@typescript-eslint/typescript-estree@7.18.0": version "7.18.0" @@ -3806,15 +3805,15 @@ tinyglobby "^0.2.15" ts-api-utils "^2.4.0" -"@typescript-eslint/typescript-estree@8.58.0": - version "8.58.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz#ed233faa8e2f2a2e1357c3e7d553d6465a0ee59a" - integrity sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA== +"@typescript-eslint/typescript-estree@8.58.1": + version "8.58.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz#8230cc9628d2cffef101e298c62807c4b9bf2fe9" + integrity sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg== dependencies: - "@typescript-eslint/project-service" "8.58.0" - "@typescript-eslint/tsconfig-utils" "8.58.0" - "@typescript-eslint/types" "8.58.0" - "@typescript-eslint/visitor-keys" "8.58.0" + "@typescript-eslint/project-service" "8.58.1" + "@typescript-eslint/tsconfig-utils" "8.58.1" + "@typescript-eslint/types" "8.58.1" + "@typescript-eslint/visitor-keys" "8.58.1" debug "^4.4.3" minimatch "^10.2.2" semver "^7.7.3" @@ -3831,15 +3830,15 @@ "@typescript-eslint/types" "8.56.1" "@typescript-eslint/typescript-estree" "8.56.1" -"@typescript-eslint/utils@8.58.0": - version "8.58.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.58.0.tgz#21a74a7963b0d288b719a4121c7dd555adaab3c3" - integrity sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA== +"@typescript-eslint/utils@8.58.1": + version "8.58.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.58.1.tgz#099a327b04ed921e6ee3988cde9ef34bc4b5435a" + integrity sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ== dependencies: "@eslint-community/eslint-utils" "^4.9.1" - "@typescript-eslint/scope-manager" "8.58.0" - "@typescript-eslint/types" "8.58.0" - "@typescript-eslint/typescript-estree" "8.58.0" + "@typescript-eslint/scope-manager" "8.58.1" + "@typescript-eslint/types" "8.58.1" + "@typescript-eslint/typescript-estree" "8.58.1" "@typescript-eslint/utils@^7.0.0": version "7.18.0" @@ -3867,61 +3866,61 @@ "@typescript-eslint/types" "8.56.1" eslint-visitor-keys "^5.0.0" -"@typescript-eslint/visitor-keys@8.58.0": - version "8.58.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz#2abd55a4be70fd55967aceaba4330b9ba9f45189" - integrity sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ== +"@typescript-eslint/visitor-keys@8.58.1": + version "8.58.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz#7c197533177f1ba9b8249f55f7f685e32bb6f204" + integrity sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ== dependencies: - "@typescript-eslint/types" "8.58.0" + "@typescript-eslint/types" "8.58.1" eslint-visitor-keys "^5.0.0" -"@typescript/native-preview-darwin-arm64@7.0.0-dev.20260407.1": - version "7.0.0-dev.20260407.1" - resolved "https://registry.yarnpkg.com/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260407.1.tgz#f4921f8211f34e4ee1ae7dfcd78a213752c2d694" - integrity sha512-akoBfxvDbULMWLqHPDBI5sRkhjQ0blX5+iG7GBoSstqJZW4P0nzd516COGs7xWHsu3apBhaBgSTMCFO78kG80w== - -"@typescript/native-preview-darwin-x64@7.0.0-dev.20260407.1": - version "7.0.0-dev.20260407.1" - resolved "https://registry.yarnpkg.com/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260407.1.tgz#0ee8274653ba00b9af4deb83a2493ecbee90bc61" - integrity sha512-j/V5BS+tgcRFGQC+y95vZB78fI45UgobAEY1+NlFZ3Yih9ICKWRfJPcalpiP5vjiO2NgqVzcFfO9XbpJyq5TTA== - -"@typescript/native-preview-linux-arm64@7.0.0-dev.20260407.1": - version "7.0.0-dev.20260407.1" - resolved "https://registry.yarnpkg.com/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260407.1.tgz#585b51a3a67c3c667dfe9f6d951d93f88f93c791" - integrity sha512-QG0E0lmcZQZimvNltxyi5Q3Oz1pd0BdztS7K5T9HTs30E3TSeYHq7Csw3SbDfAVwcqs2HTe/AVqLy6ar+1zm3Q== - -"@typescript/native-preview-linux-arm@7.0.0-dev.20260407.1": - version "7.0.0-dev.20260407.1" - resolved "https://registry.yarnpkg.com/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260407.1.tgz#4d02f49e4b30931ccc890c1f084e1b017c298660" - integrity sha512-ZDr+zQFSTPmLIGyXDWixYFeFtktWUDGAD6s65rTI5EJgyt4X5/kEMnNd04mf4PbN0ChSiTRzJYLzaM+JGo+jww== - -"@typescript/native-preview-linux-x64@7.0.0-dev.20260407.1": - version "7.0.0-dev.20260407.1" - resolved "https://registry.yarnpkg.com/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260407.1.tgz#9390d68025c02a4f424c09374e8380c4e146909f" - integrity sha512-a82yGx039yqZBS0dwKG8+kgeF2xVA7Pg6lL2SrswbaxWz3bXpI0ASX3HgUw+JMSIr4fbZ5ulKcaorPqbhc48/A== - -"@typescript/native-preview-win32-arm64@7.0.0-dev.20260407.1": - version "7.0.0-dev.20260407.1" - resolved "https://registry.yarnpkg.com/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260407.1.tgz#d5a1a54fe3a66b4654d1f29bb9ba3f8122f39b5b" - integrity sha512-e38ow5yqBrdiz4GunQCRk1E7cTtowpbXeAvVJf1wXrWbFqEc0D8BE7YPmTy9W2fOI0KFHUrsFg5h4Ad/TKVjug== - -"@typescript/native-preview-win32-x64@7.0.0-dev.20260407.1": - version "7.0.0-dev.20260407.1" - resolved "https://registry.yarnpkg.com/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260407.1.tgz#240b92804eb41c5273dd74f51257e23a67fe6753" - integrity sha512-1Jiij5NQOvlM72/DdfXzAVia1pdffgHiVgWZVmDwXECpzwQB0WwWfhI/0IddXP92Y9gVQFCGo9lypSAnamfGPA== - -"@typescript/native-preview@7.0.0-dev.20260407.1": - version "7.0.0-dev.20260407.1" - resolved "https://registry.yarnpkg.com/@typescript/native-preview/-/native-preview-7.0.0-dev.20260407.1.tgz#f39ef7b2f534b547336e61daf22ce1f041247f86" - integrity sha512-gf1W3UbzVTDkZJuwhNtOcfQ6l3hpDcxuWh90ANlp/cKupmAqaXNGpT23YjTYqXsaI7RDQR7JUELCKeWbW9PJIg== +"@typescript/native-preview-darwin-arm64@7.0.0-dev.20260413.1": + version "7.0.0-dev.20260413.1" + resolved "https://registry.yarnpkg.com/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260413.1.tgz#2c83b89c8f3b79b1d4be6ebabb91f8f332eda583" + integrity sha512-CDgxIPvAWRCfOiQKvSk4wUkAoRW4Cy6vfAUBPNHSeLalIt43ToF0LOAsa5uLyRGsftjfMYY0A4qFOmgDvBhgzQ== + +"@typescript/native-preview-darwin-x64@7.0.0-dev.20260413.1": + version "7.0.0-dev.20260413.1" + resolved "https://registry.yarnpkg.com/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260413.1.tgz#228505f87410d6389f6da50aa0ee2ac98cf2a4f3" + integrity sha512-oiMmUtNMaqBh+eUogX53ichcEf7d+7upC0qa7xS9zWl85XEPKlrZCZpZ79yixw1PkdpjqJJigI11bmCi/JVv+g== + +"@typescript/native-preview-linux-arm64@7.0.0-dev.20260413.1": + version "7.0.0-dev.20260413.1" + resolved "https://registry.yarnpkg.com/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260413.1.tgz#9156abf9c866a267d973f9296b577b250cdb2716" + integrity sha512-hPKanfs9c+7953gIYw13CNxN0HqFAOfJjnWk4SHqSBe3Pj9pxoeJvvRWlofp5C833eOZK6gZB7ll0/uNb0djtA== + +"@typescript/native-preview-linux-arm@7.0.0-dev.20260413.1": + version "7.0.0-dev.20260413.1" + resolved "https://registry.yarnpkg.com/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260413.1.tgz#a025850e478c19e7ed3f72c4749ea069aa57fc4b" + integrity sha512-0lSXBzBVsxIGrFv/PxoswzMptsnU6BgSk7GMAUt/o1dVw36R2XrSs538vwKnujaJwt4iIdMS0uGdpUC5s9jkzQ== + +"@typescript/native-preview-linux-x64@7.0.0-dev.20260413.1": + version "7.0.0-dev.20260413.1" + resolved "https://registry.yarnpkg.com/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260413.1.tgz#053911c783fbf53623b940dac2720162deede395" + integrity sha512-8Cr477HRmHZ5YyLfikNvw7qp3/WmnRjzIzJhUDrAx5173OBe8BdyV9jPemFHKDPqwI1AUMTijvptOFoQE7429w== + +"@typescript/native-preview-win32-arm64@7.0.0-dev.20260413.1": + version "7.0.0-dev.20260413.1" + resolved "https://registry.yarnpkg.com/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260413.1.tgz#7ee25645b99d63a6bd3e8a95499dd4f403e24cfe" + integrity sha512-ulJD9ZbIQyTBIDx8zzAzQLtbvQDGHSWrNRgkgBU5Os2NTYADQRco4pU747R9wZPMLopy3IeNck6m8vwPoYMk1g== + +"@typescript/native-preview-win32-x64@7.0.0-dev.20260413.1": + version "7.0.0-dev.20260413.1" + resolved "https://registry.yarnpkg.com/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260413.1.tgz#c48deb5e57ef0b9adc66c01e9976153feaaa1119" + integrity sha512-x7DsSXnLQBf5XBBR8luHf1Nc/T1eByUmrOSEThW6825UB7lHoPlqKdhIoUNnTnS4nXQMxLwcusD4P1EP23GPJw== + +"@typescript/native-preview@7.0.0-dev.20260413.1": + version "7.0.0-dev.20260413.1" + resolved "https://registry.yarnpkg.com/@typescript/native-preview/-/native-preview-7.0.0-dev.20260413.1.tgz#3860c0bdc8ce8c2b1143f24c917ac2073ca4ad79" + integrity sha512-twzr3V4QLEbXaESuI2DqdzutOVFGpkY3VZDR9sF8YlLsAXkwyQvZo58cKM77mZcsHoCR4lCYcdTatWTTa/+8tw== optionalDependencies: - "@typescript/native-preview-darwin-arm64" "7.0.0-dev.20260407.1" - "@typescript/native-preview-darwin-x64" "7.0.0-dev.20260407.1" - "@typescript/native-preview-linux-arm" "7.0.0-dev.20260407.1" - "@typescript/native-preview-linux-arm64" "7.0.0-dev.20260407.1" - "@typescript/native-preview-linux-x64" "7.0.0-dev.20260407.1" - "@typescript/native-preview-win32-arm64" "7.0.0-dev.20260407.1" - "@typescript/native-preview-win32-x64" "7.0.0-dev.20260407.1" + "@typescript/native-preview-darwin-arm64" "7.0.0-dev.20260413.1" + "@typescript/native-preview-darwin-x64" "7.0.0-dev.20260413.1" + "@typescript/native-preview-linux-arm" "7.0.0-dev.20260413.1" + "@typescript/native-preview-linux-arm64" "7.0.0-dev.20260413.1" + "@typescript/native-preview-linux-x64" "7.0.0-dev.20260413.1" + "@typescript/native-preview-win32-arm64" "7.0.0-dev.20260413.1" + "@typescript/native-preview-win32-x64" "7.0.0-dev.20260413.1" "@ungap/structured-clone@^1.3.0": version "1.3.0" @@ -4665,10 +4664,10 @@ babel-preset-current-node-syntax@^1.0.0, babel-preset-current-node-syntax@^1.2.0 "@babel/plugin-syntax-private-property-in-object" "^7.14.5" "@babel/plugin-syntax-top-level-await" "^7.14.5" -babel-preset-expo@55.0.16, babel-preset-expo@~55.0.16: - version "55.0.16" - resolved "https://registry.yarnpkg.com/babel-preset-expo/-/babel-preset-expo-55.0.16.tgz#445d764e122911d1a146208a9a659bc58290eac2" - integrity sha512-WHeXG4QbYA809O5e6YcPhYVck/sxtTPF0InQjKiFfPnOkeb2Q/DHQcRQL0dFWOu4VeUUMyEiHeKtKA442Cg8+g== +babel-preset-expo@55.0.17, babel-preset-expo@~55.0.17: + version "55.0.17" + resolved "https://registry.yarnpkg.com/babel-preset-expo/-/babel-preset-expo-55.0.17.tgz#34d584b0bc5f87a5dd638c849cf9bab7597cca59" + integrity sha512-voPAKycqeqOE+4g/nW6gGaNPMnj3MYCYbVEZlZDUlztGVxlKKkUD+xwlK0ZU/uy6HxAY+tjBEpvsabD5g6b2oQ== dependencies: "@babel/generator" "^7.20.5" "@babel/helper-module-imports" "^7.25.9" @@ -5676,10 +5675,10 @@ dns-packet@^5.2.2: dependencies: "@leichtgewicht/ip-codec" "^2.0.1" -dnssd-advertise@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/dnssd-advertise/-/dnssd-advertise-1.1.3.tgz#bf130e5b22f2d76b2b6b33b201e93c68c75b3786" - integrity sha512-XENsHi3MBzWOCAXif3yZvU1Ah0l+nhJj1sjWL6TnOAYKvGiFhbTx32xHN7+wLMLUOCj7Nr0evADWG4R8JtqCDA== +dnssd-advertise@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/dnssd-advertise/-/dnssd-advertise-1.1.4.tgz#0744865a4fa2569a44dcb9aff267022aaf2803b2" + integrity sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA== doctrine@^2.1.0: version "2.1.0" @@ -5767,10 +5766,10 @@ electron-to-chromium@^1.5.263: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz#032a5802b31f7119269959c69fe2015d8dad5edb" integrity sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg== -electron@41.1.1: - version "41.1.1" - resolved "https://registry.yarnpkg.com/electron/-/electron-41.1.1.tgz#ec2016ad886b4377a4b643fa34fe9cbcd8d7f015" - integrity sha512-8bgvDhBjli+3Z2YCKgzzoBPh6391pr7Xv2h/tTJG4ETgvPvUxZomObbZLs31mUzYb6VrlcDDd9cyWyNKtPm3tA== +electron@41.2.0: + version "41.2.0" + resolved "https://registry.yarnpkg.com/electron/-/electron-41.2.0.tgz#7598019461b3cde346a9d59cc6ba11229d7717f0" + integrity sha512-0OKLiymqfV0WK68RBXqAm3Myad2TpI5wwxLCBEUcH5Nugo3YfSk7p1Js/AL9266qTz5xZioUnxt9hG8FFwax0g== dependencies: "@electron/get" "^2.0.0" "@types/node" "^24.9.0" @@ -6361,53 +6360,53 @@ expect@30.3.0, expect@^30.0.0: jest-mock "30.3.0" jest-util "30.3.0" -expo-asset@55.0.13, expo-asset@~55.0.13: - version "55.0.13" - resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-55.0.13.tgz#012a46ee26bc3bd6c541e343423b562d82b2bfe6" - integrity sha512-XDtshd8GZujYEmC84B3Gj+dCStvjcoywCyHrhO5K68J3CwkauIxyNeOLFlIX/U9FXtCuEykv14Lhz7xCcn1RWA== +expo-asset@55.0.15, expo-asset@~55.0.15: + version "55.0.15" + resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-55.0.15.tgz#21da7801f27adeb0a66680b47c65de726827fedb" + integrity sha512-d3FIpHJ6ZngYXxRItYWBGT5H8Wkk7/l4fMe8Mmd2xDyKrO0/CM7c8r/J5M71D+BJr5P3My8wertGYZXHSiZYxQ== dependencies: - "@expo/image-utils" "^0.8.12" - expo-constants "~55.0.12" + "@expo/image-utils" "^0.8.13" + expo-constants "~55.0.14" -expo-audio@55.0.12: - version "55.0.12" - resolved "https://registry.yarnpkg.com/expo-audio/-/expo-audio-55.0.12.tgz#d1dc32d874db75b87e93eec176b608f23ae31865" - integrity sha512-S192nhgNtvamDf+GCweeIXs8J057uOSEa89y/9xz5OufYQGDAxCcyyffGBIueyHoP3t36hCUnvJjpMJksOEdKQ== +expo-audio@55.0.13: + version "55.0.13" + resolved "https://registry.yarnpkg.com/expo-audio/-/expo-audio-55.0.13.tgz#b8799325b5acd4873a3040c3d3bd4884c2971fe2" + integrity sha512-rY9C81mSE6HHCPtyeCv53nRrBL1Su7JpQVuvbMFOA7AOY7xppg3Gq1SFybiDIiNQunDfcUUc2b8eDO8vkO0Iag== -expo-camera@55.0.14: - version "55.0.14" - resolved "https://registry.yarnpkg.com/expo-camera/-/expo-camera-55.0.14.tgz#2ce14e47c4279e08b4cf489ed5bf1fbfd1ff4d25" - integrity sha512-DT/cPVKKHSems+pT0whVVPsynk47ZbPEZxQnZZfhAZ9LTlWw58KPs3ps2sODVX6CsHghumUd3+NkbHnlNKQDOw== +expo-camera@55.0.15: + version "55.0.15" + resolved "https://registry.yarnpkg.com/expo-camera/-/expo-camera-55.0.15.tgz#db74c1a1dfa65d17a275be380852afb227d848fe" + integrity sha512-WRVsZf+2p7EsxudwyiUMYijJS8M98t/BVP6yG7N+08JSUotkGjmZcemom1gM36uy27P8QsSVP0hD+FravmQiBA== dependencies: barcode-detector "^3.0.0" -expo-clipboard@55.0.12: - version "55.0.12" - resolved "https://registry.yarnpkg.com/expo-clipboard/-/expo-clipboard-55.0.12.tgz#63fb9783ccce53164e8d7569710d5553e9812112" - integrity sha512-DaLjhidJvpkAovzrMUv9LN9OZhiBpwqBOTFeTStRSLiMSwX4QWS0wjqRE2A0v8YnIgjSPrZxLXHLmRJ6TEceow== +expo-clipboard@55.0.13: + version "55.0.13" + resolved "https://registry.yarnpkg.com/expo-clipboard/-/expo-clipboard-55.0.13.tgz#29e9920bf3b22fe80378f438aa9929e7cbcd289c" + integrity sha512-PrOmmuVsGW4bAkNQmGKtxMXj3invsfN+jfIKmQxHwE/dn7ODqwFWviUTa+PMUjP3XZmYCDLyu/i0GLeu7HF9Ew== -expo-constants@~55.0.12: - version "55.0.12" - resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-55.0.12.tgz#4649b5030c418832417239ba6960d820a1dff683" - integrity sha512-e2oxzvPyBv0t51o/lNuiiBtYFQcv3rWnTUvIH0GXRjHkg8LHHePly1vJ5oGg5KO2v8qprleDp9g6s5YD0MIUtQ== +expo-constants@~55.0.14: + version "55.0.14" + resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-55.0.14.tgz#5059aa518a02b6ff405da7d59ed568a501cf7fb3" + integrity sha512-l23QVQCYBPKT5zbxxZdJeuhiunadvWdjcQ9+GC8h+02jCoLmWRk20064nCINnQTP3Hf+uLPteUiwYrJd0e446w== dependencies: - "@expo/config" "~55.0.13" + "@expo/config" "~55.0.15" "@expo/env" "~2.1.1" -expo-contacts@55.0.12: - version "55.0.12" - resolved "https://registry.yarnpkg.com/expo-contacts/-/expo-contacts-55.0.12.tgz#c19731b2cde091123fab4054c36e67801642e607" - integrity sha512-1HGUx1OnZ56F5vD3GxM1P7rc74XjYPBh4Og8y1WFXgZdG3B5FlKNweJR3R2hHGURHCLXeMRfFRt3ZVg3/sFP9A== +expo-contacts@55.0.13: + version "55.0.13" + resolved "https://registry.yarnpkg.com/expo-contacts/-/expo-contacts-55.0.13.tgz#a35f41f0e12b7089eb93ec8637279a8bded778c1" + integrity sha512-UgaadPEvCobODVaaFVrolVl5jzYQitclrB45Uubp4NpYwoVrRVpCKMM2qZLHRPxveib/jmAoF40mva3xDtBuHw== -expo-document-picker@55.0.12: - version "55.0.12" - resolved "https://registry.yarnpkg.com/expo-document-picker/-/expo-document-picker-55.0.12.tgz#a4bc527d2deda076cc9502f1ddcbdfc581eb9d77" - integrity sha512-AyekhUmKD2VjD2y5sOyQh4TBlCaYb5XKzxpXuYpIZzTIQuKb/TdecqxpjwdDH/rtdZvjEWG9ZWRCxDFkLIP1wA== +expo-document-picker@55.0.13: + version "55.0.13" + resolved "https://registry.yarnpkg.com/expo-document-picker/-/expo-document-picker-55.0.13.tgz#f580ea88252c3608d23be1cd3fb0c5a08c0898e2" + integrity sha512-IhswJElhdzs3fKDEKW8KXYRoFkWGEsXRMYAZT46Yo56zqqy8yQXrczo33RSwD2hFzNQBdLT97SJL9N311UyS3g== -expo-file-system@55.0.15, expo-file-system@~55.0.15: - version "55.0.15" - resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-55.0.15.tgz#dfbec76316479c9f930da9eda5d6e16f1f93086c" - integrity sha512-GEo0CzfmRfR7nOjp5p4Tb9XWtgPxDIYRiQws79DpBQsX15UsCdDw7/se3aFO6NyZuGFx/85KsdD7SPGphbE/jw== +expo-file-system@55.0.16, expo-file-system@~55.0.16: + version "55.0.16" + resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-55.0.16.tgz#a4e766ef9ac0d3c4e1bf24165eb78040dd3ac7f7" + integrity sha512-EetQ/zVFK07Vmz4Yke0fvoES4xVwScTdd0PMoLekuMX7puE4op75pNnEdh1M0AeWzkqLrBoZIaU2ynSrKN5VZg== expo-font@~55.0.6: version "55.0.6" @@ -6416,20 +6415,20 @@ expo-font@~55.0.6: dependencies: fontfaceobserver "^2.1.0" -expo-haptics@55.0.13: - version "55.0.13" - resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-55.0.13.tgz#8cd10850795d6fdc714114bbd2be5964829a52aa" - integrity sha512-mfchTuKX6aiR3CEn1NyUviSnp9NwunuBlx2p5XIQymvCBwDxUddJlrStz5gMPUb6phUS+1YSH5O2S+IyFgqFjA== +expo-haptics@55.0.14: + version "55.0.14" + resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-55.0.14.tgz#9532ba088ee7eae561ad0ef5552c78f33161998a" + integrity sha512-KjDItBsA9mi1f5nRwf8g1wOdfEcLHwvEdt5Jl1sMCDETR/homcGOl+F3QIiPOl/PRlbGVieQsjTtF4DGtHOj6g== expo-image-loader@~55.0.0: version "55.0.0" resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-55.0.0.tgz#56ae6631a0f43191432296a1f7f1e9737e653cfe" integrity sha512-NOjp56wDrfuA5aiNAybBIjqIn1IxKeGJ8CECWZncQ/GzjZfyTYAHTCyeApYkdKkMBLHINzI4BbTGSlbCa0fXXQ== -expo-image-picker@55.0.17: - version "55.0.17" - resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-55.0.17.tgz#8395217d43bc97a7f648253decc534ee0ae4e257" - integrity sha512-oCayiw6ZMKDnUGVPFhQ1j0Cg0ZvzSDWwuVm0QSX+AkdqBuRv/n3SB3ZTVW2M+lR6zU/aTtVTduqlNnVyv4CrhA== +expo-image-picker@55.0.18: + version "55.0.18" + resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-55.0.18.tgz#a0554e9c7ba5f090256788f7c9c686dc6a88ef91" + integrity sha512-lGpPGRu+7mE8qN0ma2boRsCmfOGbdHZ2bXTpWVeWly0JCZdogGlTrYFnhTqgS8+lmiRb/UCOs7iTm2P5Rra6kw== dependencies: expo-image-loader "~55.0.0" @@ -6445,99 +6444,99 @@ expo-keep-awake@~55.0.6: resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-55.0.6.tgz#b5bac9a811e0dfe77deefeaf57e9c73b5dbcc839" integrity sha512-acJjeHqkNxMVckEcJhGQeIksqqsarscSHJtT559bNgyiM4r14dViQ66su7bb6qDVeBt0K7z3glXI1dHVck1Zgg== -expo-localization@55.0.12: - version "55.0.12" - resolved "https://registry.yarnpkg.com/expo-localization/-/expo-localization-55.0.12.tgz#54dd2c2472c1244f3dc1cc4d89b90db2e0a13602" - integrity sha512-HggkFgTeiIIXpus9CMSO5d/YPxT2vhQXOO34bAQp1vNdByKgIU1k8ILsAlkPwJN4qnvGums+zdMakLO26aH+vA== +expo-localization@55.0.13: + version "55.0.13" + resolved "https://registry.yarnpkg.com/expo-localization/-/expo-localization-55.0.13.tgz#fd999125aaebaae19545f536c8f246dcffdff9b7" + integrity sha512-fXiEUUihIrXmAEzoneaTOFcQ7TKmr25RR/ymrB/MvYTVnmevFA1zY2KI0VSiXY+NKKjZ8mG65YSn1wh4gEYKxA== dependencies: rtl-detect "^1.0.2" -expo-location@55.1.7: - version "55.1.7" - resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-55.1.7.tgz#25a0c4a7901c3254af2990be5078b31c753ade0e" - integrity sha512-E8Qib2yAHTU7WZM/Qrmfx7G/OvMAnjeIyinyKK6x/sFxm+nBu/hKwGEp2BIW9ubM1tBjaId7S0WSAaZr3OPzHg== +expo-location@55.1.8: + version "55.1.8" + resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-55.1.8.tgz#031a7e94e95cb91bbe42c87e6661070acf60be21" + integrity sha512-mEExFf84nmWLwi14GFfUsFLrCm10gbcqFn9EPXpuruQ28YMtJWgCD+jJtESYPQkYF44N21fVok3T28fLuCqydA== dependencies: - "@expo/image-utils" "^0.8.12" + "@expo/image-utils" "^0.8.13" -expo-mail-composer@55.0.12: - version "55.0.12" - resolved "https://registry.yarnpkg.com/expo-mail-composer/-/expo-mail-composer-55.0.12.tgz#4dcd32667c46aa6353eb24d86d26ca7875a97e20" - integrity sha512-gjtqdtLVNwuPPWhtDm4AZ/4220j0VfiiNfy6tMeYiV1sCM1eD8Od7tR4YeHK51Fe/PHLzRzbJEdGDLW/n2TA2A== - -expo-media-library@55.0.13: +expo-mail-composer@55.0.13: version "55.0.13" - resolved "https://registry.yarnpkg.com/expo-media-library/-/expo-media-library-55.0.13.tgz#c51590b2cffee82fe10b4c8e9da289c357f51ca5" - integrity sha512-kEEnxr4iwIDIYwWdsBzJQokiKKBE8o7TYU+klBtcBpYm0oQCTKjoe884hL9P3CdjNFg+BcpmEVHwDADshEjCvw== + resolved "https://registry.yarnpkg.com/expo-mail-composer/-/expo-mail-composer-55.0.13.tgz#2b5561e9ec34f68c41c155b7f3c10471b972098c" + integrity sha512-XMcP5uosKy1vW63c+8/Gb6FA5VU3W6UQpZGkDNRZQtFj8+F4GGneZVh07wQlFXW1FYvRR+yGfQgzDDLgRdTm8w== -expo-modules-autolinking@55.0.15: - version "55.0.15" - resolved "https://registry.yarnpkg.com/expo-modules-autolinking/-/expo-modules-autolinking-55.0.15.tgz#3f82d801ebe6fdcffc264607ba6c35aec0e62eb0" - integrity sha512-89WNHlSo+hmH8O7sEHDgOpb3MyHON/NmDIl+LiEGMiHHHSrSbU10DSglYWKUk68yjQebxkmfzXcEghbous3LcA== +expo-media-library@55.0.14: + version "55.0.14" + resolved "https://registry.yarnpkg.com/expo-media-library/-/expo-media-library-55.0.14.tgz#32bd243cfde504ba5255a45c4a7fd0af4f9c55db" + integrity sha512-S84myNFYinf6Yu492/hA7BV+mRURUmSkLR9GpZOgJ0SunmG3/7S/R6Bj0yx8TcbLToObciya+BejC8juPuyoBg== + +expo-modules-autolinking@55.0.17: + version "55.0.17" + resolved "https://registry.yarnpkg.com/expo-modules-autolinking/-/expo-modules-autolinking-55.0.17.tgz#d514023b1518d85d8e4e342e315795d0f5382fca" + integrity sha512-VhlEVGnP+xBjfSKDKNN7GAPKN2whIfV08jsZvNj7UGyJWpZYiO6Emx1FLP5xd1+JZVpIrt/kxR641kdcPo7Ehw== dependencies: - "@expo/require-utils" "^55.0.3" + "@expo/require-utils" "^55.0.4" "@expo/spawn-async" "^1.7.2" chalk "^4.1.0" commander "^7.2.0" -expo-modules-core@55.0.21: - version "55.0.21" - resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-55.0.21.tgz#4322ad5fb50cff8f850d9f0cfdd1ee21dc7aa7e6" - integrity sha512-JGMREOmVHeHR3FdHqYWFtwJt2o6w9cXOCZ7al3x4cCcM9ihMpleze44SDYh3yfPo+BgWT3HCbpTunIsfNMMyPA== +expo-modules-core@55.0.22: + version "55.0.22" + resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-55.0.22.tgz#b74fa6a3a235e3a895f406525da8a10b979a31b7" + integrity sha512-NC5GyvCHvnOvi5MtgLv68oUSrRP/0UORGzU/MX+7BIA8ctgBPxKSjPXPSfhwk3gMzj7eHBhYwlu0HJsIEnVd9A== dependencies: invariant "^2.2.4" -expo-screen-capture@55.0.12: - version "55.0.12" - resolved "https://registry.yarnpkg.com/expo-screen-capture/-/expo-screen-capture-55.0.12.tgz#52563dc3b60cbb531be5de52b57566999a27de41" - integrity sha512-FtMEW4U4CshC69NGF9Phe1jC9EGw85CK5BMH0fKiMuTljbA3Y3eidMCuTydvhiJjHShOtD41x1bZqg2gfvLRdQ== +expo-screen-capture@55.0.13: + version "55.0.13" + resolved "https://registry.yarnpkg.com/expo-screen-capture/-/expo-screen-capture-55.0.13.tgz#4339d883fbe225c62083d28114b105316def1d90" + integrity sha512-wdSktx6hHJz8wiP1c96gNRc5TOVfBA6wd7GJJlADvLVL4OVKqfiUQ122Z6L6gBtELoXW23XOS/CB5rvRM8xjVA== expo-server@^55.0.7: version "55.0.7" resolved "https://registry.yarnpkg.com/expo-server/-/expo-server-55.0.7.tgz#51bdb292daa87194ce19fe163e32d34b704d50b9" integrity sha512-Cc1btFyPsD9P4DT2xd1pG/uR96TLVMx0W+dPm9Gjk1uDV9xuzvMcUsY7nf9bt4U5pGyWWkCXmPJcKwWfdl51Pw== -expo-sms@55.0.12: - version "55.0.12" - resolved "https://registry.yarnpkg.com/expo-sms/-/expo-sms-55.0.12.tgz#53fbdf7f84bda25f24c92c6b32ee14bf32b50beb" - integrity sha512-8OBWdaUK+nLoRx8+HWvuOvmKbk/X62R0SgBWD/o+9TMNem7OCTq+WYyuLGGG+CrLvJVypRyeJVfmDOk3Rf+5vg== - -expo-task-manager@55.0.13: +expo-sms@55.0.13: version "55.0.13" - resolved "https://registry.yarnpkg.com/expo-task-manager/-/expo-task-manager-55.0.13.tgz#e182d9dae02a14fafd754ff376878d2a2f987c65" - integrity sha512-lGtDHolhm72+whgm0+QymDP7hDpI0fJOAuT2oZkvQi1sCPp2b14idkEadC7KdrCt+2sG9OMtiyXbOp0ohNNFow== + resolved "https://registry.yarnpkg.com/expo-sms/-/expo-sms-55.0.13.tgz#01d6408e473b6a018346cefd566a7845210a342a" + integrity sha512-GJrVxt+Rwc9pbzZoPWSKhFEfKbDF4GbVdClpRj4e9KroGEzeIuJYk/h9cL16oBLHVUKbQe7k2Dc0lohI22eKOQ== + +expo-task-manager@55.0.14: + version "55.0.14" + resolved "https://registry.yarnpkg.com/expo-task-manager/-/expo-task-manager-55.0.14.tgz#69f44a8d7057da2d706203fbf5ed4fe2683ab9a5" + integrity sha512-KWee8OhusVJYkhCfFtZ1AqsjkbnTqErgcV595CY0mUQZK7Phhe1qJsv9xiIpxTI0nbOS/248nvm/FcVOrZPaPw== dependencies: unimodules-app-loader "~55.0.4" -expo-video@55.0.14: - version "55.0.14" - resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-55.0.14.tgz#1648e465e10731122c1fe02322ed446c645872b4" - integrity sha512-/dBtnL7z3E6zykMTJnmOPZjyiubK6OzcFaTKPP3yP5KJE2Xf5F6N6kH7e0PvmesUJXJxoB6FNs/N1ZoCgvaqSg== +expo-video@55.0.15: + version "55.0.15" + resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-55.0.15.tgz#577236279aec50375d48687febaf2fbf9852fa47" + integrity sha512-4GEEiTH5hGsyEt7Chsiv8IS4ioYuEJ4Wc+tjbf8NiGvAw0bQquN41zWmYLnwgzPoU3tCr8SaACgEvJRc3+FcWw== -expo@55.0.12: - version "55.0.12" - resolved "https://registry.yarnpkg.com/expo/-/expo-55.0.12.tgz#c19c373c03170f66057659d9bea2251b26791205" - integrity sha512-O3lp+HOydF4LUSbi9gF1c+ly4FkLB9FSyJZ1Zatt12oClraB2FUe/W8J4tq5ERqKLeRzsrVVt319hMTQgwNEUQ== +expo@55.0.15: + version "55.0.15" + resolved "https://registry.yarnpkg.com/expo/-/expo-55.0.15.tgz#d1caebf5ace3aef894a209b3f83b08cecdea11fb" + integrity sha512-sHIvqG477UU1jZHhaexXbUgsU7y+xnYZqDW1HrUkEBYiuEb5lobvWLmwea76EBVkityQx46UDtepFtarpUJQqQ== dependencies: "@babel/runtime" "^7.20.0" - "@expo/cli" "55.0.22" - "@expo/config" "~55.0.13" + "@expo/cli" "55.0.24" + "@expo/config" "~55.0.15" "@expo/config-plugins" "~55.0.8" "@expo/devtools" "55.0.2" "@expo/fingerprint" "0.16.6" - "@expo/local-build-cache-provider" "55.0.9" + "@expo/local-build-cache-provider" "55.0.11" "@expo/log-box" "55.0.10" "@expo/metro" "~55.0.0" - "@expo/metro-config" "55.0.14" + "@expo/metro-config" "55.0.16" "@expo/vector-icons" "^15.0.2" "@ungap/structured-clone" "^1.3.0" - babel-preset-expo "~55.0.16" - expo-asset "~55.0.13" - expo-constants "~55.0.12" - expo-file-system "~55.0.15" + babel-preset-expo "~55.0.17" + expo-asset "~55.0.15" + expo-constants "~55.0.14" + expo-file-system "~55.0.16" expo-font "~55.0.6" expo-keep-awake "~55.0.6" - expo-modules-autolinking "55.0.15" - expo-modules-core "55.0.21" + expo-modules-autolinking "55.0.17" + expo-modules-core "55.0.22" pretty-format "^29.7.0" react-refresh "^0.14.2" whatwg-url-minimum "^0.1.1" @@ -6703,10 +6702,10 @@ fdir@^6.5.0: resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== -fetch-nodeshim@^0.4.6: - version "0.4.8" - resolved "https://registry.yarnpkg.com/fetch-nodeshim/-/fetch-nodeshim-0.4.8.tgz#e87df7d8f85c6409903dac402aaf9465e36b5165" - integrity sha512-YW5vG33rabBq6JpYosLNoXoaMN69/WH26MeeX2hkDVjN6UlvRGq3Wkazl9H0kisH95aMu/HtHL64JUvv/+Nv/g== +fetch-nodeshim@^0.4.10: + version "0.4.10" + resolved "https://registry.yarnpkg.com/fetch-nodeshim/-/fetch-nodeshim-0.4.10.tgz#0bde71d3c87fcbd87e037dd498e743d9361b0f71" + integrity sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w== file-entry-cache@^8.0.0: version "8.0.0" @@ -8669,10 +8668,10 @@ kleur@^3.0.3: resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== -lan-network@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/lan-network/-/lan-network-0.2.0.tgz#2d0858ef8f909dff62f17e868bb31786def30a64" - integrity sha512-EZgbsXMrGS+oK+Ta12mCjzBFse+SIewGdwrSTr5g+MSymnjpox2x05ceI20PQejJOFvOgzcXrfDk/SdY7dSCtw== +lan-network@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/lan-network/-/lan-network-0.2.1.tgz#e4764a0d17f6bd1f2794c838fa219526a1b756f8" + integrity sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A== launch-editor@^2.6.1, launch-editor@^2.9.1: version "2.13.1" @@ -9507,7 +9506,7 @@ minimatch@^10.0.1, minimatch@^10.1.1, minimatch@^10.2.2: dependencies: brace-expansion "^5.0.2" -minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2, minimatch@^3.1.3: +minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2, minimatch@^3.1.5: version "3.1.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== @@ -10309,10 +10308,10 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -prettier@3.8.1: - version "3.8.1" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" - integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== +prettier@3.8.2: + version "3.8.2" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.2.tgz#4f52e502193c9aa5b384c3d00852003e551bbd9f" + integrity sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q== pretty-error@^4.0.0: version "4.0.0" @@ -10567,10 +10566,10 @@ react-native-is-edge-to-edge@^1.3.1: "react-native-kb@file:../rnmodules/react-native-kb": version "0.1.1" -react-native-keyboard-controller@1.21.4: - version "1.21.4" - resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.4.tgz#9ea9687c4b1a7e9856d796abd867449713a83da5" - integrity sha512-j1bS2ZKo+ahexnhTYJ+GxXWeMHUylY7AM0h3i0y+XgxcCHW05DpJJQcSvMCmDtMrMhRUtalLZRDCGvuh/aUPZQ== +react-native-keyboard-controller@1.21.5: + version "1.21.5" + resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.5.tgz#563aabb7e9ce8dbe2a0dd5f949883ba81620b6c0" + integrity sha512-wxR+vpJ+2g6QMQCP1mRQKySDUietf5xLntZ76cUNHOGsjyqk6LtznXwHBG9YsR9E/b2IrHXISylwqPnIit6Y6A== dependencies: react-native-is-edge-to-edge "^1.2.1" @@ -12038,15 +12037,15 @@ typed-array-length@^1.0.7: possible-typed-array-names "^1.0.0" reflect.getprototypeof "^1.0.6" -typescript-eslint@8.58.0: - version "8.58.0" - resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.58.0.tgz#5758b1b68ae7ec05d756b98c63a1f6953a01172b" - integrity sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA== +typescript-eslint@8.58.1: + version "8.58.1" + resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.58.1.tgz#e765cbfea5774dcb4b1473e5e77a46254f309b32" + integrity sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg== dependencies: - "@typescript-eslint/eslint-plugin" "8.58.0" - "@typescript-eslint/parser" "8.58.0" - "@typescript-eslint/typescript-estree" "8.58.0" - "@typescript-eslint/utils" "8.58.0" + "@typescript-eslint/eslint-plugin" "8.58.1" + "@typescript-eslint/parser" "8.58.1" + "@typescript-eslint/typescript-estree" "8.58.1" + "@typescript-eslint/utils" "8.58.1" typescript@6.0.2: version "6.0.2" @@ -12374,10 +12373,10 @@ webpack-virtual-modules@^0.6.2: resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz#057faa9065c8acf48f24cb57ac0e77739ab9a7e8" integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ== -webpack@5.105.4: - version "5.105.4" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.105.4.tgz#1b77fcd55a985ac7ca9de80a746caffa38220169" - integrity sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw== +webpack@5.106.1: + version "5.106.1" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.106.1.tgz#0a3eeb43a50e4f67fbecd206e1e6fc2c89fc2b6f" + integrity sha512-EW8af29ak8Oaf4T8k8YsajjrDBDYgnKZ5er6ljWFJsXABfTNowQfvHLftwcepVgdz+IoLSdEAbBiM9DFXoll9w== dependencies: "@types/eslint-scope" "^3.7.7" "@types/estree" "^1.0.8"