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 --- 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/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. diff --git a/go/chat/search/deadlock_test.go b/go/chat/search/deadlock_test.go new file mode 100644 index 000000000000..773aab687308 --- /dev/null +++ b/go/chat/search/deadlock_test.go @@ -0,0 +1,234 @@ +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") + } + }) +} diff --git a/go/chat/search/indexer.go b/go/chat/search/indexer.go index b3b2417f34b1..c1e65515ed4d 100644 --- a/go/chat/search/indexer.go +++ b/go/chat/search/indexer.go @@ -868,8 +868,12 @@ 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) + 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/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 } diff --git a/plans/chat-refactor.md b/plans/chat-refactor.md deleted file mode 100644 index f331fa551655..000000000000 --- a/plans/chat-refactor.md +++ /dev/null @@ -1,130 +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. -- [ ] Audit per-message render-time computation and decide whether values that are only consumed by one caller should be stored in derived message state instead of recomputed during render. - -Primary files: - -- `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 - -- [ ] 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. -- [ ] Keep scroll position and centered-message behavior unchanged. - -Primary files: - -- `shared/chat/conversation/list-area/index.native.tsx` -- `shared/chat/conversation/messages/text/wrapper.tsx` -- `shared/chat/conversation/recycle-type-context.tsx` - -### 6. Measurement And Regression Guardrails - -- [ ] Add or improve lightweight profiling hooks where they help compare before/after behavior. -- [ ] Define a manual verification checklist for initial thread mount, new incoming message, placeholder resolution, reactions, edits, and centered jumps. -- [ ] Capture follow-up profiling notes after each landed workstream. - -Primary files: - -- `shared/chat/conversation/list-area/index.native.tsx` -- `shared/chat/conversation/list-area/index.desktop.tsx` -- `shared/perf/*` - -## Recommended Order - -1. Workstream 1: Row Renderer Boundary -2. Workstream 2: Incremental Derived Message Metadata -3. Workstream 3: Row Subscription Consolidation -4. Workstream 4: Split Volatile UI State From Message Data -5. Workstream 5: List Data Stability And Recycling -6. Workstream 6: Measurement And Regression Guardrails - -## Clean Context Prompts - -Use these as narrow follow-up task starts: - -1. "Implement Workstream 1 from `PLAN.md`: introduce a row-level renderer boundary and remove the native placeholder redraw hack." -2. "Implement Workstream 2 from `PLAN.md`: make convo-store derived message metadata incremental instead of full-thread recompute." -3. "Implement Workstream 3 from `PLAN.md`: consolidate message row subscriptions so row children mostly receive props instead of subscribing directly." -4. "Implement Workstream 4 from `PLAN.md`: split volatile convo UI state from message graph state." -5. "Implement Workstream 5 from `PLAN.md`: stabilize list data and recycling after the earlier refactors." -6. "Implement Workstream 6 from `PLAN.md`: add measurement hooks and a regression checklist for the chat message perf cleanup." 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/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/conversation/input-area/normal/index.tsx b/shared/chat/conversation/input-area/normal/index.tsx index 3c17dbc095e4..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,8 +111,8 @@ 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 + 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, 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 3c52761c8627..52d94423a5b2 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' @@ -20,6 +21,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 +29,14 @@ let markedInitiallyLoaded = false export const DEBUGDump = () => {} +const useInvertedMessageOrdinals = (messageOrdinals?: ReadonlyArray) => { + const source = messageOrdinals ?? noOrdinals + return React.useMemo(() => (source.length > 1 ? [...source].reverse() : source), [source]) +} + const useScrolling = (p: { centeredOrdinal: T.Chat.Ordinal - messageOrdinals: Array + messageOrdinals: ReadonlyArray conversationIDKey: T.Chat.ConversationIDKey listRef: React.RefObject |*/ FlatList | null> }) => { @@ -95,20 +102,26 @@ 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 = [...(_messageOrdinals ?? [])].reverse() + const messageOrdinals = useInvertedMessageOrdinals(listData.messageOrdinals) const listRef = React.useRef |*/ FlatList | null>(null) const {markInitiallyLoadedThreadAsRead} = Hooks.useActions({conversationIDKey}) @@ -116,9 +129,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,19 +144,16 @@ const ConversationList = function ConversationList() { const numOrdinals = messageOrdinals.length - const getItemType = (ordinal: T.Chat.Ordinal, idx: number) => { - 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 - } + const getItemType = React.useCallback( + (ordinal: T.Chat.Ordinal) => { + if (!ordinal) { + return 'null' + } + const convoState = Chat.getConvoState(conversationIDKey) + return convoState.rowRecycleTypeMap.get(ordinal) ?? convoState.messageTypeMap.get(ordinal) ?? 'text' + }, + [conversationIDKey] + ) const {scrollToCentered, scrollToBottom, onEndReached} = useScrolling({ centeredOrdinal, @@ -253,6 +262,7 @@ const ConversationList = function ConversationList() { (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 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', diff --git a/shared/chat/conversation/thread-search-route.ts b/shared/chat/conversation/thread-search-route.ts index d36dc0e84353..e09128aa450f 100644 --- a/shared/chat/conversation/thread-search-route.ts +++ b/shared/chat/conversation/thread-search-route.ts @@ -9,7 +9,8 @@ export type ThreadSearchRouteProps = { threadSearch?: ThreadSearchRoute } -export const useThreadSearchRoute = () => { +export const useThreadSearchRoute = (): ThreadSearchRoute | undefined => { const route = useRoute | RootRouteProps<'chatRoot'>>() - return getRouteParamsFromRoute<'chatConversation' | 'chatRoot'>(route)?.threadSearch + const params = getRouteParamsFromRoute<'chatConversation' | 'chatRoot'>(route) + return params && typeof params === 'object' && '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 4ca66af4ca3b..f739496aef42 100644 --- a/shared/chat/inbox-and-conversation-header.tsx +++ b/shared/chat/inbox-and-conversation-header.tsx @@ -2,19 +2,15 @@ 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 {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' 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() @@ -74,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() @@ -151,14 +148,17 @@ const Header2 = () => { const leftSide = ( - {Kb.Styles.isMobile ? null : ( + {C.isTablet ? ( + {headerPortalContent} + ) : !Kb.Styles.isMobile ? ( - - - +
setInboxHeaderPortalNode(node)} + /> - )} - + ) : null} + {!C.isElectron && !C.isTablet && } ) @@ -358,6 +358,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-shared.tsx b/shared/chat/inbox-and-conversation-shared.tsx new file mode 100644 index 000000000000..0a932f563c2f --- /dev/null +++ b/shared/chat/inbox-and-conversation-shared.tsx @@ -0,0 +1,76 @@ +// 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} +} + +export type ChatRootRouteParams = InboxAndConversationProps + +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..f410c722ffd8 --- /dev/null +++ b/shared/chat/inbox-and-conversation.desktop.tsx @@ -0,0 +1,34 @@ +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 useInboxHeaderPortal from './inbox/use-header-portal' +import {useInboxSearch} from './inbox/use-inbox-search' + +export default function InboxAndConversationDesktop(props: InboxAndConversationProps) { + const search = useInboxSearch() + const headerPortal = useInboxHeaderPortal(search) + const leftPane = ( + + + + ) + + return ( + <> + {headerPortal} + + + ) +} + +const styles = Kb.Styles.styleSheetCreate( + () => + ({ + 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..75bc3fc293b1 --- /dev/null +++ b/shared/chat/inbox-and-conversation.native.tsx @@ -0,0 +1,19 @@ +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() + const headerPortal = useInboxHeaderPortal(search) + + return ( + <> + {headerPortal} + } + /> + + ) +} diff --git a/shared/chat/inbox-and-conversation.tsx b/shared/chat/inbox-and-conversation.tsx index 9ecd83505100..e18500a7c574 100644 --- a/shared/chat/inbox-and-conversation.tsx +++ b/shared/chat/inbox-and-conversation.tsx @@ -1,79 +1,11 @@ -// 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 Desktop from './inbox-and-conversation.desktop' +import Native from './inbox-and-conversation.native' +import type {InboxAndConversationProps} from './inbox-and-conversation-shared' -type Props = ThreadSearchRouteProps & { - conversationIDKey?: T.Chat.ConversationIDKey - infoPanel?: {tab?: Panel} +function InboxAndConversation(props: InboxAndConversationProps) { + return C.isMobile ? : } -function InboxAndConversation(props: Props) { - const conversationIDKey = props.conversationIDKey ?? Chat.noConversationIDKey - const inboxSearch = Chat.useChatState(s => s.inboxSearch) - 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 && inboxSearch ? ( - - ) : ( - - )} - - - - {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 -) - export default InboxAndConversation +export type {ChatRootRouteParams, InboxAndConversationProps} from './inbox-and-conversation-shared' diff --git a/shared/chat/inbox-search/index.tsx b/shared/chat/inbox-search/index.tsx index edef226c2db8..1ef40326cb50 100644 --- a/shared/chat/inbox-search/index.tsx +++ b/shared/chat/inbox-search/index.tsx @@ -11,8 +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' - -type OwnProps = {header?: React.ReactElement | null} +import { + inboxSearchMaxTextMessages, + inboxSearchPreviewSectionSize, + type InboxSearchController, + type InboxSearchVisibleResultCounts, +} from '../inbox/use-inbox-search' + +type OwnProps = { + header?: React.ReactElement | null + search: Pick +} type NameResult = { conversationIDKey: T.Chat.ConversationIDKey @@ -42,19 +51,10 @@ type OpenTeamResult = { type Item = NameResult | TextResult | BotResult | OpenTeamResult -const emptySearch = Chat.makeInboxSearchInfo() - export default function InboxSearchContainer(ownProps: OwnProps) { - const {_inboxSearch, toggleInboxSearch, inboxSearchSelect} = Chat.useChatState( - C.useShallow(s => ({ - _inboxSearch: s.inboxSearch ?? emptySearch, - inboxSearchSelect: s.dispatch.inboxSearchSelect, - toggleInboxSearch: s.dispatch.toggleInboxSearch, - })) - ) - const onCancel = () => { - toggleInboxSearch(false) - } + const { + search: {searchInfo: _inboxSearch, selectResult, setVisibleResultCounts}, + } = ownProps const navigateAppend = C.Router2.navigateAppend const onInstallBot = (username: string) => { navigateAppend({name: 'chatInstallBotPick', params: {botUsername: username}}) @@ -64,7 +64,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 @@ -85,10 +85,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: Section['renderItem'] = ({item, index, section}) => { if (item.type !== 'openTeam') return null + const fullSection = section as Section const {hit} = item + const realIndex = index + fullSection.indexOffset return ( ) } - const renderBots = (h: {item: Item; index: number}) => { - const {item, index} = h + const renderBots: Section['renderItem'] = ({item, index, section}) => { if (item.type !== 'bot') return null + const fullSection = section as Section + const realIndex = index + fullSection.indexOffset return ( - + ) } @@ -229,7 +237,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 +247,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 +257,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 @@ -286,11 +293,33 @@ 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 visibleResultCounts = React.useMemo( + () => ({ + bots: botsResults.length, + names: nameResults.length, + openTeams: openTeamsResults.length, + text: textCollapsed || nameResultsUnread ? 0 : _textResults.length, + }), + [ + botsResults.length, + nameResults.length, + nameResultsUnread, + openTeamsResults.length, + textCollapsed, + _textResults.length, + ] + ) + + React.useLayoutEffect(() => { + setVisibleResultCounts(visibleResultCounts) + }, [setVisibleResultCounts, visibleResultCounts]) + const nameSection: Section = { data: nameResults, indexOffset: 0, @@ -320,7 +349,7 @@ export default function InboxSearchContainer(ownProps: OwnProps) { onCollapse: toggleCollapseBots, onSelect: selectBot, renderHeader: renderBotsHeader, - renderItem: renderBots as Section['renderItem'], + renderItem: renderBots, status: botsStatus, title: botsResultsSuggested ? 'Suggested bots' : 'Featured bots', } @@ -347,6 +376,7 @@ export default function InboxSearchContainer(ownProps: OwnProps) { section.renderHeader(section)} keyboardShouldPersistTaps="handled" diff --git a/shared/chat/inbox/defer-loading.tsx b/shared/chat/inbox/defer-loading.tsx deleted file mode 100644 index 58b0f01795bd..000000000000 --- a/shared/chat/inbox/defer-loading.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import * as React from 'react' -import Inbox from '.' -import {useIsFocused} from '@react-navigation/core' - -// keep track of this even on unmount, else if you background / foreground you'll lose it -let _everFocused = false - -export default function Deferred() { - const [visible, setVisible] = React.useState(_everFocused) - const isFocused = useIsFocused() - 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/filter-row.tsx b/shared/chat/inbox/filter-row.tsx index a373f5b1e26e..4d693383eb52 100644 --- a/shared/chat/inbox/filter-row.tsx +++ b/shared/chat/inbox/filter-row.tsx @@ -1,39 +1,31 @@ import * as C from '@/constants' -import * as Chat from '@/stores/chat' import * as React from 'react' import * as Kb from '@/common-adapters' 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 = Chat.useChatState(s => !!s.inboxSearch) - const appendNewChatBuilder = C.Router2.appendNewChatBuilder - const toggleInboxSearch = Chat.useChatState(s => s.dispatch.toggleInboxSearch) - const onStartSearch = () => { - toggleInboxSearch(true) - } - const onStopSearch = () => { - toggleInboxSearch(false) - } + 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() @@ -66,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) { @@ -86,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)'} @@ -107,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} diff --git a/shared/chat/inbox/header-portal-state.tsx b/shared/chat/inbox/header-portal-state.tsx new file mode 100644 index 000000000000..0d9a2e2e5e5b --- /dev/null +++ b/shared/chat/inbox/header-portal-state.tsx @@ -0,0 +1,42 @@ +import * as React from 'react' + +let portalNode: HTMLElement | null = null +let portalContent: React.ReactElement | 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 useInboxHeaderPortalNode = () => + React.useSyncExternalStore(subscribe, (): HTMLElement | null => portalNode, (): HTMLElement | null => null) + +export const useInboxHeaderPortalContent = () => + React.useSyncExternalStore( + subscribe, + (): React.ReactElement | null => portalContent, + (): React.ReactElement | null => null + ) + +export const setInboxHeaderPortalNode = (node: HTMLElement | null) => { + if (portalNode === node) { + return + } + portalNode = node + notify() +} + +export const setInboxHeaderPortalContent = (content: React.ReactElement | null) => { + if (portalContent === content) { + return + } + portalContent = content + notify() +} diff --git a/shared/chat/inbox/index.d.ts b/shared/chat/inbox/index.d.ts index 26fe5bcdd677..40f155cf43b3 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..8f74175f909b 100644 --- a/shared/chat/inbox/index.desktop.tsx +++ b/shared/chat/inbox/index.desktop.tsx @@ -13,6 +13,7 @@ import { } from './list-helpers' import BigTeamsDivider from './row/big-teams-divider' import BuildTeam from './row/build-team' +import InboxSearch from '../inbox-search' import TeamsDivider from './row/teams-divider' import UnreadShortcut from './unread-shortcut' import * as Kb from '@/common-adapters' @@ -20,6 +21,8 @@ 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 {useInboxSearch} from './use-inbox-search' import {useInboxState} from './use-inbox-state' import './inbox.css' @@ -193,10 +196,24 @@ 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) +type ControlledInboxProps = { + conversationIDKey?: T.Chat.ConversationIDKey + search: InboxSearchController +} + +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 const {toggleSmallTeamsExpanded, selectedConversationIDKey, onUntrustedInboxVisible} = inbox const {setInboxNumSmallRows, allowShowFloatingButton} = inbox @@ -276,41 +293,62 @@ 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 && ( + + )}
) } +function Inbox(props: InboxProps) { + return props.search ? ( + + ) : ( + + ) +} + const styles = Kb.Styles.styleSheetCreate( () => ({ + body: { + flex: 1, + minHeight: 0, + width: '100%', + }, container: Kb.Styles.platformStyles({ isElectron: { backgroundColor: Kb.Styles.globalColors.blueGrey, @@ -404,6 +442,7 @@ const styles = Kb.Styles.styleSheetCreate( flex: 1, height: '100%', position: 'relative' as const, + width: '100%', }, spacer: { backgroundColor: Kb.Styles.globalColors.blueGrey, diff --git a/shared/chat/inbox/index.native.tsx b/shared/chat/inbox/index.native.tsx index 7baef49ca501..10b932d1dad9 100644 --- a/shared/chat/inbox/index.native.tsx +++ b/shared/chat/inbox/index.native.tsx @@ -15,6 +15,8 @@ import {Alert} from 'react-native' 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' @@ -49,15 +51,28 @@ const NoChats = (props: {onNewChat: () => void}) => ( ) -const HeadComponent = +type InboxProps = { + conversationIDKey?: T.Chat.ConversationIDKey + search?: InboxSearchController +} + +type ControlledInboxProps = { + conversationIDKey?: T.Chat.ConversationIDKey + search: InboxSearchController +} -type InboxProps = {conversationIDKey?: T.Chat.ConversationIDKey} +function InboxWithSearch(props: {conversationIDKey?: T.Chat.ConversationIDKey}) { + const search = useInboxSearch() + return +} -function Inbox(p: InboxProps) { - const inbox = useInboxState(p.conversationIDKey) +function InboxBody(p: ControlledInboxProps) { + 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 = C.isTablet ? null : const listRef = React.useRef(null) const {showFloating, showUnread, unreadCount, scrollToUnread, applyUnreadAndFloating} = @@ -150,12 +165,12 @@ function Inbox(p: InboxProps) { {isSearching ? ( - + ) : ( + ) : ( + + ) +} + const NoRowsBuildTeam = () => { const isLoading = C.useWaitingState(s => [...s.counts.keys()].some(k => k.startsWith('chat:'))) return isLoading ? null : diff --git a/shared/chat/inbox/search-row.tsx b/shared/chat/inbox/search-row.tsx index 69f2c5308e26..b1848f022cd1 100644 --- a/shared/chat/inbox/search-row.tsx +++ b/shared/chat/inbox/search-row.tsx @@ -1,13 +1,24 @@ -import * as React from 'react' 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' -type OwnProps = {headerContext: 'chat-header' | 'inbox-header'} +type OwnProps = { + search: Pick< + InboxSearchController, + 'cancelSearch' | 'isSearching' | 'moveSelectedIndex' | 'query' | 'selectResult' | 'setQuery' | 'startSearch' + > + forceShowFilter?: boolean + showSearch: boolean + showNewChatButton?: boolean +} export default function InboxSearchRow(ownProps: OwnProps) { - const {headerContext} = ownProps + const {forceShowFilter, search, showNewChatButton, showSearch} = ownProps + const {cancelSearch, isSearching, moveSelectedIndex, query, selectResult, setQuery, startSearch} = search const chatState = Chat.useChatState( C.useShallow(s => { const hasLoadedEmptyInbox = @@ -16,54 +27,56 @@ 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 showStartNewChat = !showNewChatButton && !C.isMobile && !isSearching && showEmptyInbox + const showFilter = !!forceShowFilter || 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 filter = showFilter ? ( + moveSelectedIndex(false)} + onSelectDown={() => moveSelectedIndex(true)} + onEnsureSelection={selectResult} + onQueryChanged={setQuery} + query={query} + showSearch={showSearch} + startSearch={startSearch} + /> + ) : null - const [lastSearching, setLastSearching] = React.useState(isSearching) - if (lastSearching !== isSearching) { - setLastSearching(isSearching) - if (!isSearching) { - setQuery('') - } + if (showNewChatButton) { + return ( + + {filter} + + + ) } - const showNewChat = headerContext === 'chat-header' - const showSearch = headerContext === 'chat-header' ? !C.isTablet : C.isMobile - return ( <> - {!!showStartNewChat && ( - - )} - {!!showFilter && ( - inboxSearchMoveSelectedIndex(false)} - onSelectDown={() => inboxSearchMoveSelectedIndex(true)} - onEnsureSelection={inboxSearchSelect} - onQueryChanged={onQueryChanged} - query={query} - showNewChat={showNewChat} - showSearch={showSearch} - /> - )} + {!!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.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 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..7f35bee8fe87 --- /dev/null +++ b/shared/chat/inbox/use-header-portal.desktop.tsx @@ -0,0 +1,11 @@ +import {createPortal} from 'react-dom' +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 +} 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..513c295b6d2f --- /dev/null +++ b/shared/chat/inbox/use-header-portal.native.tsx @@ -0,0 +1,30 @@ +import * as C from '@/constants' +import * as React from 'react' +import SearchRow from './search-row' +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 + } + return () => { + setInboxHeaderPortalContent(null) + } + }, []) + + return null +} diff --git a/shared/chat/inbox/use-inbox-search.test.tsx b/shared/chat/inbox/use-inbox-search.test.tsx new file mode 100644 index 000000000000..88f695f2bf2e --- /dev/null +++ b/shared/chat/inbox/use-inbox-search.test.tsx @@ -0,0 +1,51 @@ +/// +import {makeInboxSearchInfo, nextInboxSearchSelectedIndex} from './use-inbox-search' + +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) +}) + +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 new file mode 100644 index 000000000000..70e74ac12b61 --- /dev/null +++ b/shared/chat/inbox/use-inbox-search.tsx @@ -0,0 +1,522 @@ +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 inboxSearchPreviewSectionSize = 3 + +export type InboxSearchVisibleResultCounts = { + bots: number + names: number + openTeams: number + text: number +} + +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 getDefaultVisibleResultCounts = ( + inboxSearch: T.Immutable +): InboxSearchVisibleResultCounts => ({ + bots: Math.min(inboxSearch.botsResults.length, inboxSearchPreviewSectionSize), + names: inboxSearch.nameResults.length || (inboxSearch.nameResultsUnread ? 1 : 0), + 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 + | { + botUsername?: string + conversationIDKey: T.Chat.ConversationIDKey + openTeamName?: string + query?: string + } => { + 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] + const conversationIDKey = maybeNameResults === undefined ? undefined : maybeNameResults.conversationIDKey + if (conversationIDKey) { + return { + conversationIDKey, + query: undefined, + } + } + } else if (selectedIndex < firstBotResultIdx) { + return + } 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, + visibleResultCounts = getDefaultVisibleResultCounts(inboxSearch) +) => { + const {selectedIndex} = inboxSearch + const totalResults = getTotalVisibleResultCount(visibleResultCounts) + 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 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 + setVisibleResultCounts: (visibleResultCounts: InboxSearchVisibleResultCounts) => 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) + 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 + }, [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 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, + [] + ) + + 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 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, visibleResultCountsRef.current), + })) + }, [updateSearchInfo]) + + const selectResult = 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, visibleResultCountsRef.current) + 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 + visibleResultCountsRef.current = getDefaultVisibleResultCounts(next) + setIsSearching(true) + setSearchInfo(next) + runSearch('') + }, [runSearch]) + + React.useEffect(() => { + cancelActiveSearch() + return () => { + invalidateSearch() + } + }, [cancelActiveSearch, invalidateSearch]) + + React.useEffect(() => { + if (mobileAppState === 'background' && isSearchingRef.current) { + clearSearch() + } + }, [clearSearch, mobileAppState]) + + return React.useMemo( + () => ({ + cancelSearch, + isSearching, + moveSelectedIndex, + query: searchInfo.query, + searchInfo, + selectResult, + setQuery, + setVisibleResultCounts, + startSearch, + }), + [ + cancelSearch, + isSearching, + moveSelectedIndex, + searchInfo, + selectResult, + setQuery, + setVisibleResultCounts, + startSearch, + ] + ) +} diff --git a/shared/chat/inbox/use-inbox-state.tsx b/shared/chat/inbox/use-inbox-state.tsx index 9f7c3d3588fa..77261584044b 100644 --- a/shared/chat/inbox/use-inbox-state.tsx +++ b/shared/chat/inbox/use-inbox-state.tsx @@ -5,7 +5,7 @@ import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useIsFocused} from '@react-navigation/core' -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) @@ -17,7 +17,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 +24,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 @@ -44,7 +43,7 @@ export function useInboxState(conversationIDKey?: string) { 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 cf5b5b0c599c..0a92387617a2 100644 --- a/shared/chat/routes.tsx +++ b/shared/chat/routes.tsx @@ -13,6 +13,7 @@ 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')) type ChatBlockingRouteParams = { @@ -36,14 +37,12 @@ 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) return ( - showShareActionSheet?.(url ?? '', '', 'application/pdf')} - /> + showShareActionSheet?.(url ?? '', '', 'application/pdf')} /> ) } @@ -64,16 +63,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 } @@ -105,9 +126,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({ @@ -127,14 +156,14 @@ export const newRoutes = defineRouteMap({ getOptions: inboxAndConvoGetOptions, skipProvider: true, }), - initialParams: {}, + initialParams: emptyChatRootRouteParams, } : { - ...Chat.makeChatScreen(React.lazy(async () => import('./inbox/defer-loading')), { + ...Chat.makeChatScreen(React.lazy(async () => import('./inbox')), { getOptions: inboxGetOptions, skipProvider: true, }), - initialParams: {}, + initialParams: emptyChatRootRouteParams, }, }) @@ -155,7 +184,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 }, } @@ -165,29 +200,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%'}}} @@ -218,19 +267,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( diff --git a/shared/common-adapters/markdown/generate-emoji-parser.mts b/shared/common-adapters/markdown/generate-emoji-parser.mts index af455b6fd34d..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 emojiData from 'emoji-datasource-apple' -import escapeRegExp from 'lodash/escapeRegExp' +import type {EmojiData} from 'emoji-datasource-apple' import prettier from 'prettier' import {fileURLToPath} from 'node:url' @@ -46,7 +45,16 @@ function UTF162JSON(text: string) { return r.join('') } -function genEmojiData() { +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 +} + +function genEmojiData(emojiData: Array) { const emojiIndexByChar: {[key: string]: string} = {} const emojiIndexByName: {[key: string]: string} = {} const emojiLiterals: Array = [] @@ -118,7 +126,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('|') diff --git a/shared/constants/init/index.native.tsx b/shared/constants/init/index.native.tsx index c52c23f5332d..14470d7da011 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,11 @@ const ensureBackgroundTask = () => { lon: pos?.coords.longitude ?? 0, } - useChatState.getState().dispatch.updateLastCoord(coord) + try { + await T.RPCChat.localLocationUpdateRpcPromise({coord}) + } catch (error) { + logger.info('background location update failed: ' + String(error)) + } return Promise.resolve() }) } diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index feb063d639bd..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') } @@ -454,12 +455,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/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/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: diff --git a/shared/stores/chat.tsx b/shared/stores/chat.tsx index 1a342b3cf5ba..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, @@ -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 @@ -2019,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 @@ -2046,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 ? ( ) : ( diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 4a501fc47370..beb259a1bdd5 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 { @@ -2679,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/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/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) 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" 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`