diff --git a/AGENTS.md b/AGENTS.md index eae73a9a0022..f9254958aee9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,3 +12,4 @@ - Components must not mutate Zustand stores directly with `useXState.setState`, `getState()`-based writes, or similar ad hoc store mutation. If a component needs to affect store state, route it through a store dispatch action or move the state out of the store. - During refactors, do not delete existing guards, conditionals, or platform/test-specific behavior unless you have proven they are dead and the user asked for that behavior change. Port checks like `androidIsTestDevice` forward into the new code path instead of silently dropping them. - When addressing PR or review feedback, including bot or lint-style suggestions, do not apply it mechanically. Verify that the reported issue is real in this codebase and that the proposed fix is consistent with repo rules and improves correctness, behavior, or maintainability before making changes. +- When working from a repo plan or checklist such as `PLAN.md`, update the checklist in the same change and mark implemented items done before you finish. diff --git a/go/chat/localizer.go b/go/chat/localizer.go index 2993541a2e86..1deb6bde7bf3 100644 --- a/go/chat/localizer.go +++ b/go/chat/localizer.go @@ -369,7 +369,10 @@ func (s *localizerPipeline) suspend(ctx context.Context) bool { if !s.started { return false } + prevSuspendCount := s.suspendCount s.suspendCount++ + s.Debug(ctx, "suspend: count %d -> %d waiters: %d cancelChs: %d queued: %d", + prevSuspendCount, s.suspendCount, len(s.suspendWaiters), len(s.cancelChs), len(s.jobQueue)) if len(s.cancelChs) == 0 { return false } @@ -405,8 +408,12 @@ func (s *localizerPipeline) resume(ctx context.Context) bool { s.Debug(ctx, "resume: spurious resume call without suspend") return false } + prevSuspendCount := s.suspendCount s.suspendCount-- + s.Debug(ctx, "resume: count %d -> %d waiters: %d cancelChs: %d queued: %d", + prevSuspendCount, s.suspendCount, len(s.suspendWaiters), len(s.cancelChs), len(s.jobQueue)) if s.suspendCount == 0 { + s.Debug(ctx, "resume: releasing waiters: %d", len(s.suspendWaiters)) for _, cb := range s.suspendWaiters { close(cb) } @@ -415,6 +422,12 @@ func (s *localizerPipeline) resume(ctx context.Context) bool { return false } +func (s *localizerPipeline) suspendStats() (suspendCount, waiters, cancelChs, queued int) { + s.Lock() + defer s.Unlock() + return s.suspendCount, len(s.suspendWaiters), len(s.cancelChs), len(s.jobQueue) +} + func (s *localizerPipeline) registerWaiter() chan struct{} { s.Lock() defer s.Unlock() @@ -430,13 +443,15 @@ func (s *localizerPipeline) registerWaiter() chan struct{} { func (s *localizerPipeline) localizeJobPulled(job *localizerPipelineJob, stopCh chan struct{}) { id, cancelCh := s.registerJobPull(job.ctx) defer s.finishJobPull(id) - s.Debug(job.ctx, "localizeJobPulled: pulling job: pending: %d completed: %d", job.numPending(), - job.numCompleted()) + s.Debug(job.ctx, "localizeJobPulled[%s]: pulling job: pending: %d completed: %d", id, + job.numPending(), job.numCompleted()) waitCh := make(chan struct{}) if !globals.IsLocalizerCancelableCtx(job.ctx) { close(waitCh) } else { - s.Debug(job.ctx, "localizeJobPulled: waiting for resume") + suspendCount, waiters, cancelChs, queued := s.suspendStats() + s.Debug(job.ctx, "localizeJobPulled[%s]: waiting for resume suspendCount: %d waiters: %d cancelChs: %d queued: %d", + id, suspendCount, waiters, cancelChs, queued) go func() { <-s.registerWaiter() close(waitCh) @@ -444,9 +459,11 @@ func (s *localizerPipeline) localizeJobPulled(job *localizerPipelineJob, stopCh } select { case <-waitCh: - s.Debug(job.ctx, "localizeJobPulled: resume, proceeding") + suspendCount, waiters, cancelChs, queued := s.suspendStats() + s.Debug(job.ctx, "localizeJobPulled[%s]: resume, proceeding suspendCount: %d waiters: %d cancelChs: %d queued: %d", + id, suspendCount, waiters, cancelChs, queued) case <-stopCh: - s.Debug(job.ctx, "localizeJobPulled: shutting down") + s.Debug(job.ctx, "localizeJobPulled[%s]: shutting down", id) return } s.jobPulled(job.ctx, job) @@ -455,25 +472,25 @@ func (s *localizerPipeline) localizeJobPulled(job *localizerPipelineJob, stopCh defer close(doneCh) if err := s.localizeConversations(job); err == context.Canceled { // just put this right back if we canceled it - s.Debug(job.ctx, "localizeJobPulled: re-enqueuing canceled job") + s.Debug(job.ctx, "localizeJobPulled[%s]: re-enqueuing canceled job", id) s.jobQueue <- job.retry(s.G()) } if job.closeIfDone() { - s.Debug(job.ctx, "localizeJobPulled: all job tasks complete") + s.Debug(job.ctx, "localizeJobPulled[%s]: all job tasks complete", id) } }() select { case <-doneCh: job.cancelFn() case <-cancelCh: - s.Debug(job.ctx, "localizeJobPulled: canceled a live job") + s.Debug(job.ctx, "localizeJobPulled[%s]: canceled a live job", id) job.cancelFn() case <-stopCh: - s.Debug(job.ctx, "localizeJobPulled: shutting down") + s.Debug(job.ctx, "localizeJobPulled[%s]: shutting down", id) job.cancelFn() return } - s.Debug(job.ctx, "localizeJobPulled: job pass complete") + s.Debug(job.ctx, "localizeJobPulled[%s]: job pass complete", id) } func (s *localizerPipeline) localizeLoop(stopCh chan struct{}) { diff --git a/go/chat/server.go b/go/chat/server.go index ed1fe06430cb..8923379987b0 100644 --- a/go/chat/server.go +++ b/go/chat/server.go @@ -171,8 +171,13 @@ func (h *Server) RequestInboxLayout(ctx context.Context, reselectMode chat1.Inbo func (h *Server) RequestInboxUnbox(ctx context.Context, convIDs []chat1.ConversationID) (err error) { ctx = globals.ChatCtx(ctx, h.G(), keybase1.TLFIdentifyBehavior_CHAT_GUI, nil, nil) ctx = globals.CtxAddLocalizerCancelable(ctx) + reqID := libkb.RandStringB64(3) defer h.Trace(ctx, &err, "RequestInboxUnbox")() defer h.PerfTrace(ctx, &err, "RequestInboxUnbox")() + h.Debug(ctx, "RequestInboxUnbox[%s]: begin convs: %d", reqID, len(convIDs)) + defer func() { + h.Debug(ctx, "RequestInboxUnbox[%s]: return err: %v", reqID, err) + }() for _, convID := range convIDs { h.GetPerfLog().CDebugf(ctx, "RequestInboxUnbox: queuing unbox for: %s", convID) h.Debug(ctx, "RequestInboxUnbox: queuing unbox for: %s", convID) @@ -398,14 +403,26 @@ func (h *Server) GetUnreadline(ctx context.Context, arg chat1.GetUnreadlineArg) func (h *Server) GetThreadNonblock(ctx context.Context, arg chat1.GetThreadNonblockArg) (res chat1.NonblockFetchRes, err error) { var identBreaks []keybase1.TLFIdentifyFailure ctx = globals.ChatCtx(ctx, h.G(), arg.IdentifyBehavior, &identBreaks, h.identNotifier) + reqID := libkb.RandStringB64(3) defer h.Trace(ctx, &err, "GetThreadNonblock(%s,%v,%v)", arg.ConversationID, arg.CbMode, arg.Reason)() defer h.PerfTrace(ctx, &err, "GetThreadNonblock(%s,%v,%v)", arg.ConversationID, arg.CbMode, arg.Reason)() defer func() { h.setResultRateLimit(ctx, &res) }() defer func() { err = h.handleOfflineError(ctx, err, &res) }() + defer func() { + h.Debug(ctx, "GetThreadNonblock[%s]: return convID: %s err: %v", reqID, arg.ConversationID, err) + }() defer h.suspendBgConvLoads(ctx)() - defer h.suspendInboxSource(ctx)() + h.Debug(ctx, "GetThreadNonblock[%s]: suspend inbox source begin convID: %s", reqID, arg.ConversationID) + resumeInboxSource := h.suspendInboxSource(ctx) + h.Debug(ctx, "GetThreadNonblock[%s]: suspend inbox source done convID: %s", reqID, arg.ConversationID) + defer func() { + h.Debug(ctx, "GetThreadNonblock[%s]: resume inbox source begin convID: %s", reqID, arg.ConversationID) + resumeInboxSource() + h.Debug(ctx, "GetThreadNonblock[%s]: resume inbox source done convID: %s", reqID, arg.ConversationID) + }() + h.Debug(ctx, "GetThreadNonblock[%s]: begin convID: %s sessionID: %d", reqID, arg.ConversationID, arg.SessionID) uid, err := utils.AssertLoggedInUID(ctx, h.G()) if err != nil { return chat1.NonblockFetchRes{}, err diff --git a/go/chat/uithreadloader.go b/go/chat/uithreadloader.go index 3840ce497657..f808950d4803 100644 --- a/go/chat/uithreadloader.go +++ b/go/chat/uithreadloader.go @@ -501,7 +501,14 @@ func (t *UIThreadLoader) LoadNonblock(ctx context.Context, chatUI libkb.ChatUI, ) (err error) { var pagination, resultPagination *chat1.Pagination var fullErr error + reqID := libkb.RandStringB64(3) + fullSent := false defer t.Trace(ctx, &err, "LoadNonblock")() + t.Debug(ctx, "LoadNonblock[%s]: begin convID: %s reason: %v", reqID, convID, reason) + defer func() { + t.Debug(ctx, "LoadNonblock[%s]: return convID: %s err: %v fullErr: %v fullSent: %v", + reqID, convID, err, fullErr, fullSent) + }() defer func() { // Detect any problem loading the thread, and queue it up in the retrier if there is a problem. // Otherwise, send notice that we successfully loaded the conversation. @@ -539,7 +546,7 @@ func (t *UIThreadLoader) LoadNonblock(ctx context.Context, chatUI libkb.ChatUI, return err } defer t.G().ConvSource.ReleaseConversationLock(ctx, uid, convID) - t.Debug(ctx, "LoadNonblock: conversation lock obtained") + t.Debug(ctx, "LoadNonblock[%s]: conversation lock obtained convID: %s", reqID, convID) // Enable delete placeholders for supersede transform if query == nil { @@ -648,11 +655,11 @@ func (t *UIThreadLoader) LoadNonblock(ctx context.Context, chatUI libkb.ChatUI, } else { t.Debug(ctx, "LoadNonblock: sending nil cached response") } - start := time.Now() + t.Debug(ctx, "LoadNonblock[%s]: cached send begin convID: %s", reqID, convID) if err := chatUI.ChatThreadCached(ctx, pthread); err != nil { t.Debug(ctx, "LoadNonblock: failed to send cached thread: %s", err) } - t.Debug(ctx, "LoadNonblock: cached response send time: %v", time.Since(start)) + t.Debug(ctx, "LoadNonblock[%s]: cached send done convID: %s", reqID, convID) }(localCtx) startTime := t.clock.Now() @@ -708,23 +715,25 @@ func (t *UIThreadLoader) LoadNonblock(ctx context.Context, chatUI libkb.ChatUI, } resultPagination = rthread.Pagination t.applyPagerModeOutgoing(ctx, convID, rthread.Pagination, pagination, pgmode) - start = time.Now() - if fullErr = chatUI.ChatThreadFull(ctx, string(jsonUIRes)); err != nil { - t.Debug(ctx, "LoadNonblock: failed to send full result to UI: %s", err) + t.Debug(ctx, "LoadNonblock[%s]: full send begin convID: %s", reqID, convID) + if fullErr = chatUI.ChatThreadFull(ctx, string(jsonUIRes)); fullErr != nil { + t.Debug(ctx, "LoadNonblock: failed to send full result to UI: %s", fullErr) return } - t.Debug(ctx, "LoadNonblock: full response send time: %v", time.Since(start)) + fullSent = true + t.Debug(ctx, "LoadNonblock[%s]: full send done convID: %s", reqID, convID) // This means we transmitted with success, so cancel local thread cancel() }() wg.Wait() - t.Debug(ctx, "LoadNonblock: thread payloads transferred, checking for resolve") + t.Debug(ctx, "LoadNonblock[%s]: payload transfer complete convID: %s fullSent: %v", reqID, convID, fullSent) // Resolve any messages we didn't cache and get full information about if fullErr == nil { fullErr = func() error { skips := globals.CtxMessageCacheSkips(ctx) + t.Debug(ctx, "LoadNonblock[%s]: post-send resolve begin convID: %s skips: %d", reqID, convID, len(skips)) cancelUIStatus := t.setUIStatus(ctx, chatUI, chat1.NewUIChatThreadStatusWithValidating(0), getDelay()) defer func() { @@ -797,13 +806,14 @@ func (t *UIThreadLoader) LoadNonblock(ctx context.Context, chatUI libkb.ChatUI, t.G().ActivityNotifier.Activity(ctx, uid, chat1.TopicType_CHAT, &act, chat1.ChatActivitySource_LOCAL) } + t.Debug(ctx, "LoadNonblock[%s]: post-send resolve done convID: %s", reqID, convID) return nil }() } // Clean up context and set final loading status if getDisplayedStatus() { - t.Debug(ctx, "LoadNonblock: status displayed, clearing") + t.Debug(ctx, "LoadNonblock[%s]: final status clear begin convID: %s", reqID, convID) t.clock.Sleep(t.validatedDelay) // use a background context here in case our context has been canceled, we don't want to not // get this banner off the screen. @@ -820,7 +830,7 @@ func (t *UIThreadLoader) LoadNonblock(ctx context.Context, chatUI libkb.ChatUI, t.Debug(ctx, "LoadNonblock: failed to set status: %s", err) } } - t.Debug(ctx, "LoadNonblock: clear complete") + t.Debug(ctx, "LoadNonblock[%s]: final status clear done convID: %s", reqID, convID) } else { t.Debug(ctx, "LoadNonblock: no status displayed, not clearing") } diff --git a/plans/chat-refactor.md b/plans/chat-refactor.md new file mode 100644 index 000000000000..f331fa551655 --- /dev/null +++ b/plans/chat-refactor.md @@ -0,0 +1,130 @@ +# 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/plans/inbox-load-fail.md b/plans/inbox-load-fail.md new file mode 100644 index 000000000000..e3d57be327fd --- /dev/null +++ b/plans/inbox-load-fail.md @@ -0,0 +1,585 @@ +# requestInboxUnbox Outstanding Session Investigation + +## Context + +Issue observed in Electron: + +- `outstandingSessionDebugger` repeatedly logged `chat.1.local.requestInboxUnbox` +- the logs appeared in the renderer +- the problem showed up after startup / after entering Chat + +The issue stopped reproducing after restarting the Go daemon. + +## Repro Flow Used + +1. Start the app clean. +2. Let startup settle. +3. Clear renderer and node logs. +4. Click into Chat. +5. Wait for `outstandingSessionDebugger`. +6. Pick the reported renderer `sessionID`. +7. Correlate that session to the transport `seqid` from renderer logs. + +Example stuck pairs we tracked: + +- `sessionID: 180` -> `seqid: 57` +- `sessionID: 181` -> `seqid: 58` +- `sessionID: 183` -> `seqid: 60` + +## What We Confirmed + +### 1. The outstanding sessions were real renderer engine sessions + +For a stuck case we saw: + +- renderer `session start {sessionID: ...}` +- renderer `invokeNow {seqid: ..., sessionID: ...}` +- no matching renderer `session end {sessionID: ...}` + +So this was not just noisy transport logging. The renderer engine session really remained open. + +### 2. The inner UI callback path was working + +For `chat.1.chatUi.chatInboxConversation` we saw: + +- incoming invoke in renderer +- renderer sending `response.result()` +- node receiving that response + +So the nested UI callback ack path was functioning during investigation. + +### 3. The generic JS request path was functioning + +For successful `requestInboxUnbox` calls we saw the full path: + +- renderer started engine session +- renderer invoked outer RPC with a `seqid` +- main received invoke +- node wrote invoke to daemon +- node received outer response from daemon +- renderer matched the response +- renderer ended the engine session + +So the JS RPC path was capable of closing these sessions normally. + +### 4. Some specific outer requests never produced a closing response during the bad runs + +For stuck cases like `181/58` and `183/60`, the important pattern was: + +- renderer started session +- renderer sent invoke +- main received invoke +- node wrote invoke to daemon +- no corresponding renderer session end + +During some bad runs, the decisive missing line for the stuck `seqid` was: + +- no `node received response from daemon ... seqid: ` + +That means the stuck session was not explained by renderer bookkeeping before send. + +### 5. The issue disappeared after restarting the Go daemon + +After restarting the daemon, the problem stopped reproducing. + +That materially weakens the hypothesis that the root cause is a deterministic bug in the new JS RPC implementation alone. + +## What We Tried On The JS Side + +These were investigated and then removed: + +- temp renderer/session logs in the engine layer +- temp transport logs in renderer and node +- temp bridge logs across Electron IPC +- temporary response normalization changes (`undefined` -> `null`) +- temporary bridge framing / chunk normalization experiments +- temporary `requestInboxUnbox` special-casing + +None of those produced a confirmed fix. + +The temporary instrumentation has been cleaned up. + +## Strongest Current Interpretation + +The best-supported explanation from the investigation is: + +- the renderer session stayed outstanding because the outer `requestInboxUnbox` RPC did not complete for some requests +- in bad runs, the request had already crossed renderer -> main -> daemon write +- the missing completion signal was at or after daemon handling, not before renderer send +- restarting the daemon made the issue disappear + +That makes a daemon-side or daemon-state issue the strongest next lead. + +## Service Log Follow-Up + +We checked: + +- `/Users/ChrisNojima/Library/Logs/keybase.service.log` + +Important caveat: + +- this log was later identified as coming from the wrong machine / wrong daemon instance for the bad repro +- so it should not be treated as evidence that the bad run itself completed normally +- it is still useful for understanding the structure of the Go path and the kinds of daemon-side interactions that can delay `RequestInboxUnbox` + +### What The Service Log Added + +The service log did **not** show a direct reproduction of: + +- `+ Server: RequestInboxUnbox` +- no matching `- Server: RequestInboxUnbox -> ok` + +In the ranges inspected, every `RequestInboxUnbox` entry returned. + +However, the service log did show a strong interaction between: + +- `Server: GetThreadNonblock(...)` +- inbox localizer suspension +- concurrent `RequestInboxUnbox` + +### Repeated Pattern In Service Logs + +In several runs, the sequence was: + +1. `GetThreadNonblock(...)` starts. +2. `SuspendComponent: canceled background task` appears. +3. `localizerPipeline: suspend` runs. +4. concurrent `RequestInboxUnbox` starts. +5. its job reaches: + - `localizerPipeline: localizeJobPulled: waiting for resume` +6. only after `GetThreadNonblock(...) -> ok` does the unbox localizer resume and the outer `RequestInboxUnbox` return. + +Representative examples: + +- around `2026-04-08 10:39:02 -04:00` +- around `2026-04-08 10:46:56 -04:00` +- around `2026-04-08 17:14:03 -04:00` + +### Concrete Example: Slow But Completing Unbox + +At `2026-04-08 10:39:02 -04:00`: + +- `GetThreadNonblock(000030...)` starts +- it suspends the localizer +- `RequestInboxUnbox` for the same conv starts +- the unbox job logs `waiting for resume` +- `GetThreadNonblock` returns after about `609ms` +- then `RequestInboxUnbox` returns after about `611ms` + +This is a daemon-side confirmation that `RequestInboxUnbox` can be blocked behind thread-load suspension. + +### Concrete Example: Cancellation / Re-enqueue Storm + +At `2026-04-08 10:46:56 -04:00`: + +- `GetThreadNonblock(000030...)` suspends the localizer +- multiple lines appear: + - `localizerPipeline: localizeJobPulled: canceled a live job` + - `localizerPipeline: localizeConversations: context is done, bailing` + - `localizerPipeline: localizeJobPulled: re-enqueuing canceled job` + - `localizerPipeline: localizeJobPulled: waiting for resume` +- a concurrent `RequestInboxUnbox` for the same conv also enters `waiting for resume` +- it eventually returns, but only after resume + +This did not wedge in the captured log, but it is the clearest daemon-side evidence of a fragile path. + +### Updated Go-Side Interpretation + +The best current Go-side explanation is now narrower: + +- `RequestInboxUnbox` is not independently blocked on UI callback ack in the daemon +- it is blocked on inbox localizer progress +- `GetThreadNonblock` can suspend and cancel localizer work +- concurrent thread loading and inbox unboxing share the same localizer machinery +- a daemon-side wedge in suspend/resume or cancel/re-enqueue handling remains a strong candidate for the runs where the outer response never came back + +### Specific Go Areas Now Most Suspicious + +1. `GetThreadNonblock` suspending the inbox source/localizer during thread load +2. `localizerPipeline.suspend/resume` +3. `localizerPipeline.localizeJobPulled` +4. cancel/re-enqueue behavior after `context canceled` +5. whether a resumed or retried job can be left waiting forever if resume signaling or pending/completed bookkeeping goes wrong + +## Correct-Machine Service Logs + +We later checked the actual machine logs: + +- `/Users/ChrisNojima/Downloads/logs/keybase.service.log` +- `/Users/ChrisNojima/Downloads/logs/keybase.service.log-20260408T180719-0400-20260408T181728-0400` + +These logs materially strengthen the same Go-side suspicion. + +### What These Logs Confirm + +They show that on the real machine: + +- `GetThreadNonblock(...)` does suspend the inbox source/localizer +- concurrent inbox-unbox jobs do enter `localizerPipeline: localizeJobPulled: waiting for resume` +- live localizer jobs can be canceled during that suspension +- canceled jobs can be re-enqueued and later resumed + +So the previously suspected thread-load/localizer interaction is not just theoretical. + +### Concrete Example: Thread Load Cancels Live Localizer Work + +Around `2026-04-08 17:34:59 -04:00` in the rotated log: + +1. `Server: GetThreadNonblock(...)` starts. +2. `SuspendComponent: canceled background task` appears. +3. `localizerPipeline: suspend` runs. +4. live localizer workers log: + - `localizerPipeline: localizeJobPulled: canceled a live job` + - `RemoteClient: chat.1.remote.getMessagesRemote -> ERROR: context canceled` + - `localizerPipeline: failed to transform message ... context canceled` +5. a concurrent inbox-unbox trace enters: + - `localizerPipeline: localizeJobPulled: waiting for resume` +6. only after `GetThreadNonblock(...) -> ok [time=205.738625ms]` do we see: + - `localizerPipeline: resume` + - `localizerPipeline: localizeJobPulled: resume, proceeding` + +That is direct daemon evidence that inbox unbox work can be paused behind thread loading and only released on resume. + +### Concrete Example: Slow RequestInboxUnbox Without Obvious Error + +Around `2026-04-08 18:21:46 -04:00` in the current log: + +- two `RequestInboxUnbox` traces (`tu-3DgB67sGd` and `WTZ2nhFPsZAr`) both enter: + - `localizerPipeline: localizeJobPulled: waiting for resume` +- both then resume and spend roughly `736ms` in `localizeConversations` +- both outer RPCs finally return in about `737ms` + +This shows two important things: + +- the outer RPC really is waiting on localizer progress +- the visible stall can be a combination of suspend/resume delay plus expensive per-conversation localization work + +### What We Have Not Yet Seen + +In the sampled correct-machine log windows, we have not yet isolated: + +- a `RequestInboxUnbox` that entered and never produced `-> ok` +- a `resume: spurious resume call without suspend` + +So the logs do not yet prove the exact permanent wedge. They do prove the fragile path that could plausibly cause it. + +## Narrowed Go Hypothesis + +Given the code and the correct-machine logs, the strongest daemon-side wedge theory is now: + +1. `RequestInboxUnbox` calls `UIInboxLoader.UpdateConvs` +2. `UpdateConvs` calls `LoadNonblock` +3. `LoadNonblock` does not return until the localizer callback channel closes +4. localizer jobs marked cancelable can block in `localizeJobPulled: waiting for resume` +5. `resume()` only releases those waiters when `suspendCount` drops all the way back to zero + +That means an unmatched or leaked suspend would strand every future waiting localizer job indefinitely. + +If that happens: + +- the localizer callback channel never finishes +- `LoadNonblock` never returns +- `RequestInboxUnbox` never returns an outer response +- because `RequestInboxUnbox` swallows normal `UpdateConvs` errors, the renderer would just observe an outstanding request rather than a useful failure + +### Most Specific Code-Level Candidate Now + +The cleanest permanent-hang mechanism is: + +- `GetThreadNonblock` enters `defer h.suspendInboxSource(ctx)()` +- the inbox localizer `suspendCount` is incremented +- cancelable localizer jobs queue up in `waiting for resume` +- for some path or nesting combination, the matching `resume()` does not drive `suspendCount` back to zero +- the waiters are never closed +- the outstanding `RequestInboxUnbox` stays open until daemon restart resets that state + +This is now the main Go-side hypothesis to verify against the next bad-run log. + +## Code Audit Follow-Up + +After digging further into the Go code, the strongest explanation shifted slightly: + +- the most likely wedge is no longer just a leaked `suspendCount` +- a stuck `GetThreadNonblock` path can itself keep the inbox localizer suspended forever + +### Why GetThreadNonblock Is So Dangerous Here + +`GetThreadNonblock` does: + +- `defer h.suspendInboxSource(ctx)()` + +That means inbox-source resume does **not** happen when thread payloads are first sent to the UI. +It only happens when `UIThreadLoader.LoadNonblock(...)` fully returns. + +So if `LoadNonblock(...)` wedges anywhere, the inbox localizer remains suspended the whole time. + +### Important Detail: Thread Load Continues After UI Delivery + +`UIThreadLoader.LoadNonblock(...)` does more work after sending the full thread to the UI: + +1. waits for the local/full goroutines with `wg.Wait()` +2. logs `thread payloads transferred, checking for resolve` +3. resolves skipped unboxeds / validation work +4. performs final status clearing +5. only then returns + +This matters because: + +- the UI may already look "done" +- but the inbox source is still suspended +- so concurrent `RequestInboxUnbox` jobs can still sit in `waiting for resume` + +### Conversation Lock Coupling + +The thread loader also grabs a conversation lock for the full lifetime of `LoadNonblock(...)`: + +- `ConvSource.AcquireConversationLock(...)` +- defer release only when `LoadNonblock(...)` returns + +The same underlying lock is used by conversation-source operations that the inbox localizer calls, including: + +- `ConvSource.GetMessages(...)` +- `ConvSource.GetMessagesWithRemotes(...)` +- other pull/push/expunge paths + +So thread loading and inbox localization are contending on the same per-conversation lock. + +### Lock Behavior That Makes This Risky + +`ConversationLockTab.Acquire(...)` ultimately blocks on a plain `sync.Mutex`: + +- it does not select on `ctx.Done()` +- there is no timeout once it is waiting on the mutex itself + +So if one trace grabs the conversation lock and wedges before release: + +- later thread work for that conv can block forever +- later inbox-localizer work for that conv can block forever +- and if the wedged holder is `GetThreadNonblock`, inbox-source resume never fires + +That is a very strong match for: + +- issue appears after entering Chat +- some `requestInboxUnbox` requests never complete +- daemon restart clears the problem + +## Revised Leading Hypotheses + +From code inspection, the leading Go-side possibilities are now: + +1. `GetThreadNonblock` hangs before its deferred inbox-source resume runs +2. a conversation lock is held indefinitely by thread-loading or message-fetch code +3. a localizer worker hangs inside `localizeConversation(...)` after the initial inbox callback +4. a true suspend-count / waiter-state leak in the localizer pipeline + +### Strongest Current Candidate + +The strongest candidate is now: + +- `GetThreadNonblock` suspends the inbox source +- `UIThreadLoader.LoadNonblock(...)` acquires the conversation lock +- thread loading wedges somewhere under that lock or in its post-send validation phase +- deferred inbox-source resume never runs +- `RequestInboxUnbox` jobs remain stuck in `waiting for resume` + +This is a simpler explanation than a pure `suspendCount` imbalance and fits both the logs and the code structure better. + +## New Strong Code-Only Candidate: Blocking Thread-Status UI RPC + +There is an even more specific way `GetThreadNonblock` can wedge: + +- `UIThreadLoader.LoadNonblock(...)` uses `setUIStatus(...)` +- `setUIStatus(...)` eventually calls `chatUI.ChatThreadStatus(context.Background(), status)` +- `cancelStatusFn()` then waits for that goroutine to report whether the status was displayed + +This is important because: + +- `ChatThreadStatus(...)` is not fire-and-forget; it is a blocking RPC call through `ChatUiClient` +- it is invoked with `context.Background()`, not the request context +- if that UI RPC blocks, the goroutine never reports back on `resCh` +- `cancelStatusFn()` blocks forever waiting on `resCh` +- `UIThreadLoader.LoadNonblock(...)` then never returns +- `GetThreadNonblock` never reaches its deferred inbox-source resume +- `RequestInboxUnbox` jobs can remain stuck in `waiting for resume` + +### Why This Fits The User-Visible Symptom + +This is especially plausible because `LoadNonblock(...)` can already have sent the thread to the UI before the wedge happens. + +So the UI can look mostly usable while the daemon is still: + +- inside `GetThreadNonblock` +- holding the inbox source suspended +- blocking later inbox-unbox RPCs + +### Related Risk In Final Status Clearing + +At the end of `LoadNonblock(...)`, final status clearing also calls: + +- `chatUI.ChatThreadStatus(context.Background(), validated)` +- or `chatUI.ChatThreadStatus(context.Background(), none)` + +Those are also blocking UI RPCs with an uncancelable background context. + +So even after successful thread delivery and validation work, a wedged status callback could still keep `GetThreadNonblock` open indefinitely. + +### Relative Strength Of This Hypothesis + +This now looks at least as strong as the pure lock/suspend-count theories because: + +- it directly explains why entering Chat could trigger the stuck state +- it directly explains how the daemon can stay stuck even after visible UI progress +- it uses an explicit uncancelable blocking RPC call in the exact thread-load path that suspends inbox unboxing + +## Further Code Audit Narrowing + +After tracing the desktop/UI callback plumbing more carefully, the thread-status theory became narrower. + +### Important Weakening: JS Auto-Acks Thread Callbacks + +On the JS side, listener-backed incoming calls are acknowledged immediately: + +- the listener sends `response.result()` before it schedules the actual handler body +- `chatThreadStatus` in the desktop store is only a synchronous state update +- `chatThreadFull` / `chatThreadCached` are also listener callbacks on the same path + +So a slow or expensive JS status handler is **not** enough by itself to wedge the Go call. + +For the status-RPC theory to be the root cause, the failure has to be lower level: + +- the service-side RPC transport never gets the callback delivered to the frontend session +- or the frontend session is not actually able to dispatch the callback at all + +That keeps this as a real possibility, but weaker than it first looked from Go alone. + +### Another Candidate We Can Mostly De-Prioritize: waitForOnline + +`UIThreadLoader.waitForOnline(...)` only waits about one second total: + +- it loops 40 times +- each loop waits `25ms` +- then it proceeds anyway + +So `GetThreadNonblock` is not likely to wedge forever just because the loader was waiting to come online. + +### Stronger Code-Only Candidate: Post-Send Validation Before Resume + +There is a more convincing path inside `UIThreadLoader.LoadNonblock(...)`: + +1. the full thread is sent to the UI +2. `wg.Wait()` completes +3. the loader enters `thread payloads transferred, checking for resolve` +4. it resolves skipped unboxeds / validation work +5. only after that does `LoadNonblock(...)` return +6. only then does `GetThreadNonblock` run its deferred inbox-source resume + +This is important because it matches a user-visible state where: + +- the thread already appears loaded +- but the inbox localizer is still suspended +- and concurrent `RequestInboxUnbox` calls are still stuck in `waiting for resume` + +### Why The Post-Send Work Is Risky + +That post-send path does: + +- `NewBoxer(...).ResolveSkippedUnboxeds(...)` +- `ConvSource.TransformSupersedes(...)` +- notifier/update work on the resulting messages + +`ResolveSkippedUnboxeds(...)` re-validates sender keys for quick-unboxed messages through: + +- `ResolveSkippedUnboxed(...)` +- `ValidSenderKey(...)` +- `CtxUPAKFinder(ctx, ...).CheckKIDForUID(...)` + +So even after the thread is already visible in the UI, the loader can still be blocked doing sender-key validation and related follow-up work before resume happens. + +### Conversation Lock Scope Still Matters + +This is all still happening while `UIThreadLoader.LoadNonblock(...)` holds the per-conversation lock for its full lifetime. + +So the critical section is not just: + +- pull local thread +- pull remote thread + +It also includes: + +- JSON presentation/send +- post-send skip-resolution +- final status-clearing path + +That makes the thread-loader critical section broader than it first appears. + +### Additional Thread-Loader Bug + +There is also a separate bug in `UIThreadLoader.singleFlightConv(...)`: + +- `activeConvLoads[convID] = cancel` is written +- but entries are never removed + +This is probably not the root cause of the outstanding `requestInboxUnbox` sessions, but it is real thread-loader state leakage and could make cancellation behavior harder to reason about over time. + +## Revised Ranking After More Code Reading + +From code alone, the current ranking is: + +1. `GetThreadNonblock` wedges in post-send work before deferred inbox-source resume +2. `GetThreadNonblock` wedges somewhere else while holding the conversation lock +3. UI callback transport wedges on `ChatThreadStatus` / `ChatThreadFull` / `ChatThreadCached` +4. localizer worker hangs after resume inside `localizeConversation(...)` +5. pure localizer suspend-count imbalance / waiter leak + +## Additional Code Smells Worth Remembering + +These are not yet proven root cause, but they increase risk: + +- `UIInboxLoader.LoadNonblock(...)` has a 1-minute timeout only for the first unverified inbox result; after that, draining `localizeCb` has no timeout. +- several localizer username lookups use `UIDMap.MapUIDsToUsernamePackages(..., networkTimeBudget=0, ...)`, which means no explicit timeout is applied at that layer. +- `UIDMap.MapUIDsToUsernamePackages(...)` holds the UID-map mutex across the server lookup path, so one slow miss can serialize later username lookups behind it. +- `UIThreadLoader.LoadNonblock(...)` appears to check `err != nil` instead of `fullErr != nil` after `ChatThreadFull(...)`, which is likely a bug, though not the main outstanding-session explanation. + +## Things That Were Noise / Not Root Cause + +- "Inbox asked for too much work" was not the root issue for the stuck sessions. +- The fact that unboxes happen on startup is expected and not itself the bug. +- `chatInboxConversation` lacking a useful `sessionID` in logs did not explain the stuck outer requests by itself. +- The generic Electron bridge was not universally broken; many requests completed normally through it. + +## Useful Facts For The Next Debug Session + +If the issue reproduces again, capture: + +1. renderer `sessionID` +2. matching renderer outer `seqid` +3. whether node logged: + - `main received invoke ... seqid` + - `node wrote invoke to daemon ... seqid` + - `node received response from daemon ... seqid` +4. whether renderer later logs: + - `response matched invocation ... seqid` + - `session end ... sessionID` + +The most valuable bad-run pattern is: + +- renderer session start +- renderer invoke +- node wrote invoke to daemon +- no daemon response for that same outer `seqid` +- no renderer session end + +## Next Go-Side Questions + +1. Why would `chat.1.local.requestInboxUnbox` sometimes not produce an outer response for a subset of requests? +2. Is there any daemon-side state that can wedge this path until daemon restart? +3. Is `RequestInboxUnbox` blocked on loader/UI state in a way that can fail to return? +4. Is there any batching / callback / channel drain behavior in the inbox loader that can prevent the outer RPC from finishing? +5. Are there daemon logs around the stuck outer request showing the handler entered but never returned? + +## Current Status + +- no confirmed root-cause fix +- JS-side debug instrumentation removed +- daemon restart stopped reproduction +- next investigation should focus on Go/daemon behavior for stuck outer `requestInboxUnbox` calls diff --git a/shared/chat/audio/audio-recorder.native.tsx b/shared/chat/audio/audio-recorder.native.tsx index 76f5d2021128..726ee55ccec9 100644 --- a/shared/chat/audio/audio-recorder.native.tsx +++ b/shared/chat/audio/audio-recorder.native.tsx @@ -364,7 +364,7 @@ const useRecorder = (p: {ampSV: SVN; setShowAudioSend: (s: boolean) => void; sho setStaged(false) setShowAudioSend(false) } - const setCommandStatusInfo = Chat.useChatContext(s => s.dispatch.setCommandStatusInfo) + const setCommandStatusInfo = Chat.useChatUIContext(s => s.dispatch.setCommandStatusInfo) const startRecording = () => { const checkPerms = async () => { diff --git a/shared/chat/conversation/command-status.tsx b/shared/chat/conversation/command-status.tsx index 50b9f2c61ccb..83f56446412d 100644 --- a/shared/chat/conversation/command-status.tsx +++ b/shared/chat/conversation/command-status.tsx @@ -10,11 +10,11 @@ const empty = { } const Container = () => { - const info = Chat.useChatContext(s => s.commandStatus) + const info = Chat.useChatUIContext(s => s.commandStatus) const _info = info || empty const onOpenAppSettings = useConfigState(s => s.dispatch.defer.openAppSettings) - const setCommandStatusInfo = Chat.useChatContext(s => s.dispatch.setCommandStatusInfo) + const setCommandStatusInfo = Chat.useChatUIContext(s => s.dispatch.setCommandStatusInfo) const onCancel = () => { setCommandStatusInfo() } diff --git a/shared/chat/conversation/container.tsx b/shared/chat/conversation/container.tsx index 9986ab9385db..f27584c978ed 100644 --- a/shared/chat/conversation/container.tsx +++ b/shared/chat/conversation/container.tsx @@ -5,8 +5,9 @@ import NoConversation from './no-conversation' import Error from './error' import YouAreReset from './you-are-reset' import Rekey from './rekey/container' +import type {ThreadSearchRouteProps} from './thread-search-route' -const Conversation = function Conversation() { +const Conversation = function Conversation(_: ThreadSearchRouteProps) { const type = Chat.useChatContext(s => { const meta = s.meta switch (s.id) { diff --git a/shared/chat/conversation/force-list-redraw-context.tsx b/shared/chat/conversation/force-list-redraw-context.tsx deleted file mode 100644 index 4a5c4b59848a..000000000000 --- a/shared/chat/conversation/force-list-redraw-context.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import * as React from 'react' -export const ForceListRedrawContext = React.createContext(() => {}) diff --git a/shared/chat/conversation/giphy/hooks.tsx b/shared/chat/conversation/giphy/hooks.tsx index 2b3389b66fe8..128cab51bb5e 100644 --- a/shared/chat/conversation/giphy/hooks.tsx +++ b/shared/chat/conversation/giphy/hooks.tsx @@ -1,7 +1,7 @@ import * as Chat from '@/stores/chat' export const useHooks = () => { - const giphy = Chat.useChatContext(s => s.giphyResult) + const giphy = Chat.useChatUIContext(s => s.giphyResult) const onClick = Chat.useChatContext(s => s.dispatch.giphySend) return { galleryURL: giphy?.galleryUrl ?? '', diff --git a/shared/chat/conversation/input-area/container.tsx b/shared/chat/conversation/input-area/container.tsx index b1ed00f120ef..4482b2915fb3 100644 --- a/shared/chat/conversation/input-area/container.tsx +++ b/shared/chat/conversation/input-area/container.tsx @@ -4,10 +4,11 @@ import {PerfProfiler} from '@/perf/react-profiler' import Normal from './normal' import Preview from './preview' import ThreadSearch from '../search' +import {useThreadSearchRoute} from '../thread-search-route' const InputAreaContainer = () => { const conversationIDKey = Chat.useChatContext(s => s.id) - const showThreadSearch = Chat.useChatContext(s => s.threadSearchInfo.visible) + const showThreadSearch = !!useThreadSearchRoute() const {membershipType, resetParticipants, wasFinalizedBy} = Chat.useChatContext( C.useShallow(s => { const {membershipType, resetParticipants, wasFinalizedBy} = s.meta diff --git a/shared/chat/conversation/input-area/location-popup.native.tsx b/shared/chat/conversation/input-area/location-popup.native.tsx index afe3c8ae9439..b6d6273a1adc 100644 --- a/shared/chat/conversation/input-area/location-popup.native.tsx +++ b/shared/chat/conversation/input-area/location-popup.native.tsx @@ -28,7 +28,7 @@ const LocationButton = (props: {disabled: boolean; label: string; onClick: () => const useWatchPosition = (conversationIDKey: T.Chat.ConversationIDKey) => { const updateLastCoord = Chat.useChatState(s => s.dispatch.updateLastCoord) - const setCommandStatusInfo = Chat.useChatContext(s => s.dispatch.setCommandStatusInfo) + const setCommandStatusInfo = Chat.useChatUIContext(s => s.dispatch.setCommandStatusInfo) React.useEffect(() => { let unsub = () => {} logger.info('[location] perms check due to map') @@ -70,7 +70,7 @@ const LocationPopup = () => { const username = useCurrentUserState(s => s.username) const httpSrv = useConfigState(s => s.httpSrv) const location = Chat.useChatState(s => s.lastCoord) - const locationDenied = Chat.useChatContext( + const locationDenied = Chat.useChatUIContext( s => s.commandStatus?.displayType === T.RPCChat.UICommandStatusDisplayTyp.error ) const [mapLoaded, setMapLoaded] = React.useState(false) diff --git a/shared/chat/conversation/input-area/normal/index.tsx b/shared/chat/conversation/input-area/normal/index.tsx index 29ce889c81a8..3c17dbc095e4 100644 --- a/shared/chat/conversation/input-area/normal/index.tsx +++ b/shared/chat/conversation/input-area/normal/index.tsx @@ -69,10 +69,11 @@ const useHintText = (p: { } const Input = function Input() { - const showGiphySearch = Chat.useChatContext(s => s.giphyWindow) + const showGiphySearch = Chat.useChatUIContext(s => s.giphyWindow) const showCommandMarkdown = Chat.useChatContext(s => !!s.commandMarkdown) - const showCommandStatus = Chat.useChatContext(s => !!s.commandStatus) - const showReplyTo = Chat.useChatContext(s => !!s.messageMap.get(s.replyTo)?.id) + const showCommandStatus = Chat.useChatUIContext(s => !!s.commandStatus) + const replyTo = Chat.useChatUIContext(s => s.replyTo) + const showReplyTo = Chat.useChatContext(s => !!s.messageMap.get(replyTo)?.id) return ( {showReplyTo && } @@ -112,15 +113,20 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() { const route = useRoute | RootRouteProps<'chatRoot'>>() const infoPanelShowing = route.name === 'chatRoot' && 'infoPanel' in route.params ? !!route.params.infoPanel : false + const uiData = Chat.useChatUIContext( + C.useShallow(s => ({ + editOrdinal: s.editing, + replyTo: s.replyTo, + unsentText: s.unsentText, + })) + ) const data = Chat.useChatContext( C.useShallow(s => { - const {meta, id: conversationIDKey, editing: editOrdinal, messageMap, unsentText} = s - const {sendMessage, setEditing, jumpToRecent, setExplodingMode} = s.dispatch - const {injectIntoInput: updateUnsentText} = s.dispatch + const {meta, id: conversationIDKey, messageMap} = s + const {sendMessage, jumpToRecent, setExplodingMode} = s.dispatch const {cannotWrite, minWriterRole, tlfname} = meta - const showReplyPreview = !!messageMap.get(s.replyTo)?.id + const showReplyPreview = !!messageMap.get(uiData.replyTo)?.id const suggestBotCommandsUpdateStatus = s.botCommandsUpdateStatus - const isEditing = !!editOrdinal const convoID = s.getConvID() const metaGood = s.isMetaGood() const storeDraft = metaGood ? meta.draft : undefined @@ -132,16 +138,19 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() { : explodingMode // prettier-ignore return {cannotWrite, conversationIDKey, convoID, explodingMode, explodingModeSeconds, - isEditing, jumpToRecent, minWriterRole, sendMessage, setEditing, setExplodingMode, - showReplyPreview, storeDraft, suggestBotCommandsUpdateStatus, tlfname, unsentText, - updateUnsentText} + jumpToRecent, minWriterRole, sendMessage, setExplodingMode, showReplyPreview, + storeDraft, suggestBotCommandsUpdateStatus, tlfname} }) ) const {cannotWrite, conversationIDKey, setExplodingMode: setExplodingModeRaw} = data - const {isEditing, jumpToRecent, minWriterRole, sendMessage} = data - const {explodingModeSeconds: explodingModeSecondsRaw, setEditing, convoID, tlfname, storeDraft} = data - const {suggestBotCommandsUpdateStatus, unsentText, showReplyPreview, updateUnsentText} = data + const {jumpToRecent, minWriterRole, sendMessage} = data + const {explodingModeSeconds: explodingModeSecondsRaw, convoID, tlfname, storeDraft} = data + const {suggestBotCommandsUpdateStatus, showReplyPreview} = data + const {editOrdinal, unsentText} = uiData + const isEditing = !!editOrdinal + const setEditing = Chat.useChatUIContext(s => s.dispatch.setEditing) + const updateUnsentText = Chat.useChatUIContext(s => s.dispatch.injectIntoInput) const [explodingModeSeconds, setExplodingModeSeconds] = React.useState(explodingModeSecondsRaw) const isExploding = explodingModeSeconds !== 0 diff --git a/shared/chat/conversation/input-area/normal/input.desktop.tsx b/shared/chat/conversation/input-area/normal/input.desktop.tsx index 86f07404d177..79fa47cea087 100644 --- a/shared/chat/conversation/input-area/normal/input.desktop.tsx +++ b/shared/chat/conversation/input-area/normal/input.desktop.tsx @@ -312,7 +312,7 @@ const EmojiButton = function EmojiButton(p: EmojiButtonProps) { } const GiphyButton = function GiphyButton() { - const toggleGiphyPrefill = Chat.useChatContext(s => s.dispatch.toggleGiphyPrefill) + const toggleGiphyPrefill = Chat.useChatUIContext(s => s.dispatch.toggleGiphyPrefill) const onGiphyToggle = toggleGiphyPrefill return ( @@ -389,7 +389,7 @@ const useKeyboard = (p: UseKeyboardProps) => { const {htmlInputRef, focusInput, isEditing, onKeyDown, onCancelEditing} = p const {onChangeText, onEditLastMessage, showReplyPreview} = p const lastText = React.useRef('') - const setReplyTo = Chat.useChatContext(s => s.dispatch.setReplyTo) + const setReplyTo = Chat.useChatUIContext(s => s.dispatch.setReplyTo) const {scrollDown, scrollUp} = React.useContext(ScrollContext) const onCancelReply = () => { setReplyTo(T.Chat.numberToOrdinal(0)) @@ -518,7 +518,7 @@ const PlatformInput = function PlatformInput(p: Props) { const focusInput = () => { inputRef.current?.focus() } - const setEditing = Chat.useChatContext(s => s.dispatch.setEditing) + const setEditing = Chat.useChatUIContext(s => s.dispatch.setEditing) const onEditLastMessage = () => { setEditing('last') } diff --git a/shared/chat/conversation/input-area/normal/moremenu-popup.native.tsx b/shared/chat/conversation/input-area/normal/moremenu-popup.native.tsx index 11a68ef3af76..5081785b30b5 100644 --- a/shared/chat/conversation/input-area/normal/moremenu-popup.native.tsx +++ b/shared/chat/conversation/input-area/normal/moremenu-popup.native.tsx @@ -8,7 +8,7 @@ type Props = { const MoreMenuPopup = (props: Props) => { const {onHidden, visible} = props - const injectIntoInput = Chat.useChatContext(s => s.dispatch.injectIntoInput) + const injectIntoInput = Chat.useChatUIContext(s => s.dispatch.injectIntoInput) const navigateAppend = Chat.useChatNavigateAppend() const onLocationShare = () => { navigateAppend(conversationIDKey => ({name: 'chatLocationPreview', params: {conversationIDKey}})) diff --git a/shared/chat/conversation/input-area/normal/typing.tsx b/shared/chat/conversation/input-area/normal/typing.tsx index 7afa301260b1..d1031d8ee82d 100644 --- a/shared/chat/conversation/input-area/normal/typing.tsx +++ b/shared/chat/conversation/input-area/normal/typing.tsx @@ -44,12 +44,12 @@ const Names = (props: {names?: ReadonlySet}) => { const emptySet = new Set() const Typing = function Typing() { + const showGiphySearch = Chat.useChatUIContext(s => s.giphyWindow) const names = Chat.useChatContext( C.useShallow(s => { const names = s.typing if (!C.isMobile) return names const showCommandMarkdown = !!s.commandMarkdown - const showGiphySearch = s.giphyWindow const showTypingStatus = !showGiphySearch && !showCommandMarkdown return showTypingStatus ? names : emptySet }) diff --git a/shared/chat/conversation/input-area/suggestors/commands.tsx b/shared/chat/conversation/input-area/suggestors/commands.tsx index de22029457d3..7b583f0ef240 100644 --- a/shared/chat/conversation/input-area/suggestors/commands.tsx +++ b/shared/chat/conversation/input-area/suggestors/commands.tsx @@ -106,7 +106,7 @@ type UseDataSourceProps = { const useDataSource = (p: UseDataSourceProps) => { const {filter, inputRef, lastTextRef} = p const staticConfig = Chat.useChatState(s => s.staticConfig) - const showGiphySearch = Chat.useChatContext(s => s.giphyWindow) + const showGiphySearch = Chat.useChatUIContext(s => s.giphyWindow) const showCommandMarkdown = Chat.useChatContext(s => !!s.commandMarkdown) return Chat.useChatContext( C.useShallow(s => { diff --git a/shared/chat/conversation/list-area/index.desktop.tsx b/shared/chat/conversation/list-area/index.desktop.tsx index 25692d01bc6c..ed591fb61c97 100644 --- a/shared/chat/conversation/list-area/index.desktop.tsx +++ b/shared/chat/conversation/list-area/index.desktop.tsx @@ -10,11 +10,9 @@ import SpecialBottomMessage from '../messages/special-bottom-message' import SpecialTopMessage from '../messages/special-top-message' import chunk from 'lodash/chunk' import {findLast} from '@/util/arrays' -import {getMessageRender} from '../messages/wrapper' +import {MessageRow} from '../messages/wrapper' import {globalMargins} from '@/styles/shared' import {FocusContext, ScrollContext} from '../normal/context' -import {chatDebugEnabled} from '@/constants/chat/debug' -import logger from '@/logger' import shallowEqual from '@/util/shallow-equal' import useResizeObserver from '@/util/use-resize-observer.desktop' import useIntersectionObserver from '@/util/use-intersection-observer' @@ -340,7 +338,7 @@ const useScrolling = (p: { }, [scrollDown, scrollToBottom, scrollUp, setScrollRef]) // go to editing message - const editingOrdinal = Chat.useChatContext(s => s.editing) + const editingOrdinal = Chat.useChatUIContext(s => s.editing) const lastEditingOrdinalRef = React.useRef(0) React.useEffect(() => { if (lastEditingOrdinalRef.current !== editingOrdinal) return @@ -366,23 +364,14 @@ const useScrolling = (p: { } const useItems = (p: { + centeredHighlightOrdinal: T.Chat.Ordinal | undefined messageOrdinals: ReadonlyArray centeredOrdinal: T.Chat.Ordinal | undefined editingOrdinal: T.Chat.Ordinal | undefined - messageTypeMap: ReadonlyMap | undefined }) => { - const {messageTypeMap, messageOrdinals, centeredOrdinal, editingOrdinal} = p + const {messageOrdinals, centeredHighlightOrdinal, centeredOrdinal, editingOrdinal} = p const ordinalsInAWaypoint = 10 const rowRenderer = (ordinal: T.Chat.Ordinal) => { - const type = messageTypeMap?.get(ordinal) ?? 'text' - const Clazz = getMessageRender(type) - if (!Clazz) { - if (chatDebugEnabled) { - logger.error('[CHATDEBUG] no rendertype', {Clazz, ordinal, type}) - } - return null - } - return (
- +
) } @@ -483,25 +475,26 @@ const useItems = (p: { const noOrdinals = new Array() const ThreadWrapper = function ThreadWrapper() { + const editingOrdinal = Chat.useChatUIContext(s => s.editing) const data = Chat.useChatContext( C.useShallow(s => { - const {messageTypeMap, editing: editingOrdinal, id: conversationIDKey} = s + const {id: conversationIDKey} = s const {messageCenterOrdinal: mco, messageOrdinals = noOrdinals, loaded} = s - const centeredOrdinal = mco && mco.highlightMode !== 'none' ? mco.ordinal : undefined + const centeredHighlightOrdinal = mco && mco.highlightMode !== 'none' ? mco.ordinal : undefined + const centeredOrdinal = mco?.ordinal const containsLatestMessage = s.isCaughtUp() return { + centeredHighlightOrdinal, centeredOrdinal, containsLatestMessage, conversationIDKey, - editingOrdinal, loaded, messageOrdinals, - messageTypeMap, } }) ) - const {conversationIDKey, editingOrdinal, centeredOrdinal} = data - const {containsLatestMessage, messageOrdinals, loaded, messageTypeMap} = data + const {conversationIDKey, centeredHighlightOrdinal, centeredOrdinal} = data + const {containsLatestMessage, messageOrdinals, loaded} = data const copyToClipboard = useConfigState(s => s.dispatch.defer.copyToClipboard) const listRef = React.useRef(null) const _setListRef = (r: HTMLDivElement | null) => { @@ -561,7 +554,12 @@ const ThreadWrapper = function ThreadWrapper() { } } - const items = useItems({centeredOrdinal, editingOrdinal, messageOrdinals, messageTypeMap}) + const items = useItems({ + centeredHighlightOrdinal, + centeredOrdinal, + editingOrdinal, + messageOrdinals, + }) const setListContents = useHandleListResize({ centeredOrdinal, isLockedToBottom, diff --git a/shared/chat/conversation/list-area/index.native.tsx b/shared/chat/conversation/list-area/index.native.tsx index d0b14b4df6a1..3c52761c8627 100644 --- a/shared/chat/conversation/list-area/index.native.tsx +++ b/shared/chat/conversation/list-area/index.native.tsx @@ -9,10 +9,8 @@ import SpecialTopMessage from '../messages/special-top-message' import type {ItemType} from '.' import {FlatList} from 'react-native' // import {FlashList, type ListRenderItemInfo} from '@shopify/flash-list' -import {getMessageRender} from '../messages/wrapper' +import {MessageRow} from '../messages/wrapper' import {mobileTypingContainerHeight} from '../input-area/normal/typing' -import {SetRecycleTypeContext} from '../recycle-type-context' -import {ForceListRedrawContext} from '../force-list-redraw-context' // import {useChatDebugDump} from '@/constants/chat/debug' import {usingFlashList} from './flashlist-config' import {PerfProfiler} from '@/perf/react-profiler' @@ -99,15 +97,16 @@ const ConversationList = function ConversationList() { const conversationIDKey = Chat.useChatContext(s => s.id) - // used to force a rerender when a type changes, aka placeholder resolves - const [extraData, setExtraData] = React.useState(0) - const [lastED, setLastED] = React.useState(extraData) - const loaded = Chat.useChatContext(s => s.loaded) - const centeredOrdinal = - Chat.useChatContext(s => s.messageCenterOrdinal)?.ordinal ?? T.Chat.numberToOrdinal(-1) + const messageCenterOrdinal = Chat.useChatContext(s => s.messageCenterOrdinal) + const centeredHighlightOrdinal = + messageCenterOrdinal && messageCenterOrdinal.highlightMode !== 'none' + ? messageCenterOrdinal.ordinal + : T.Chat.numberToOrdinal(-1) + const centeredOrdinal = messageCenterOrdinal?.ordinal ?? T.Chat.numberToOrdinal(-1) const messageTypeMap = Chat.useChatContext(s => s.messageTypeMap) const _messageOrdinals = Chat.useChatContext(s => s.messageOrdinals) + const rowRecycleTypeMap = Chat.useChatContext(s => s.rowRecycleTypeMap) const messageOrdinals = [...(_messageOrdinals ?? [])].reverse() @@ -123,21 +122,12 @@ const ConversationList = function ConversationList() { if (!ordinal) { return null } - const type = messageTypeMap.get(ordinal) ?? 'text' - const Clazz = getMessageRender(type) - if (!Clazz) return null - return - } - - const recycleTypeRef = React.useRef(new Map()) - React.useEffect(() => { - if (lastED !== extraData) { - recycleTypeRef.current = new Map() - setLastED(extraData) - } - }, [extraData, lastED]) - const setRecycleType = (ordinal: T.Chat.Ordinal, type: string) => { - recycleTypeRef.current.set(ordinal, type) + return ( + + ) } const numOrdinals = messageOrdinals.length @@ -146,8 +136,7 @@ const ConversationList = function ConversationList() { if (!ordinal) { return 'null' } - // Check recycleType first (set by messages after render — includes subtypes like 'text:reply') - const recycled = recycleTypeRef.current.get(ordinal) + const recycled = rowRecycleTypeMap.get(ordinal) if (recycled) return recycled const baseType = messageTypeMap.get(ordinal) ?? 'text' // Last item is most-recently sent; isolate it to avoid recycling with settled messages @@ -210,28 +199,15 @@ const ConversationList = function ConversationList() { } }, [loaded, centeredOrdinal, scrollToBottom, scrollToCentered, numOrdinals]) - // We use context to inject a way for items to force the list to rerender when they notice something about their - // internals have changed (aka a placeholder isn't a placeholder anymore). This can be racy as if you detect this - // and call you can get effectively memoized. In order to allow the item to re-render if they're still in this state - // we make this callback mutate, so they have a chance to rerender and recall it - // A repro is a placeholder resolving as a placeholder multiple times before resolving for real - const forceListRedraw = () => { - extraData // just to silence eslint - // wrap in timeout so we don't get max update depths sometimes - setTimeout(() => { - setExtraData(d => d + 1) - }, 100) - } - // useChatDebugDump( // 'listArea', // C.useEvent(() => { // if (!listRef.current) return '' // const {props, state} = listRef.current as { - // props: {extraData?: {}; data?: [number]} + // props: {data?: [number]} // state?: object // } - // const {extraData, data} = props + // const {data} = props // // // const layoutManager = (state?.layoutProvider?._lastLayoutManager ?? ({} as unknown)) as { // // _layouts?: [unknown] @@ -263,7 +239,6 @@ const ConversationList = function ConversationList() { // _totalHeight, // _totalWidth, // data, - // extraData, // items, // } // return JSON.stringify(details) @@ -275,41 +250,36 @@ const ConversationList = function ConversationList() { return ( - - - - - - {jumpToRecent} - {debugWhichList} - - - - + + + + {jumpToRecent} + {debugWhichList} + + ) } diff --git a/shared/chat/conversation/messages/account-payment/wrapper.tsx b/shared/chat/conversation/messages/account-payment/wrapper.tsx index c3e018a5e990..48d0475555f7 100644 --- a/shared/chat/conversation/messages/account-payment/wrapper.tsx +++ b/shared/chat/conversation/messages/account-payment/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type PaymentMessageType from './container' function WrapperPayment(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const {ordinal, isCenteredHighlight} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData - if (message?.type !== 'requestPayment' && message?.type !== 'sendPayment') return null + if (message.type !== 'requestPayment' && message.type !== 'sendPayment') return null const {default: PaymentMessage} = require('./container') as {default: typeof PaymentMessageType} return ( - + ) diff --git a/shared/chat/conversation/messages/attachment/audio.tsx b/shared/chat/conversation/messages/attachment/audio.tsx index 663952856b61..a7924e61cfc3 100644 --- a/shared/chat/conversation/messages/attachment/audio.tsx +++ b/shared/chat/conversation/messages/attachment/audio.tsx @@ -1,12 +1,9 @@ import * as Chat from '@/stores/chat' import type * as T from '@/constants/types' import * as Kb from '@/common-adapters' -import {useOrdinal} from '../ids-context' import AudioPlayer from '@/chat/audio/audio-player' import {useFSState} from '@/stores/fs' -const missingMessage = Chat.makeMessageAttachment() - const messageAttachmentHasProgress = (message: T.Chat.MessageAttachment) => { return ( !!message.transferState && @@ -14,14 +11,7 @@ const messageAttachmentHasProgress = (message: T.Chat.MessageAttachment) => { message.transferState !== 'mobileSaving' ) } -const AudioAttachment = () => { - const ordinal = useOrdinal() - - // TODO not message - const message = Chat.useChatContext(s => { - const m = s.messageMap.get(ordinal) - return m?.type === 'attachment' ? m : missingMessage - }) +const AudioAttachment = ({message}: {message: T.Chat.MessageAttachment}) => { const progressLabel = Chat.messageAttachmentTransferStateToProgressLabel(message.transferState) const hasProgress = messageAttachmentHasProgress(message) const openLocalPathInSystemFileManagerDesktop = useFSState( diff --git a/shared/chat/conversation/messages/attachment/file.tsx b/shared/chat/conversation/messages/attachment/file.tsx index 03de575ab726..223db9e84d87 100644 --- a/shared/chat/conversation/messages/attachment/file.tsx +++ b/shared/chat/conversation/messages/attachment/file.tsx @@ -1,8 +1,8 @@ import * as C from '@/constants' import * as CryptoRoutes from '@/constants/crypto' import * as Chat from '@/stores/chat' +import type * as T from '@/constants/types' import {isPathSaltpack, isPathSaltpackEncrypted, isPathSaltpackSigned} from '@/util/path' -import {useOrdinal} from '@/chat/conversation/messages/ids-context' import captialize from 'lodash/capitalize' import * as Kb from '@/common-adapters' import type {StyleOverride} from '@/common-adapters/markdown' @@ -10,54 +10,50 @@ import {getEditStyle, ShowToastAfterSaving} from './shared' import {useFSState} from '@/stores/fs' import {makeUUID} from '@/util/uuid' -type OwnProps = {showPopup: () => void} - -const missingMessage = Chat.makeMessageAttachment({}) +type OwnProps = { + isEditing: boolean + message: T.Chat.MessageAttachment + ordinal: T.Chat.Ordinal + showPopup: () => void +} function FileContainer(p: OwnProps) { - const ordinal = useOrdinal() - const data = Chat.useChatContext( - C.useShallow(s => { - const m = s.messageMap.get(ordinal) ?? missingMessage - const isEditing = !!s.editing - const conversationIDKey = s.id - const {downloadPath, fileName, fileType, transferErrMsg, transferState} = m - const title = m.decoratedText?.stringValue() || m.title || m.fileName - const progress = m.type === 'attachment' ? m.transferProgress : 0 - - const {dispatch} = s - const {attachmentDownload, messageAttachmentNativeShare} = dispatch - return { - attachmentDownload, - conversationIDKey, - downloadPath, - fileName, - fileType, - isEditing, - messageAttachmentNativeShare, - progress, - title, - transferErrMsg, - transferState, - } - }) + const {isEditing, message, ordinal} = p + const {attachmentDownload, messageAttachmentNativeShare} = Chat.useChatContext( + C.useShallow(s => ({ + attachmentDownload: s.dispatch.attachmentDownload, + messageAttachmentNativeShare: s.dispatch.messageAttachmentNativeShare, + })) ) - - const {conversationIDKey, fileType, downloadPath, isEditing, progress, messageAttachmentNativeShare} = data - const {attachmentDownload, title, transferState, transferErrMsg, fileName: _fileName} = data + const { + conversationIDKey, + downloadPath, + fileName: _fileName, + fileType, + transferErrMsg, + transferProgress: progress, + transferState, + } = message + const title = message.decoratedText?.stringValue() || message.title || message.fileName const switchTab = C.Router2.switchTab const navigateAppend = C.Router2.navigateAppend - const onSaltpackFileOpen = (path: string, name: typeof CryptoRoutes.decryptTab | typeof CryptoRoutes.verifyTab) => { + const onSaltpackFileOpen = ( + path: string, + name: typeof CryptoRoutes.decryptTab | typeof CryptoRoutes.verifyTab + ) => { switchTab(C.Tabs.cryptoTab) - navigateAppend({ - name, - params: { - entryNonce: makeUUID(), - seedInputPath: path, - seedInputType: 'file', + navigateAppend( + { + name, + params: { + entryNonce: makeUUID(), + seedInputPath: path, + seedInputType: 'file', + }, }, - }, true) + true + ) } const openLocalPathInSystemFileManagerDesktop = useFSState( s => s.dispatch.defer.openLocalPathInSystemFileManagerDesktop @@ -99,7 +95,7 @@ function FileContainer(p: OwnProps) { !!transferState && transferState !== 'remoteUploading' && transferState !== 'mobileSaving' const errorMsg = transferErrMsg || '' - const fileName = _fileName ?? '' + const fileName = _fileName const isSaltpackFile = !!fileName && isPathSaltpack(fileName) const onShowInFinder = !C.isMobile && downloadPath ? _onShowInFinder : undefined const showMessageMenu = p.showPopup @@ -123,6 +119,7 @@ function FileContainer(p: OwnProps) { @@ -182,7 +179,7 @@ function FileContainer(p: OwnProps) { )} {!!progressLabel && ( - + {progressLabel} @@ -245,6 +242,14 @@ const styles = Kb.Styles.styleSheetCreate( color: Kb.Styles.globalColors.black_50, marginRight: Kb.Styles.globalMargins.tiny, }, + progressOverlay: { + backgroundColor: Kb.Styles.globalColors.greyLight, + bottom: 0, + left: 0, + opacity: 0.9, + position: 'absolute', + width: 'auto', + }, retry: { color: Kb.Styles.globalColors.redDark, textDecorationLine: 'underline', diff --git a/shared/chat/conversation/messages/attachment/image/imageimpl.d.ts b/shared/chat/conversation/messages/attachment/image/imageimpl.d.ts index 21bf021a53b4..1f586de3e41f 100644 --- a/shared/chat/conversation/messages/attachment/image/imageimpl.d.ts +++ b/shared/chat/conversation/messages/attachment/image/imageimpl.d.ts @@ -1,3 +1,4 @@ import type * as React from 'react' -declare const ImageImpl: () => React.ReactNode +import type * as T from '@/constants/types' +declare const ImageImpl: (p: {message: T.Chat.MessageAttachment}) => React.ReactNode export default ImageImpl diff --git a/shared/chat/conversation/messages/attachment/image/imageimpl.desktop.tsx b/shared/chat/conversation/messages/attachment/image/imageimpl.desktop.tsx index c2cc1a0fee9d..c2afda0ba109 100644 --- a/shared/chat/conversation/messages/attachment/image/imageimpl.desktop.tsx +++ b/shared/chat/conversation/messages/attachment/image/imageimpl.desktop.tsx @@ -1,9 +1,10 @@ import * as Kb from '@/common-adapters' -import {useState} from './use-state' +import type * as T from '@/constants/types' +import {getAttachmentPreviewSize} from '../shared' // its important we use explicit height/width so we never CLS while loading -const ImageImpl = () => { - const {previewURL, height, width} = useState() +const ImageImpl = ({message}: {message: T.Chat.MessageAttachment}) => { + const {previewURL, height, width} = getAttachmentPreviewSize(message, true) return ( { - const {previewURL, height, width} = useState() +const ImageImpl = ({message}: {message: T.Chat.MessageAttachment}) => { + const {previewURL, height, width} = getAttachmentPreviewSize(message, true) return } diff --git a/shared/chat/conversation/messages/attachment/image/index.tsx b/shared/chat/conversation/messages/attachment/image/index.tsx index 31b1ec5f5369..6f433768822b 100644 --- a/shared/chat/conversation/messages/attachment/image/index.tsx +++ b/shared/chat/conversation/messages/attachment/image/index.tsx @@ -1,26 +1,37 @@ import * as Kb from '@/common-adapters' import * as React from 'react' +import * as Chat from '@/stores/chat' +import type * as T from '@/constants/types' import ImageImpl from './imageimpl' import { + getAttachmentDisplayFileName, ShowToastAfterSaving, Title, - useAttachmentState, useCollapseIcon, Collapsed, Transferring, TransferIcon, } from '../shared' +import {Keyboard} from 'react-native' type Props = { + message: T.Chat.MessageAttachment + ordinal: T.Chat.Ordinal showPopup: () => void } function Image(p: Props) { - const {showPopup} = p - const {fileName, isCollapsed, showTitle, openFullscreen, transferState, transferProgress} = - useAttachmentState() + const {message, ordinal, showPopup} = p + const {isCollapsed, title, transferProgress, transferState} = message + const attachmentPreviewSelect = Chat.useChatContext(s => s.dispatch.attachmentPreviewSelect) + const fileName = getAttachmentDisplayFileName(message) + const showTitle = !!title + const openFullscreen = () => { + Keyboard.dismiss() + attachmentPreviewSelect(ordinal) + } const containerStyle = styles.container - const collapseIcon = useCollapseIcon(false) + const collapseIcon = useCollapseIcon(ordinal, isCollapsed, false) const filename = Kb.Styles.isMobile || !fileName ? null : ( @@ -55,19 +66,19 @@ function Image(p: Props) { style={styles.imageContainer} ref={toastTargetRef} > - + - {showTitle ? : null} + {showTitle ? <Title message={message} /> : null} <Transferring transferState={transferState} ratio={transferProgress} /> </Kb.Box2> - <TransferIcon style={Kb.Styles.isMobile ? styles.transferIcon : undefined} /> + <TransferIcon message={message} ordinal={ordinal} style={Kb.Styles.isMobile ? styles.transferIcon : undefined} /> </Kb.Box2> </> ) return ( <Kb.Box2 direction="vertical" fullWidth={true} style={containerStyle} alignItems="flex-start"> - {isCollapsed ? <Collapsed /> : content} + {isCollapsed ? <Collapsed isCollapsed={isCollapsed} ordinal={ordinal} /> : content} </Kb.Box2> ) } diff --git a/shared/chat/conversation/messages/attachment/image/use-state.tsx b/shared/chat/conversation/messages/attachment/image/use-state.tsx deleted file mode 100644 index e540129c10e3..000000000000 --- a/shared/chat/conversation/messages/attachment/image/use-state.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import * as C from '@/constants' -import * as Chat from '@/stores/chat' -import {useOrdinal} from '@/chat/conversation/messages/ids-context' -import {maxWidth, maxHeight} from '../shared' - -const missingMessage = Chat.makeMessageAttachment() - -export const useState = () => { - const ordinal = useOrdinal() - return Chat.useChatContext( - C.useShallow(s => { - const m = s.messageMap.get(ordinal) - const message = m?.type === 'attachment' ? m : missingMessage - const {fileURL, previewHeight, previewWidth} = message - let {previewURL} = message - let {height, width} = Chat.clampImageSize(previewWidth, previewHeight, maxWidth, maxHeight) - // This is mostly a sanity check and also allows us to handle HEIC even though the go side doesn't - // understand - if (height === 0 || width === 0) { - height = 320 - width = 320 - } - if (!previewURL) { - previewURL = fileURL - } - return {height, previewURL, width} - }) - ) -} diff --git a/shared/chat/conversation/messages/attachment/shared.tsx b/shared/chat/conversation/messages/attachment/shared.tsx index f2afc22fcf62..7d6f208f9687 100644 --- a/shared/chat/conversation/messages/attachment/shared.tsx +++ b/shared/chat/conversation/messages/attachment/shared.tsx @@ -3,9 +3,7 @@ import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' import * as React from 'react' import * as T from '@/constants/types' -import {useOrdinal} from '../ids-context' import {sharedStyles} from '../shared-styles' -import {Keyboard} from 'react-native' import {useFSState} from '@/stores/fs' type Props = { @@ -17,8 +15,6 @@ type Props = { export const maxWidth = Kb.Styles.isMobile ? Math.min(356, Kb.Styles.dimensionWidth - 70) : 356 export const maxHeight = 320 -export const missingMessage = Chat.makeMessageAttachment() - export const ShowToastAfterSaving = ({transferState, toastTargetRef}: Props) => { const [showingToast, setShowingToast] = React.useState(false) const lastTransferStateRef = React.useRef(transferState) @@ -62,33 +58,29 @@ export const ShowToastAfterSaving = ({transferState, toastTargetRef}: Props) => ) : null } -export const TransferIcon = (p: {style: Kb.Styles.StylesCrossPlatform}) => { - const {style} = p - const ordinal = useOrdinal() - const {state, downloadPath, download} = Chat.useChatContext( - C.useShallow(s => { - const m = s.messageMap.get(ordinal) - let state: 'none' | 'doneWithPath' | 'done' | 'downloading' = 'none' - let downloadPath = '' - if (m?.type === 'attachment') { - downloadPath = m.downloadPath ?? '' - if (downloadPath.length) { - state = 'doneWithPath' - } else if (m.transferProgress === 1) { - state = 'done' - } else { - switch (m.transferState) { - case 'downloading': - case 'mobileSaving': - state = 'downloading' - break - default: - } - } - } - const download = C.isMobile ? s.dispatch.messageAttachmentNativeSave : s.dispatch.attachmentDownload - return {download, downloadPath, state} - }) +export const TransferIcon = (p: { + message: T.Chat.MessageAttachment + ordinal: T.Chat.Ordinal + style: Kb.Styles.StylesCrossPlatform +}) => { + const {message, ordinal, style} = p + let state: 'none' | 'doneWithPath' | 'done' | 'downloading' = 'none' + const downloadPath = message.downloadPath ?? '' + if (downloadPath.length) { + state = 'doneWithPath' + } else if (message.transferProgress === 1) { + state = 'done' + } else { + switch (message.transferState) { + case 'downloading': + case 'mobileSaving': + state = 'downloading' + break + default: + } + } + const download = Chat.useChatContext(s => + C.isMobile ? s.dispatch.messageAttachmentNativeSave : s.dispatch.attachmentDownload ) const onDownload = () => { download(ordinal) @@ -169,12 +161,33 @@ export const getEditStyle = (isEditing: boolean) => { return isEditing ? sharedStyles.sentEditing : sharedStyles.sent } -export const Title = () => { - const ordinal = useOrdinal() - const title = Chat.useChatContext(s => { - const m = s.messageMap.get(ordinal) - return m?.type === 'attachment' ? (m.decoratedText?.stringValue() ?? m.title) : '' - }) +export const getAttachmentDisplayFileName = (message: T.Chat.MessageAttachment) => { + return message.deviceType === 'desktop' + ? message.fileName + : `${message.inlineVideoPlayable ? 'Video' : 'Image'} from mobile` +} + +export const getAttachmentPreviewSize = ( + message: T.Chat.MessageAttachment, + useSquareFallback = false +) => { + const {fileURL, previewHeight, previewWidth} = message + let {previewURL} = message + let {height, width} = Chat.clampImageSize(previewWidth, previewHeight, maxWidth, maxHeight) + // This is mostly a sanity check and also allows us to handle HEIC even though the go side doesn't + // understand. + if (useSquareFallback && (height === 0 || width === 0)) { + height = 320 + width = 320 + } + if (!previewURL) { + previewURL = fileURL + } + return {height, previewURL, width} +} + +export const Title = ({message}: {message: T.Chat.MessageAttachment}) => { + const title = message.decoratedText?.stringValue() ?? message.title const styleOverride = Kb.Styles.isMobile ? {paragraph: {backgroundColor: Kb.Styles.globalColors.black_05_on_white}} @@ -194,14 +207,7 @@ export const Title = () => { ) } -const CollapseIcon = ({isWhite}: {isWhite: boolean}) => { - const ordinal = useOrdinal() - const isCollapsed = Chat.useChatContext(s => { - const m = s.messageMap.get(ordinal) - const message = m?.type === 'attachment' ? m : missingMessage - const {isCollapsed} = message - return isCollapsed - }) +const CollapseIcon = ({isCollapsed, isWhite}: {isCollapsed: boolean; isWhite: boolean}) => { return ( <Kb.Icon style={isWhite ? styles.collapseLabelWhite : undefined} @@ -227,8 +233,7 @@ const styles = Kb.Styles.styleSheetCreate(() => ({ }, })) -const useCollapseAction = () => { - const ordinal = useOrdinal() +const useCollapseAction = (ordinal: T.Chat.Ordinal) => { const toggleMessageCollapse = Chat.useChatContext(s => s.dispatch.toggleMessageCollapse) const onCollapse = () => { toggleMessageCollapse(T.Chat.numberToMessageID(T.Chat.ordinalToNumber(ordinal)), ordinal) @@ -237,57 +242,23 @@ const useCollapseAction = () => { } // not showing this for now -const useCollapseIconDesktop = (isWhite: boolean) => { - const onCollapse = useCollapseAction() +const useCollapseIconDesktop = (ordinal: T.Chat.Ordinal, isCollapsed: boolean, isWhite: boolean) => { + const onCollapse = useCollapseAction(ordinal) return ( <Kb.ClickableBox2 onClick={onCollapse}> <Kb.Box2 alignSelf="flex-start" direction="horizontal" gap="xtiny"> - <CollapseIcon isWhite={isWhite} /> + <CollapseIcon isCollapsed={isCollapsed} isWhite={isWhite} /> </Kb.Box2> </Kb.ClickableBox2> ) } -const useCollapseIconMobile = (_isWhite: boolean) => null +const useCollapseIconMobile = (_ordinal: T.Chat.Ordinal, _isCollapsed: boolean, _isWhite: boolean) => null export const useCollapseIcon = C.isMobile ? useCollapseIconMobile : useCollapseIconDesktop -export const useAttachmentState = () => { - const ordinal = useOrdinal() - const {attachmentPreviewSelect, fileName, isCollapsed, isEditing, showTitle, submitState, transferProgress, transferState} = - Chat.useChatContext( - C.useShallow(s => { - const m = s.messageMap.get(ordinal) - const message = m?.type === 'attachment' ? m : missingMessage - const {isCollapsed, title, fileName: fileNameRaw, transferProgress} = message - const {deviceType, inlineVideoPlayable, transferState, submitState} = message - const isEditing = s.editing === ordinal - const showTitle = !!title - const fileName = - deviceType === 'desktop' ? fileNameRaw : `${inlineVideoPlayable ? 'Video' : 'Image'} from mobile` - - return {attachmentPreviewSelect: s.dispatch.attachmentPreviewSelect, fileName, isCollapsed, isEditing, showTitle, submitState, transferProgress, transferState} - }) - ) - const openFullscreen = () => { - Keyboard.dismiss() - attachmentPreviewSelect(ordinal) - } - - return { - fileName, - isCollapsed, - isEditing, - openFullscreen, - showTitle, - submitState, - transferProgress, - transferState, - } -} - -export const Collapsed = () => { - const onCollapse = useCollapseAction() - const collapseIcon = useCollapseIcon(false) +export const Collapsed = ({isCollapsed, ordinal}: {isCollapsed: boolean; ordinal: T.Chat.Ordinal}) => { + const onCollapse = useCollapseAction(ordinal) + const collapseIcon = useCollapseIcon(ordinal, isCollapsed, false) return ( <Kb.Box2 direction="horizontal" fullWidth={true}> <Kb.Text type="BodyTiny" onClick={onCollapse}> diff --git a/shared/chat/conversation/messages/attachment/video/index.tsx b/shared/chat/conversation/messages/attachment/video/index.tsx index bc81e8f504c8..750be423ad16 100644 --- a/shared/chat/conversation/messages/attachment/video/index.tsx +++ b/shared/chat/conversation/messages/attachment/video/index.tsx @@ -1,27 +1,37 @@ import * as React from 'react' import * as Kb from '@/common-adapters' +import * as Chat from '@/stores/chat' +import type * as T from '@/constants/types' import VideoImpl from './videoimpl' import { Title, - useAttachmentState, Collapsed, useCollapseIcon, Transferring, TransferIcon, ShowToastAfterSaving, + getAttachmentDisplayFileName, } from '../shared' +import {Keyboard} from 'react-native' type Props = { + message: T.Chat.MessageAttachment + ordinal: T.Chat.Ordinal showPopup: () => void } function Video(p: Props) { - const {showPopup} = p - const r = useAttachmentState() - const {transferState, transferProgress, submitState} = r - const {fileName, isCollapsed, showTitle, openFullscreen} = r + const {message, ordinal, showPopup} = p + const {isCollapsed, submitState, title, transferProgress, transferState} = message + const attachmentPreviewSelect = Chat.useChatContext(s => s.dispatch.attachmentPreviewSelect) + const fileName = getAttachmentDisplayFileName(message) + const showTitle = !!title + const openFullscreen = () => { + Keyboard.dismiss() + attachmentPreviewSelect(ordinal) + } const containerStyle = styles.container - const collapseIcon = useCollapseIcon(false) + const collapseIcon = useCollapseIcon(ordinal, isCollapsed, false) const filename = Kb.Styles.isMobile || !fileName ? null : ( <Kb.Box2 direction="horizontal" alignSelf="flex-start" gap="xtiny"> @@ -52,21 +62,22 @@ function Video(p: Props) { > <ShowToastAfterSaving transferState={transferState} toastTargetRef={toastTargetRef} /> <VideoImpl + message={message} openFullscreen={openFullscreen} showPopup={showPopup} allowPlay={transferState !== 'uploading' && submitState !== 'pending'} /> - {showTitle ? <Title /> : null} + {showTitle ? <Title message={message} /> : null} <Transferring transferState={transferState} ratio={transferProgress} /> </Kb.Box2> - <TransferIcon style={Kb.Styles.isMobile ? styles.transferIcon : undefined} /> + <TransferIcon message={message} ordinal={ordinal} style={Kb.Styles.isMobile ? styles.transferIcon : undefined} /> </Kb.Box2> </> ) return ( <Kb.Box2 direction="vertical" fullWidth={true} relative={true} style={containerStyle} alignItems="flex-start"> - {isCollapsed ? <Collapsed /> : content} + {isCollapsed ? <Collapsed isCollapsed={isCollapsed} ordinal={ordinal} /> : content} </Kb.Box2> ) } diff --git a/shared/chat/conversation/messages/attachment/video/use-state.tsx b/shared/chat/conversation/messages/attachment/video/use-state.tsx deleted file mode 100644 index 6234a9ea7b2b..000000000000 --- a/shared/chat/conversation/messages/attachment/video/use-state.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as C from '@/constants' -import * as Chat from '@/stores/chat' -import {useOrdinal} from '@/chat/conversation/messages/ids-context' -import {missingMessage, maxWidth, maxHeight} from '../shared' - -export const useState = () => { - const ordinal = useOrdinal() - return Chat.useChatContext( - C.useShallow(s => { - const m = s.messageMap.get(ordinal) - const message = m?.type === 'attachment' ? m : missingMessage - const {previewURL, previewHeight, previewWidth} = message - const {fileURL, downloadPath, transferState, videoDuration} = message - const {height, width} = Chat.clampImageSize(previewWidth, previewHeight, maxWidth, maxHeight) - return {downloadPath, height, previewURL, transferState, url: fileURL, videoDuration, width} - }) - ) -} diff --git a/shared/chat/conversation/messages/attachment/video/videoimpl.d.ts b/shared/chat/conversation/messages/attachment/video/videoimpl.d.ts index 90c8d64f1432..ebf4aa6f607a 100644 --- a/shared/chat/conversation/messages/attachment/video/videoimpl.d.ts +++ b/shared/chat/conversation/messages/attachment/video/videoimpl.d.ts @@ -1,8 +1,10 @@ import type * as React from 'react' +import type * as T from '@/constants/types' export type Props = { openFullscreen: () => void showPopup: () => void allowPlay: boolean + message: T.Chat.MessageAttachment } declare const VideoImpl: (p: Props) => React.ReactNode export default VideoImpl diff --git a/shared/chat/conversation/messages/attachment/video/videoimpl.desktop.tsx b/shared/chat/conversation/messages/attachment/video/videoimpl.desktop.tsx index 64436c40b305..6376ab95b414 100644 --- a/shared/chat/conversation/messages/attachment/video/videoimpl.desktop.tsx +++ b/shared/chat/conversation/messages/attachment/video/videoimpl.desktop.tsx @@ -1,13 +1,13 @@ import * as Kb from '@/common-adapters' import * as React from 'react' import type {Props} from './videoimpl' -import {useState} from './use-state' -import {maxWidth, maxHeight} from '../shared' +import {getAttachmentPreviewSize, maxWidth, maxHeight} from '../shared' // its important we use explicit height/width so we never CLS while loading const VideoImpl = (p: Props) => { - const {openFullscreen, allowPlay} = p - const {previewURL, height, width, url, videoDuration} = useState() + const {allowPlay, message, openFullscreen} = p + const {fileURL: url, videoDuration} = message + const {previewURL, height, width} = getAttachmentPreviewSize(message) const [showPoster, setShowPoster] = React.useState(true) const [lastUrl, setLastUrl] = React.useState(url) diff --git a/shared/chat/conversation/messages/attachment/video/videoimpl.native.tsx b/shared/chat/conversation/messages/attachment/video/videoimpl.native.tsx index 250735259dbe..aa2107c32175 100644 --- a/shared/chat/conversation/messages/attachment/video/videoimpl.native.tsx +++ b/shared/chat/conversation/messages/attachment/video/videoimpl.native.tsx @@ -1,15 +1,16 @@ import * as React from 'react' import * as Kb from '@/common-adapters' -import {useState} from './use-state' import {ShowToastAfterSaving} from '../shared' import {useVideoPlayer, VideoView} from 'expo-video' import {useEventListener} from 'expo' import {Pressable} from 'react-native' import type {Props} from './videoimpl' +import {getAttachmentPreviewSize} from '../shared' const VideoImpl = (p: Props) => { - const {allowPlay, showPopup} = p - const {previewURL, height, width, url, transferState, videoDuration} = useState() + const {allowPlay, message, showPopup} = p + const {fileURL: url, transferState, videoDuration} = message + const {previewURL, height, width} = getAttachmentPreviewSize(message) const sourceUri = `${url}&contentforce=true` const player = useVideoPlayer(sourceUri, pl => { diff --git a/shared/chat/conversation/messages/attachment/wrapper.tsx b/shared/chat/conversation/messages/attachment/wrapper.tsx index 142185e41154..5357b8576410 100644 --- a/shared/chat/conversation/messages/attachment/wrapper.tsx +++ b/shared/chat/conversation/messages/attachment/wrapper.tsx @@ -2,56 +2,68 @@ import type AudioAttachmentType from './audio' import type FileAttachmentType from './file' import type ImageAttachmentType from './image' import type VideoAttachmentType from './video' -import {WrapperMessage, useCommonWithData, useMessageData, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' export function WrapperAttachmentAudio(p: Props) { - const {ordinal} = p - const messageData = useMessageData(ordinal) - const common = useCommonWithData(ordinal, messageData) + const {ordinal, isCenteredHighlight = false} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData + if (message.type !== 'attachment') { + return null + } const {default: AudioAttachment} = require('./audio') as {default: typeof AudioAttachmentType} return ( - <WrapperMessage {...p} {...common} messageData={messageData}> - <AudioAttachment /> + <WrapperMessage {...p} {...wrapper}> + <AudioAttachment message={message} /> </WrapperMessage> ) } export function WrapperAttachmentFile(p: Props) { - const {ordinal} = p - const messageData = useMessageData(ordinal) - const common = useCommonWithData(ordinal, messageData) - const {showPopup} = common + const {ordinal, isCenteredHighlight = false} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {showPopup} = wrapper + const {message, isEditing} = wrapper.messageData + if (message.type !== 'attachment') { + return null + } const {default: FileAttachment} = require('./file') as {default: typeof FileAttachmentType} return ( - <WrapperMessage {...p} {...common} messageData={messageData}> - <FileAttachment showPopup={showPopup} /> + <WrapperMessage {...p} {...wrapper}> + <FileAttachment isEditing={isEditing} message={message} ordinal={ordinal} showPopup={showPopup} /> </WrapperMessage> ) } export function WrapperAttachmentVideo(p: Props) { - const {ordinal} = p - const messageData = useMessageData(ordinal) - const common = useCommonWithData(ordinal, messageData) - const {showPopup} = common + const {ordinal, isCenteredHighlight = false} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {showPopup} = wrapper + const {message} = wrapper.messageData + if (message.type !== 'attachment') { + return null + } const {default: VideoAttachment} = require('./video') as {default: typeof VideoAttachmentType} return ( - <WrapperMessage {...p} {...common} messageData={messageData}> - <VideoAttachment showPopup={showPopup} /> + <WrapperMessage {...p} {...wrapper}> + <VideoAttachment message={message} ordinal={ordinal} showPopup={showPopup} /> </WrapperMessage> ) } export function WrapperAttachmentImage(p: Props) { - const {ordinal} = p - const messageData = useMessageData(ordinal) - const common = useCommonWithData(ordinal, messageData) - const {showPopup} = common + const {ordinal, isCenteredHighlight = false} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {showPopup} = wrapper + const {message} = wrapper.messageData + if (message.type !== 'attachment') { + return null + } const {default: ImageAttachment} = require('./image') as {default: typeof ImageAttachmentType} return ( - <WrapperMessage {...p} {...common} messageData={messageData}> - <ImageAttachment showPopup={showPopup} /> + <WrapperMessage {...p} {...wrapper}> + <ImageAttachment message={message} ordinal={ordinal} showPopup={showPopup} /> </WrapperMessage> ) } diff --git a/shared/chat/conversation/messages/emoji-row.tsx b/shared/chat/conversation/messages/emoji-row.tsx index 32afc9b17ae1..85fe9b007486 100644 --- a/shared/chat/conversation/messages/emoji-row.tsx +++ b/shared/chat/conversation/messages/emoji-row.tsx @@ -8,37 +8,31 @@ import {EmojiPickerDesktop} from '@/chat/emoji-picker/container' type OwnProps = { className?: string - hasUnfurls?: boolean - messageType?: T.Chat.MessageType + hasUnfurls: boolean + messageType: T.Chat.MessageType + onReact?: (emoji: string) => void + onReply?: () => void onShowingEmojiPicker?: (arg0: boolean) => void style?: Kb.Styles.StylesCrossPlatform } +const useTopReacjis = () => + Chat.useChatState( + C.useShallow(s => [ + s.userReacjis.topReacjis[0], + s.userReacjis.topReacjis[1], + s.userReacjis.topReacjis[2], + s.userReacjis.topReacjis[3], + s.userReacjis.topReacjis[4], + ]) + ).filter((reacji): reacji is T.RPCGen.UserReacji => !!reacji) + function EmojiRowContainer(p: OwnProps) { - const {className, onShowingEmojiPicker, style} = p + const {className, hasUnfurls, messageType, onReact: onReactProp, onReply: onReplyProp, onShowingEmojiPicker, style} = p const ordinal = useOrdinal() - - const {setReplyTo, toggleMessageReaction, type: subscriptionType, hasUnfurls: subscriptionHasUnfurls} = - Chat.useChatContext( - C.useShallow(s => { - const {toggleMessageReaction, setReplyTo} = s.dispatch - // When both are provided as props, skip message map lookup (constant return = no re-renders) - if (p.messageType !== undefined && p.hasUnfurls !== undefined) { - return {hasUnfurls: false as boolean, setReplyTo, toggleMessageReaction, type: null as T.Chat.MessageType | null} - } - const m = s.messageMap.get(ordinal) - return { - hasUnfurls: p.hasUnfurls !== undefined ? false : (m?.unfurls?.size ?? 0) > 0, - setReplyTo, - toggleMessageReaction, - type: p.messageType !== undefined ? null : (m?.type ?? null), - } - }) - ) - const type = p.messageType ?? subscriptionType ?? undefined - const hasUnfurls = p.hasUnfurls ?? subscriptionHasUnfurls - - const emojis = Chat.useChatState(C.useShallow(s => s.userReacjis.topReacjis.slice(0, 5))) + const setReplyTo = Chat.useChatUIContext(s => s.dispatch.setReplyTo) + const toggleMessageReaction = Chat.useChatContext(s => s.dispatch.toggleMessageReaction) + const emojis = useTopReacjis() const navigateAppend = Chat.useChatNavigateAppend() const _onForward = () => { navigateAppend(conversationIDKey => ({ @@ -47,14 +41,19 @@ function EmojiRowContainer(p: OwnProps) { })) } const onReact = (emoji: string) => { + if (onReactProp) { + onReactProp(emoji) + return + } toggleMessageReaction(ordinal, emoji) } const _onReply = () => { setReplyTo(ordinal) } - const onForward = hasUnfurls || type === 'attachment' ? _onForward : undefined - const onReply = type === 'text' || type === 'attachment' ? _onReply : undefined + const onForward = hasUnfurls || messageType === 'attachment' ? _onForward : undefined + const onReply = + messageType === 'text' || messageType === 'attachment' ? (onReplyProp ?? _onReply) : undefined const [showingPicker, setShowingPicker] = React.useState(false) const popupAnchor = React.useRef<Kb.MeasureRef | null>(null) diff --git a/shared/chat/conversation/messages/message-popup/hooks.tsx b/shared/chat/conversation/messages/message-popup/hooks.tsx index ea30a132419c..1fef4cca632e 100644 --- a/shared/chat/conversation/messages/message-popup/hooks.tsx +++ b/shared/chat/conversation/messages/message-popup/hooks.tsx @@ -108,12 +108,15 @@ export const useItems = (ordinal: T.Chat.Ordinal, onHidden: () => void) => { {icon: 'iconfont-link', onClick: onCopyLink, title: 'Copy a link to this message'}, ] as const - const {messageDelete, pinMessage, setEditing, setMarkAsUnread, setReplyTo} = Chat.useChatContext( + const {messageDelete, pinMessage, setMarkAsUnread} = Chat.useChatContext( C.useShallow(s => { - const {messageDelete, pinMessage, setEditing, setMarkAsUnread, setReplyTo} = s.dispatch - return {messageDelete, pinMessage, setEditing, setMarkAsUnread, setReplyTo} + const {messageDelete, pinMessage, setMarkAsUnread} = s.dispatch + return {messageDelete, pinMessage, setMarkAsUnread} }) ) + const {setEditing, setReplyTo} = Chat.useChatUIContext( + C.useShallow(s => ({setEditing: s.dispatch.setEditing, setReplyTo: s.dispatch.setReplyTo})) + ) const onReply = () => { setReplyTo(ordinal) diff --git a/shared/chat/conversation/messages/message-popup/reactionitem.tsx b/shared/chat/conversation/messages/message-popup/reactionitem.tsx index 3771eea7b50d..014c61e87d5c 100644 --- a/shared/chat/conversation/messages/message-popup/reactionitem.tsx +++ b/shared/chat/conversation/messages/message-popup/reactionitem.tsx @@ -1,5 +1,7 @@ +import * as C from '@/constants' import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' +import type * as T from '@/constants/types' type Props = { onHidden: () => void @@ -7,8 +9,19 @@ type Props = { showPicker: () => void } +const useTopReacjis = () => + Chat.useChatState( + C.useShallow(s => [ + s.userReacjis.topReacjis[0], + s.userReacjis.topReacjis[1], + s.userReacjis.topReacjis[2], + s.userReacjis.topReacjis[3], + s.userReacjis.topReacjis[4], + ]) + ).filter((reacji): reacji is T.RPCGen.UserReacji => !!reacji) + const ReactionItem = (props: Props) => { - const _topReacjis = Chat.useChatState(s => s.userReacjis.topReacjis) + const topReacjis = useTopReacjis() const onReact = (emoji: string) => { props.onReact(emoji) props.onHidden() @@ -20,7 +33,6 @@ const ReactionItem = (props: Props) => { props.showPicker() }, 100) } - const topReacjis = _topReacjis.slice(0, 5) return ( <Kb.Box2 direction="horizontal" fullWidth={true} flex={1} style={styles.container} justifyContent="space-between"> {topReacjis.map((r, idx) => ( diff --git a/shared/chat/conversation/messages/pin/wrapper.tsx b/shared/chat/conversation/messages/pin/wrapper.tsx index 3d427c3e093d..3db3f5d8e752 100644 --- a/shared/chat/conversation/messages/pin/wrapper.tsx +++ b/shared/chat/conversation/messages/pin/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type PinType from '.' function WrapperPin(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const {ordinal, isCenteredHighlight} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData - if (message?.type !== 'pin') return null + if (message.type !== 'pin') return null const {default: PinComponent} = require('.') as {default: typeof PinType} return ( - <WrapperMessage {...p} {...common}> + <WrapperMessage {...p} {...wrapper}> <PinComponent messageID={message.pinnedMessageID} /> </WrapperMessage> ) diff --git a/shared/chat/conversation/messages/placeholder/wrapper.tsx b/shared/chat/conversation/messages/placeholder/wrapper.tsx index 6a8032355001..27a3010c2d49 100644 --- a/shared/chat/conversation/messages/placeholder/wrapper.tsx +++ b/shared/chat/conversation/messages/placeholder/wrapper.tsx @@ -1,43 +1,19 @@ -import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' -import * as React from 'react' import * as T from '@/constants/types' -import {WrapperMessage, type Props} from '../wrapper/wrapper' -import {ForceListRedrawContext} from '../../force-list-redraw-context' - -const noop = () => {} +import {WrapperMessage, useWrapperMessage, type Props} from '../wrapper/wrapper' const baseWidth = Kb.Styles.isMobile ? 100 : 150 const mult = Kb.Styles.isMobile ? 5 : 10 function WrapperPlaceholder(p: Props) { - const {ordinal} = p + const {ordinal, isCenteredHighlight} = p const o = T.Chat.ordinalToNumber(ordinal) const code = o * 16807 const width = baseWidth + (code % 20) * mult // pseudo randomize the length - const noAnchor = React.useRef<Kb.MeasureRef | null>(null) - - const forceListRedraw = React.useContext(ForceListRedrawContext) - - const type = Chat.useChatContext(s => s.messageMap.get(ordinal)?.type) - const [lastType, setLastType] = React.useState(type) - - if (lastType !== type) { - setLastType(type) - if (type !== 'placeholder') { - forceListRedraw() - } - } + const wrapper = useWrapperMessage(ordinal, isCenteredHighlight) return ( - <WrapperMessage - {...p} - showCenteredHighlight={false} - showPopup={noop} - showingPopup={false} - popup={null} - popupAnchor={noAnchor} - > + <WrapperMessage {...p} {...wrapper}> <Kb.Box2 direction="horizontal" gap="tiny" style={styles.container}> <Kb.Placeholder width={width} /> </Kb.Box2> diff --git a/shared/chat/conversation/messages/react-button.tsx b/shared/chat/conversation/messages/react-button.tsx index 5c60f958a98f..3fbcd7766bc3 100644 --- a/shared/chat/conversation/messages/react-button.tsx +++ b/shared/chat/conversation/messages/react-button.tsx @@ -7,47 +7,37 @@ import type {StyleOverride} from '@/common-adapters/markdown' import {colors, darkColors} from '@/styles/colors' import {useColorScheme} from 'react-native' import {useCurrentUserState} from '@/stores/current-user' +import type * as T from '@/constants/types' export type OwnProps = { className?: string - emoji?: string + emoji: string onLongPress?: () => void - showBorder?: boolean + reaction: T.Chat.ReactionDesc style?: StylesCrossPlatform + toggleReaction?: (emoji: string) => void } -function ReactButtonContainer(p: OwnProps) { - const ordinal = useOrdinal() - const {onLongPress, style, emoji, className} = p - const me = useCurrentUserState(s => s.username) - const isDarkMode = useColorScheme() === 'dark' - const {active, count, decorated} = Chat.useChatContext( - C.useShallow(s => { - const message = s.messageMap.get(ordinal) - const reaction = message?.reactions?.get(emoji || '') - const active = (reaction?.users ?? []).some(r => r.username === me) - return { - active, - count: reaction?.users.length ?? 0, - decorated: reaction?.decorated ?? '', - } - }) - ) - - const toggleMessageReaction = Chat.useChatContext(s => s.dispatch.toggleMessageReaction) - const onClick = () => { - toggleMessageReaction(ordinal, emoji || '') - } - const navigateAppend = Chat.useChatNavigateAppend() - const onOpenEmojiPicker = () => { - navigateAppend(conversationIDKey => ({ - name: 'chatChooseEmoji', - params: {conversationIDKey, onPickAddToMessageOrdinal: ordinal, pickKey: 'reaction'}, - })) - } - - const text = decorated.length ? decorated : emoji - return emoji ? ( +function ReactionButton({ + active, + className, + count, + isDarkMode, + onClick, + onLongPress, + style, + text, +}: { + active: boolean + className?: string + count: number + isDarkMode: boolean + onClick: () => void + onLongPress?: () => void + style?: StylesCrossPlatform + text: string +}) { + return ( <Kb.ClickableBox2 className={Kb.Styles.classNames('react-button', className, {noShadow: active})} onLongPress={onLongPress} @@ -82,7 +72,50 @@ function ReactButtonContainer(p: OwnProps) { </Kb.Text> </Kb.Box2> </Kb.ClickableBox2> - ) : ( + ) +} + +function ReactButtonContainer(p: OwnProps) { + const {emoji, reaction} = p + const me = useCurrentUserState(s => s.username) + const isDarkMode = useColorScheme() === 'dark' + const onClick = () => { + p.toggleReaction?.(emoji) + } + const active = reaction.users.some(r => r.username === me) + const count = reaction.users.length + const text = reaction.decorated || emoji + + return ( + <ReactionButton + active={active} + className={p.className} + count={count} + isDarkMode={isDarkMode} + onClick={onClick} + onLongPress={p.onLongPress} + style={p.style} + text={text} + /> + ) +} + +type NewReactionButtonProps = { + style?: StylesCrossPlatform +} + +export function NewReactionButton(p: NewReactionButtonProps) { + const ordinal = useOrdinal() + const isDarkMode = useColorScheme() === 'dark' + const navigateAppend = Chat.useChatNavigateAppend() + const onOpenEmojiPicker = () => { + navigateAppend(conversationIDKey => ({ + name: 'chatChooseEmoji', + params: {conversationIDKey, onPickAddToMessageOrdinal: ordinal, pickKey: 'reaction'}, + })) + } + + return ( <Kb.ClickableBox2 onClick={onOpenEmojiPicker} style={Kb.Styles.collapseStyles([ @@ -90,7 +123,7 @@ function ReactButtonContainer(p: OwnProps) { {borderColor: isDarkMode ? darkColors.black_10 : colors.black_10}, styles.newReactionButtonBox, styles.buttonBox, - style, + p.style, ])} > <Kb.Box2 centerChildren={true} fullHeight={true} direction="horizontal"> diff --git a/shared/chat/conversation/messages/reaction-tooltip.tsx b/shared/chat/conversation/messages/reaction-tooltip.tsx index edd409c0d8eb..d6ce86a588fc 100644 --- a/shared/chat/conversation/messages/reaction-tooltip.tsx +++ b/shared/chat/conversation/messages/reaction-tooltip.tsx @@ -19,26 +19,25 @@ type OwnProps = { visible: boolean } -const emptyStateProps = { - _reactions: new Map<string, T.Chat.ReactionDesc>(), - _usersInfo: new Map<string, T.Users.UserInfo>(), +const emptyReactions = new Map<string, T.Chat.ReactionDesc>() +const emptyUsersInfo = new Map<string, T.Users.UserInfo>() + +type Section = { + data: Array<ListItem> + ordinal: T.Chat.Ordinal + reaction: T.Chat.ReactionDesc + title: string } const ReactionTooltip = (p: OwnProps) => { const {ordinal, onHidden, attachmentRef, onMouseLeave, onMouseOver, visible, emoji} = p - const infoMap = useUsersState(s => s.infoMap) - const {_reactions, good} = Chat.useChatContext( - C.useShallow(s => { - const message = s.messageMap.get(ordinal) - if (message && Chat.isMessageWithReactions(message)) { - const _reactions = message.reactions - return {_reactions, good: true} - } - return {...emptyStateProps, good: false} - }) - ) - const _usersInfo = good ? infoMap : emptyStateProps._usersInfo + const reactions = Chat.useChatContext(s => { + const message = s.messageMap.get(ordinal) + return message && Chat.isMessageWithReactions(message) ? message.reactions : undefined + }) + const usersInfo = useUsersState(s => (reactions ? s.infoMap : emptyUsersInfo)) + const toggleMessageReaction = Chat.useChatContext(s => s.dispatch.toggleMessageReaction) const navigateAppend = Chat.useChatNavigateAppend() const onAddReaction = () => { @@ -49,23 +48,26 @@ const ReactionTooltip = (p: OwnProps) => { })) } - let reactions = [...(_reactions?.keys() ?? [])] + let reactionsToShow = [...(reactions?.keys() ?? emptyReactions.keys())] .map(emoji => { - const reactionUsers = _reactions?.get(emoji)?.users ?? [] + const reaction = reactions?.get(emoji) + const reactionUsers = reactions?.get(emoji)?.users ?? [] const sortedUsers = [...reactionUsers].sort((a, b) => a.timestamp - b.timestamp) return { earliestTimestamp: sortedUsers[0]?.timestamp ?? 0, emoji, + reaction, users: sortedUsers.map(r => ({ - fullName: (_usersInfo.get(r.username) || {fullname: ''}).fullname || '', + fullName: (usersInfo.get(r.username) || {fullname: ''}).fullname || '', username: r.username, })), } }) + .filter((r): r is {earliestTimestamp: number; emoji: string; reaction: T.Chat.ReactionDesc; users: Array<ListItem>} => !!r.reaction) .sort((a, b) => a.earliestTimestamp - b.earliestTimestamp) - .map(({emoji, users}) => ({emoji, users})) + .map(({emoji, reaction, users}) => ({emoji, reaction, users})) if (!C.isMobile && emoji) { - reactions = reactions.filter(r => r.emoji === emoji) + reactionsToShow = reactionsToShow.filter(r => r.emoji === emoji) } const insets = Kb.useSafeAreaInsets() const conversationIDKey = Chat.useChatContext(s => s.id) @@ -74,12 +76,33 @@ const ReactionTooltip = (p: OwnProps) => { return null } - const sections = reactions.map(r => ({ + const sections = reactionsToShow.map(r => ({ data: r.users.map(u => ({...u, key: `${u.username}:${r.emoji}`})), key: r.emoji, ordinal: ordinal, + reaction: r.reaction, title: r.emoji, })) + const renderSectionHeader = ({section}: {section: Section}) => ( + <Kb.Box2 + key={section.title} + direction="horizontal" + gap="tiny" + gapStart={true} + gapEnd={true} + fullWidth={true} + style={styles.buttonContainer} + > + <ReactButton + emoji={section.title} + reaction={section.reaction} + toggleReaction={emoji => toggleMessageReaction(section.ordinal, emoji)} + /> + <Kb.Text type="Terminal" lineClamp={1} style={styles.emojiText}> + {section.title} + </Kb.Text> + </Kb.Box2> + ) return ( <Kb.Popup @@ -154,31 +177,6 @@ const renderItem = ({item}: {item: ListItem}) => { ) } -const renderSectionHeader = ({ - section, -}: { - section: { - data: Array<ListItem> - ordinal: T.Chat.Ordinal - title: string - } -}) => ( - <Kb.Box2 - key={section.title} - direction="horizontal" - gap="tiny" - gapStart={true} - gapEnd={true} - fullWidth={true} - style={styles.buttonContainer} - > - <ReactButton emoji={section.title} /> - <Kb.Text type="Terminal" lineClamp={1} style={styles.emojiText}> - {section.title} - </Kb.Text> - </Kb.Box2> -) - const styles = Kb.Styles.styleSheetCreate( () => ({ diff --git a/shared/chat/conversation/messages/reactions-rows.tsx b/shared/chat/conversation/messages/reactions-rows.tsx index 86eb38bec95d..667750eb3c84 100644 --- a/shared/chat/conversation/messages/reactions-rows.tsx +++ b/shared/chat/conversation/messages/reactions-rows.tsx @@ -1,9 +1,8 @@ -import * as C from '@/constants' -import * as Chat from '@/stores/chat' +import * as Message from '@/constants/chat/message' import * as Kb from '@/common-adapters' import * as React from 'react' import EmojiRow from './emoji-row' -import ReactButton from './react-button' +import ReactButton, {NewReactionButton} from './react-button' import ReactionTooltip from './reaction-tooltip' import type * as T from '@/constants/types' import {useOrdinal} from './ids-context' @@ -11,43 +10,53 @@ import {Keyboard} from 'react-native' const emptyEmojis: ReadonlyArray<string> = [] -function ReactionsRowContainer() { - const ordinal = useOrdinal() - const emojis = Chat.useChatContext(C.useShallow(s => s.reactionOrderMap.get(ordinal) ?? emptyEmojis)) +type OwnProps = { + hasUnfurls: boolean + messageType: T.Chat.MessageType + onReact: (emoji: string) => void + onReply: () => void + reactions?: T.Chat.Reactions +} + +function ReactionsRowContainer(p: OwnProps) { + const {hasUnfurls, messageType, onReact, onReply, reactions} = p + const emojis = reactions?.size ? Message.getReactionOrder(reactions) : emptyEmojis return emojis.length === 0 ? null : ( <Kb.Box2 direction="horizontal" gap="xtiny" fullWidth={true} style={styles.container}> - {emojis.map((emoji, idx) => ( - <RowItem key={String(idx)} emoji={emoji} /> - ))} + {emojis.map((emoji, idx) => { + const reaction = reactions?.get(emoji) + return reaction ? ( + <RowItem key={emoji || String(idx)} emoji={emoji} onReact={onReact} reaction={reaction} /> + ) : null + })} {Kb.Styles.isMobile ? ( - <ReactButton showBorder={true} style={styles.button} /> + <NewReactionButton style={styles.button} /> ) : ( - <EmojiRow className={Kb.Styles.classNames([btnClassName, newBtnClassName])} style={styles.emojiRow} /> + <EmojiRow + className={Kb.Styles.classNames([btnClassName, newBtnClassName])} + hasUnfurls={hasUnfurls} + messageType={messageType} + onReact={onReact} + onReply={onReply} + style={styles.emojiRow} + /> )} </Kb.Box2> ) } -export type Props = { - activeEmoji: string - emojis: Array<string> - ordinal: T.Chat.Ordinal - setActiveEmoji: (s: string) => void - setHideMobileTooltip: () => void - setShowMobileTooltip: () => void - showMobileTooltip: boolean -} - const btnClassName = 'WrapperMessage-emojiButton' const newBtnClassName = 'WrapperMessage-newEmojiButton' type IProps = { emoji: string + onReact: (emoji: string) => void + reaction: T.Chat.ReactionDesc } function RowItem(p: IProps) { const ordinal = useOrdinal() - const {emoji} = p + const {emoji, onReact, reaction} = p const popupAnchor = React.useRef<Kb.MeasureRef | null>(null) const [showingPopup, setShowingPopup] = React.useState(false) @@ -76,7 +85,9 @@ function RowItem(p: IProps) { className={btnClassName} emoji={emoji} onLongPress={Kb.Styles.isMobile ? showPopup : undefined} + reaction={reaction} style={styles.button} + toggleReaction={onReact} /> {popup} </Kb.Box2> diff --git a/shared/chat/conversation/messages/separator.tsx b/shared/chat/conversation/messages/separator.tsx index c41ee092e677..ba7704df6e40 100644 --- a/shared/chat/conversation/messages/separator.tsx +++ b/shared/chat/conversation/messages/separator.tsx @@ -1,13 +1,10 @@ import * as C from '@/constants' import * as Chat from '@/stores/chat' -import {useTeamsState} from '@/stores/teams' import * as Kb from '@/common-adapters' import * as React from 'react' import * as T from '@/constants/types' -import {formatTimeForConversationList, formatTimeForChat} from '@/util/timestamp' +import {formatTimeForConversationList} from '@/util/timestamp' import {OrangeLineContext} from '../orange-line-context' -import {useTrackerState} from '@/stores/tracker' -import {navToProfile} from '@/constants/router' const missingMessage = Chat.makeMessageDeleted({}) @@ -34,147 +31,11 @@ const useSeparatorData = (trailingItem: T.Chat.Ordinal, leadingItem: T.Chat.Ordi ? formatTimeForConversationList(m.timestamp) : '' - if (!showUsername) { - return { - author: '', - botAlias: '', - isAdhocBot: false, - orangeLineAbove, - orangeTime, - ordinal, - showUsername, - teamID: '' as T.Teams.TeamID, - teamType: 'adhoc' as T.Chat.TeamType, - teamname: '', - timestamp: 0, - } - } - - const {author, timestamp} = m - const {teamID, botAliases, teamType, teamname} = s.meta - const participantInfoNames = s.participants.name - const isAdhocBot = - teamType === 'adhoc' && participantInfoNames.length > 0 - ? !participantInfoNames.includes(author) - : false - - return { - author, - botAlias: botAliases[author] ?? '', - isAdhocBot, - orangeLineAbove, - orangeTime, - ordinal, - showUsername, - teamID, - teamType, - teamname, - timestamp, - } + return {orangeLineAbove, orangeTime, ordinal} }) ) } -type AuthorProps = { - author: string - botAlias: string - isAdhocBot: boolean - teamID: T.Teams.TeamID - teamType: T.Chat.TeamType - teamname: string - timestamp: number - showUsername: string -} - -// Separate component so useTeamsState/useTrackerState only -// subscribe when there's actually an author to show. -function AuthorSection(p: AuthorProps) { - const {author, botAlias, isAdhocBot, teamID, teamType, teamname, timestamp, showUsername} = p - - const authorRoleInTeam = useTeamsState(s => s.teamIDToMembers.get(teamID)?.get(author)?.type) - const showUser = useTrackerState(s => s.dispatch.showUser) - - const onAuthorClick = () => { - if (C.isMobile) { - navToProfile(showUsername) - } else { - showUser(showUsername, true) - } - } - - const authorIsOwner = authorRoleInTeam === 'owner' - const authorIsAdmin = authorRoleInTeam === 'admin' - const authorIsBot = teamname - ? authorRoleInTeam === 'restrictedbot' || authorRoleInTeam === 'bot' - : isAdhocBot - const allowCrown = teamType !== 'adhoc' && (authorIsOwner || authorIsAdmin) - - const usernameNode = ( - <Kb.ConnectedUsernames - colorBroken={true} - colorFollowing={true} - colorYou={true} - onUsernameClicked={onAuthorClick} - type="BodySmallBold" - usernames={showUsername} - virtualText={true} - className="separator-text" - /> - ) - - const ownerAdminTooltipIcon = allowCrown ? ( - <Kb.Box2 direction="vertical" tooltip={authorIsOwner ? 'Owner' : 'Admin'}> - <Kb.Icon - color={authorIsOwner ? Kb.Styles.globalColors.yellowDark : Kb.Styles.globalColors.black_35} - fontSize={10} - type="iconfont-crown-owner" - /> - </Kb.Box2> - ) : null - - const botIcon = authorIsBot ? ( - <Kb.Box2 direction="vertical" tooltip="Bot"> - <Kb.Icon fontSize={13} color={Kb.Styles.globalColors.black_35} type="iconfont-bot" /> - </Kb.Box2> - ) : null - - const botAliasOrUsername = botAlias ? ( - <Kb.Text type="BodySmallBold" style={styles.botAlias} lineClamp={1} className="separator-text"> - {botAlias} {' [' + showUsername + ']'} - </Kb.Text> - ) : ( - usernameNode - ) - - return ( - <> - <Kb.Avatar size={32} username={showUsername} onClick={onAuthorClick} style={styles.avatar} /> - <Kb.Box2 - pointerEvents="box-none" - key="author" - direction="horizontal" - style={styles.authorContainer} - gap="tiny" - > - <Kb.Box2 - pointerEvents="box-none" - direction="horizontal" - gap="xtiny" - fullWidth={true} - style={styles.usernameCrown} - > - {botAliasOrUsername} - {ownerAdminTooltipIcon} - {botIcon} - <Kb.Text type="BodyTiny" virtualText={true} className="separator-text"> - {formatTimeForChat(timestamp)} - </Kb.Text> - </Kb.Box2> - </Kb.Box2> - </> - ) -} - type Props = { leadingItem?: T.Chat.Ordinal trailingItem: T.Chat.Ordinal @@ -183,39 +44,25 @@ type Props = { function SeparatorConnector(p: Props) { const {leadingItem, trailingItem} = p const data = useSeparatorData(trailingItem, leadingItem ?? T.Chat.numberToOrdinal(0)) - const {ordinal, showUsername, orangeLineAbove, orangeTime} = data + const {ordinal, orangeLineAbove, orangeTime} = data - if (!ordinal || (!showUsername && !orangeLineAbove)) return null + if (!ordinal || !orangeLineAbove) return null return ( <Kb.Box2 direction="horizontal" - style={showUsername ? styles.container : styles.containerNoName} + style={styles.container} fullWidth={true} pointerEvents="box-none" className="WrapperMessage-hoverColor" > - {showUsername ? ( - <AuthorSection - author={data.author} - botAlias={data.botAlias} - isAdhocBot={data.isAdhocBot} - teamID={data.teamID} - teamType={data.teamType} - teamname={data.teamname} - timestamp={data.timestamp} - showUsername={showUsername} - /> - ) : null} - {orangeLineAbove ? ( - <Kb.Box2 key="orangeLine" direction="vertical" style={styles.orangeLine}> - {orangeTime ? ( - <Kb.Text type="BodyTiny" key="orangeLineLabel" style={styles.orangeLabel}> - {orangeTime} - </Kb.Text> - ) : null} - </Kb.Box2> - ) : null} + <Kb.Box2 key="orangeLine" direction="vertical" style={styles.orangeLine}> + {orangeTime ? ( + <Kb.Text type="BodyTiny" key="orangeLineLabel" style={styles.orangeLabel}> + {orangeTime} + </Kb.Text> + ) : null} + </Kb.Box2> </Kb.Box2> ) } @@ -223,46 +70,7 @@ function SeparatorConnector(p: Props) { const styles = Kb.Styles.styleSheetCreate( () => ({ - authorContainer: Kb.Styles.platformStyles({ - common: { - alignItems: 'flex-start', - alignSelf: 'flex-start', - marginLeft: Kb.Styles.isMobile ? 48 : 56, - }, - isElectron: { - marginBottom: 0, - marginTop: 0, - }, - isMobile: {marginTop: 8}, - }), - avatar: Kb.Styles.platformStyles({ - common: {position: 'absolute', top: 4}, - isElectron: { - left: Kb.Styles.globalMargins.small, - top: 4, - zIndex: 2, - }, - isMobile: {left: Kb.Styles.globalMargins.tiny}, - }), - botAlias: Kb.Styles.platformStyles({ - common: {color: Kb.Styles.globalColors.black}, - isElectron: { - maxWidth: 240, - wordBreak: 'break-all', - }, - isMobile: {maxWidth: 120}, - }), container: Kb.Styles.platformStyles({ - common: { - position: 'relative', - }, - isElectron: { - height: 21, - marginBottom: 0, - paddingTop: 5, - }, - }), - containerNoName: Kb.Styles.platformStyles({ common: { position: 'relative', }, @@ -297,15 +105,6 @@ const styles = Kb.Styles.styleSheetCreate( right: -16, }, }), - usernameCrown: Kb.Styles.platformStyles({ - isElectron: { - alignItems: 'baseline', - marginRight: 48, - position: 'relative', - top: -2, - }, - isMobile: {alignItems: 'center'}, - }), }) as const ) diff --git a/shared/chat/conversation/messages/set-channelname/wrapper.tsx b/shared/chat/conversation/messages/set-channelname/wrapper.tsx index 3d6520603613..aeeb105a307f 100644 --- a/shared/chat/conversation/messages/set-channelname/wrapper.tsx +++ b/shared/chat/conversation/messages/set-channelname/wrapper.tsx @@ -1,18 +1,17 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SetChannelnameType from './container' function WrapperSetChannelname(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const {ordinal, isCenteredHighlight} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData - if (message?.type !== 'setChannelname') return null + if (message.type !== 'setChannelname') return null if (message.newChannelname === 'general') return null const {default: SetChannelnameComponent} = require('./container') as {default: typeof SetChannelnameType} return ( - <WrapperMessage {...p} {...common}> + <WrapperMessage {...p} {...wrapper}> <SetChannelnameComponent message={message} /> </WrapperMessage> ) diff --git a/shared/chat/conversation/messages/set-description/wrapper.tsx b/shared/chat/conversation/messages/set-description/wrapper.tsx index 79799cca91c9..839da23862ba 100644 --- a/shared/chat/conversation/messages/set-description/wrapper.tsx +++ b/shared/chat/conversation/messages/set-description/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SetDescriptionType from './container' function WrapperSetDescription(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const {ordinal, isCenteredHighlight} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData - if (message?.type !== 'setDescription') return null + if (message.type !== 'setDescription') return null const {default: SetDescriptionComponent} = require('./container') as {default: typeof SetDescriptionType} return ( - <WrapperMessage {...p} {...common}> + <WrapperMessage {...p} {...wrapper}> <SetDescriptionComponent message={message} /> </WrapperMessage> ) diff --git a/shared/chat/conversation/messages/system-added-to-team/wrapper.tsx b/shared/chat/conversation/messages/system-added-to-team/wrapper.tsx index ea9d67704a2c..21e45e4a38c5 100644 --- a/shared/chat/conversation/messages/system-added-to-team/wrapper.tsx +++ b/shared/chat/conversation/messages/system-added-to-team/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemAddedToTeamType from './container' function SystemAddedToTeam(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const {ordinal, isCenteredHighlight} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData - if (message?.type !== 'systemAddedToTeam') return null + if (message.type !== 'systemAddedToTeam') return null const {default: SystemAddedToTeam} = require('./container') as {default: typeof SystemAddedToTeamType} return ( - <WrapperMessage {...p} {...common}> + <WrapperMessage {...p} {...wrapper}> <SystemAddedToTeam message={message} /> </WrapperMessage> ) diff --git a/shared/chat/conversation/messages/system-change-avatar/wrapper.tsx b/shared/chat/conversation/messages/system-change-avatar/wrapper.tsx index e03b5f46eded..a4ffc44d12d5 100644 --- a/shared/chat/conversation/messages/system-change-avatar/wrapper.tsx +++ b/shared/chat/conversation/messages/system-change-avatar/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemChangeAvatarType from '.' function SystemChangeAvatar(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const {ordinal, isCenteredHighlight} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData - if (message?.type !== 'systemChangeAvatar') return null + if (message.type !== 'systemChangeAvatar') return null const {default: SystemChangeAvatar} = require('.') as {default: typeof SystemChangeAvatarType} return ( - <WrapperMessage {...p} {...common}> + <WrapperMessage {...p} {...wrapper}> <SystemChangeAvatar message={message} /> </WrapperMessage> ) diff --git a/shared/chat/conversation/messages/system-change-retention/wrapper.tsx b/shared/chat/conversation/messages/system-change-retention/wrapper.tsx index c8729b0fda04..119448a72710 100644 --- a/shared/chat/conversation/messages/system-change-retention/wrapper.tsx +++ b/shared/chat/conversation/messages/system-change-retention/wrapper.tsx @@ -1,19 +1,18 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemChangeRetentionType from './container' function SystemChangeRetention(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const {ordinal, isCenteredHighlight} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData - if (message?.type !== 'systemChangeRetention') return null + if (message.type !== 'systemChangeRetention') return null const {default: SystemChangeRetention} = require('./container') as { default: typeof SystemChangeRetentionType } return ( - <WrapperMessage {...p} {...common}> + <WrapperMessage {...p} {...wrapper}> <SystemChangeRetention message={message} /> </WrapperMessage> ) diff --git a/shared/chat/conversation/messages/system-create-team/wrapper.tsx b/shared/chat/conversation/messages/system-create-team/wrapper.tsx index c3d224845552..12f31a815751 100644 --- a/shared/chat/conversation/messages/system-create-team/wrapper.tsx +++ b/shared/chat/conversation/messages/system-create-team/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemCreateTeamType from './container' function SystemCreateTeam(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const {ordinal, isCenteredHighlight} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData - if (message?.type !== 'systemCreateTeam') return null + if (message.type !== 'systemCreateTeam') return null const {default: SystemCreateTeam} = require('./container') as {default: typeof SystemCreateTeamType} return ( - <WrapperMessage {...p} {...common}> + <WrapperMessage {...p} {...wrapper}> <SystemCreateTeam message={message} /> </WrapperMessage> ) diff --git a/shared/chat/conversation/messages/system-git-push/wrapper.tsx b/shared/chat/conversation/messages/system-git-push/wrapper.tsx index b524726d4a32..b44d49840b27 100644 --- a/shared/chat/conversation/messages/system-git-push/wrapper.tsx +++ b/shared/chat/conversation/messages/system-git-push/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemGitPushType from './container' function SystemGitPush(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const {ordinal, isCenteredHighlight} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData - if (message?.type !== 'systemGitPush') return null + if (message.type !== 'systemGitPush') return null const {default: SystemGitPush} = require('./container') as {default: typeof SystemGitPushType} return ( - <WrapperMessage {...p} {...common}> + <WrapperMessage {...p} {...wrapper}> <SystemGitPush message={message} /> </WrapperMessage> ) diff --git a/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx b/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx index 4f9c3c107825..73ca0a66fd3a 100644 --- a/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx +++ b/shared/chat/conversation/messages/system-invite-accepted/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemInviteAcceptedType from './container' function WrapperSystemInvite(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const {ordinal, isCenteredHighlight} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData - if (message?.type !== 'systemInviteAccepted') return null + if (message.type !== 'systemInviteAccepted') return null const {default: SystemInviteAccepted} = require('./container') as {default: typeof SystemInviteAcceptedType} return ( - <WrapperMessage {...p} {...common}> + <WrapperMessage {...p} {...wrapper}> <SystemInviteAccepted key="systemInviteAccepted" message={message} /> </WrapperMessage> ) diff --git a/shared/chat/conversation/messages/system-joined/wrapper.tsx b/shared/chat/conversation/messages/system-joined/wrapper.tsx index a876ce759dc1..8d8d4d25e8dc 100644 --- a/shared/chat/conversation/messages/system-joined/wrapper.tsx +++ b/shared/chat/conversation/messages/system-joined/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemJoinedType from './container' function SystemJoined(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const {ordinal, isCenteredHighlight} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData - if (message?.type !== 'systemJoined') return null + if (message.type !== 'systemJoined') return null const {default: SystemJoined} = require('./container') as {default: typeof SystemJoinedType} return ( - <WrapperMessage {...p} {...common}> + <WrapperMessage {...p} {...wrapper}> <SystemJoined message={message} /> </WrapperMessage> ) diff --git a/shared/chat/conversation/messages/system-left/wrapper.tsx b/shared/chat/conversation/messages/system-left/wrapper.tsx index 3af2f7a2b166..a7aa5ab48e9d 100644 --- a/shared/chat/conversation/messages/system-left/wrapper.tsx +++ b/shared/chat/conversation/messages/system-left/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemLeftType from './container' function SystemLeft(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const {ordinal, isCenteredHighlight} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData - if (message?.type !== 'systemLeft') return null + if (message.type !== 'systemLeft') return null const {default: SystemLeft} = require('./container') as {default: typeof SystemLeftType} return ( - <WrapperMessage {...p} {...common}> + <WrapperMessage {...p} {...wrapper}> <SystemLeft /> </WrapperMessage> ) diff --git a/shared/chat/conversation/messages/system-new-channel/wrapper.tsx b/shared/chat/conversation/messages/system-new-channel/wrapper.tsx index 4065346fb8db..18e81fa51629 100644 --- a/shared/chat/conversation/messages/system-new-channel/wrapper.tsx +++ b/shared/chat/conversation/messages/system-new-channel/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemNewChannelType from './container' function SystemNewChannel(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const {ordinal, isCenteredHighlight} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData - if (message?.type !== 'systemNewChannel') return null + if (message.type !== 'systemNewChannel') return null const {default: SystemNewChannel} = require('./container') as {default: typeof SystemNewChannelType} return ( - <WrapperMessage {...p} {...common}> + <WrapperMessage {...p} {...wrapper}> <SystemNewChannel message={message} /> </WrapperMessage> ) diff --git a/shared/chat/conversation/messages/system-sbs-resolve/wrapper.tsx b/shared/chat/conversation/messages/system-sbs-resolve/wrapper.tsx index 725795b526ce..ee70c18983ad 100644 --- a/shared/chat/conversation/messages/system-sbs-resolve/wrapper.tsx +++ b/shared/chat/conversation/messages/system-sbs-resolve/wrapper.tsx @@ -1,16 +1,15 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemSBSResolvedType from './container' import type SystemJoinedType from '../system-joined/container' import {useCurrentUserState} from '@/stores/current-user' function WrapperSystemInvite(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const {ordinal, isCenteredHighlight} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData const you = useCurrentUserState(s => s.username) - if (message?.type !== 'systemSBSResolved') return null + if (message.type !== 'systemSBSResolved') return null const youAreAuthor = you === message.author const {default: SystemSBSResolved} = require('./container') as {default: typeof SystemSBSResolvedType} @@ -25,7 +24,7 @@ function WrapperSystemInvite(p: Props) { ) return ( - <WrapperMessage {...p} {...common}> + <WrapperMessage {...p} {...wrapper}> {child} </WrapperMessage> ) diff --git a/shared/chat/conversation/messages/system-simple-to-complex/wrapper.tsx b/shared/chat/conversation/messages/system-simple-to-complex/wrapper.tsx index a4a0eec4f6b6..b249ba3410f2 100644 --- a/shared/chat/conversation/messages/system-simple-to-complex/wrapper.tsx +++ b/shared/chat/conversation/messages/system-simple-to-complex/wrapper.tsx @@ -1,20 +1,19 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemSimpleToComplexType from './container' function WrapperSystemSimpleToComplex(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const {ordinal, isCenteredHighlight} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData - if (message?.type !== 'systemSimpleToComplex') return null + if (message.type !== 'systemSimpleToComplex') return null const {default: SystemSimpleToComplex} = require('./container') as { default: typeof SystemSimpleToComplexType } return ( - <WrapperMessage {...p} {...common}> + <WrapperMessage {...p} {...wrapper}> <SystemSimpleToComplex key="systemSimpleToComplex" message={message} /> </WrapperMessage> ) diff --git a/shared/chat/conversation/messages/system-text/wrapper.tsx b/shared/chat/conversation/messages/system-text/wrapper.tsx index f2b2afd8d106..b08f7d222238 100644 --- a/shared/chat/conversation/messages/system-text/wrapper.tsx +++ b/shared/chat/conversation/messages/system-text/wrapper.tsx @@ -1,17 +1,16 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemTextType from './container' function SystemText(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const {ordinal, isCenteredHighlight} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData - if (message?.type !== 'systemText') return null + if (message.type !== 'systemText') return null const {default: SystemText} = require('./container') as {default: typeof SystemTextType} return ( - <WrapperMessage {...p} {...common}> + <WrapperMessage {...p} {...wrapper}> <SystemText text={message.text.stringValue()} /> </WrapperMessage> ) diff --git a/shared/chat/conversation/messages/system-users-added-to-conv/wrapper.tsx b/shared/chat/conversation/messages/system-users-added-to-conv/wrapper.tsx index b956af223673..e87786c8b0fd 100644 --- a/shared/chat/conversation/messages/system-users-added-to-conv/wrapper.tsx +++ b/shared/chat/conversation/messages/system-users-added-to-conv/wrapper.tsx @@ -1,19 +1,18 @@ -import * as Chat from '@/stores/chat' -import {WrapperMessage, useCommon, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type SystemUsersAddedToConvType from './container' function SystemUsersAddedToConv(p: Props) { - const {ordinal} = p - const common = useCommon(ordinal) - const message = Chat.useChatContext(s => s.messageMap.get(ordinal)) + const {ordinal, isCenteredHighlight} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {message} = wrapper.messageData - if (message?.type !== 'systemUsersAddedToConversation') return null + if (message.type !== 'systemUsersAddedToConversation') return null const {default: SystemUsersAddedToConv} = require('./container') as { default: typeof SystemUsersAddedToConvType } return ( - <WrapperMessage {...p} {...common}> + <WrapperMessage {...p} {...wrapper}> <SystemUsersAddedToConv message={message} /> </WrapperMessage> ) diff --git a/shared/chat/conversation/messages/text/bottom.tsx b/shared/chat/conversation/messages/text/bottom.tsx index a42ef72f9778..d6540e0bfc0d 100644 --- a/shared/chat/conversation/messages/text/bottom.tsx +++ b/shared/chat/conversation/messages/text/bottom.tsx @@ -1,26 +1,41 @@ import type CoinFlipType from './coinflip' import type UnfurlListType from './unfurl/unfurl-list' import type UnfurlPromptListType from './unfurl/prompt-list/container' +import type * as T from '@/constants/types' type Props = { + author: string + conversationIDKey: T.Chat.ConversationIDKey hasUnfurlPrompts: boolean hasUnfurlList: boolean hasCoinFlip: boolean + messageID: T.Chat.MessageID + unfurls?: T.Chat.UnfurlMap } export const useBottom = (data: Props) => { - return <WrapperTextBottom hasCoinFlip={data.hasCoinFlip} hasUnfurlList={data.hasUnfurlList} hasUnfurlPrompts={data.hasUnfurlPrompts} /> + return ( + <WrapperTextBottom + author={data.author} + conversationIDKey={data.conversationIDKey} + hasCoinFlip={data.hasCoinFlip} + hasUnfurlList={data.hasUnfurlList} + hasUnfurlPrompts={data.hasUnfurlPrompts} + messageID={data.messageID} + unfurls={data.unfurls} + /> + ) } const WrapperTextBottom = function WrapperTextBottom(p: Props) { - const {hasUnfurlPrompts, hasUnfurlList, hasCoinFlip} = p + const {author, conversationIDKey, hasUnfurlPrompts, hasUnfurlList, hasCoinFlip, messageID, unfurls} = p const unfurlPrompts = (() => { if (hasUnfurlPrompts) { const {default: UnfurlPromptList} = require('./unfurl/prompt-list/container') as { default: typeof UnfurlPromptListType } - return <UnfurlPromptList /> + return <UnfurlPromptList messageID={messageID} /> } return null })() @@ -28,7 +43,7 @@ const WrapperTextBottom = function WrapperTextBottom(p: Props) { const unfurlList = (() => { const {default: UnfurlList} = require('./unfurl/unfurl-list') as {default: typeof UnfurlListType} if (hasUnfurlList) { - return <UnfurlList key="UnfurlList" /> + return <UnfurlList author={author} conversationIDKey={conversationIDKey} key="UnfurlList" unfurls={unfurls} /> } return null })() diff --git a/shared/chat/conversation/messages/text/reply.tsx b/shared/chat/conversation/messages/text/reply.tsx index 1de357127833..eab74216309b 100644 --- a/shared/chat/conversation/messages/text/reply.tsx +++ b/shared/chat/conversation/messages/text/reply.tsx @@ -1,12 +1,11 @@ -import * as C from '@/constants' -import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' import * as React from 'react' -import {useOrdinal, useIsHighlighted} from '../ids-context' +import * as Chat from '@/stores/chat' +import {useIsHighlighted} from '../ids-context' import type * as T from '@/constants/types' -export const useReply = (showReplyTo: boolean) => { - return showReplyTo ? <Reply /> : null +export const useReply = (replyTo?: T.Chat.MessageReplyTo, onClick?: () => void) => { + return replyTo ? <Reply replyTo={replyTo} onClick={onClick} /> : null } const ReplyToContext = React.createContext<T.Chat.MessageReplyTo>(null!) @@ -75,7 +74,7 @@ type RS = { showImage: boolean showEdited: boolean isDeleted: boolean - onClick: () => void + onClick?: () => void } function ReplyStructure(p: RS) { @@ -118,20 +117,8 @@ function ReplyStructure(p: RS) { ) } -function Reply() { - const ordinal = useOrdinal() - const {replyTo, replyJump} = Chat.useChatContext( - C.useShallow(s => { - const m = s.messageMap.get(ordinal) - return {replyJump: s.dispatch.replyJump, replyTo: m?.type === 'text' ? m.replyTo : undefined} - }) - ) - const onClick = () => { - const id = replyTo?.id ?? 0 - id && replyJump(id) - } - - if (!replyTo?.id) return null +function Reply({replyTo, onClick}: {onClick?: () => void; replyTo: T.Chat.MessageReplyTo}) { + if (!replyTo.id) return null const showEdited = !!replyTo.hasBeenEdited const isDeleted = replyTo.exploded || replyTo.type === 'deleted' diff --git a/shared/chat/conversation/messages/text/unfurl/prompt-list/container.tsx b/shared/chat/conversation/messages/text/unfurl/prompt-list/container.tsx index afc8741958ac..8f52867ca53e 100644 --- a/shared/chat/conversation/messages/text/unfurl/prompt-list/container.tsx +++ b/shared/chat/conversation/messages/text/unfurl/prompt-list/container.tsx @@ -1,21 +1,15 @@ import * as C from '@/constants' import * as Chat from '@/stores/chat' -import {useOrdinal} from '@/chat/conversation/messages/ids-context' import * as T from '@/constants/types' import * as Kb from '@/common-adapters' import Prompt from './prompt' -const noMessageID = T.Chat.numberToMessageID(0) - -function UnfurlPromptListContainer() { - const ordinal = useOrdinal() - const {unfurlResolvePrompt, messageID, promptDomains} = Chat.useChatContext( +function UnfurlPromptListContainer({messageID}: {messageID: T.Chat.MessageID}) { + const {unfurlResolvePrompt, promptDomains} = Chat.useChatContext( C.useShallow(s => { - const message = s.messageMap.get(ordinal) - const messageID = message?.type === 'text' ? message.id : noMessageID const unfurlResolvePrompt = s.dispatch.unfurlResolvePrompt const promptDomains = s.unfurlPrompt.get(messageID) - return {messageID, promptDomains, unfurlResolvePrompt} + return {promptDomains, unfurlResolvePrompt} }) ) const _setPolicy = (domain: string, result: T.RPCChat.UnfurlPromptResult) => { diff --git a/shared/chat/conversation/messages/text/unfurl/unfurl-list/generic.tsx b/shared/chat/conversation/messages/text/unfurl/unfurl-list/generic.tsx index d28ca29110bb..3c7850a47e78 100644 --- a/shared/chat/conversation/messages/text/unfurl/unfurl-list/generic.tsx +++ b/shared/chat/conversation/messages/text/unfurl/unfurl-list/generic.tsx @@ -1,75 +1,42 @@ -import * as C from '@/constants' -import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters/index' import * as T from '@/constants/types' import UnfurlImage from './image' -import {useOrdinal} from '@/chat/conversation/messages/ids-context' import {formatTimeForMessages} from '@/util/timestamp' -import {getUnfurlInfo, useActions} from './use-state' - -function UnfurlGeneric(p: {idx: number}) { - const {idx} = p - const ordinal = useOrdinal() - - const data = Chat.useChatContext( - C.useShallow(s => { - const {unfurl, isCollapsed, unfurlMessageID, youAreAuthor} = getUnfurlInfo(s, ordinal, idx) - if (unfurl?.unfurlType !== T.RPCChat.UnfurlType.generic) { - return null - } - const {generic} = unfurl - const {description, publishTime, favicon, media, siteName, title, url} = generic - const {height, width, isVideo, url: mediaUrl} = media || {height: 0, isVideo: false, url: '', width: 0} - const showImageOnSide = - !Kb.Styles.isMobile && height >= width && !isVideo && (title.length > 0 || !!description) - const imageLocation = isCollapsed - ? 'collapsed' - : showImageOnSide - ? 'side' - : width > 0 && height > 0 - ? 'bottom' - : 'none' - - return { - description: description || undefined, - favicon: favicon?.url, - height, - imageLocation, - isCollapsed, - isVideo, - mediaUrl, - publishTime: publishTime ? publishTime * 1000 : 0, - siteName, - title, - unfurlMessageID, - url, - width, - youAreAuthor, - } - }) - ) +import {useActions} from './use-state' +function UnfurlGeneric(p: { + author: string + conversationIDKey: T.Chat.ConversationIDKey + ordinal: T.Chat.Ordinal + unfurlInfo: T.RPCChat.UIMessageUnfurlInfo + youAreAuthor: boolean +}) { + const {ordinal, unfurlInfo, youAreAuthor} = p + const {isCollapsed, unfurl, unfurlMessageID} = unfurlInfo const {onClose, onToggleCollapse} = useActions( - data?.youAreAuthor ?? false, - T.Chat.numberToMessageID(data?.unfurlMessageID ?? 0), + youAreAuthor, + T.Chat.numberToMessageID(unfurlMessageID), ordinal ) - - const titleUrlProps = Kb.useClickURL(data?.url ?? '') - - if (!data) return null - - const {description, favicon, height, isCollapsed, isVideo, publishTime} = data - const {siteName, title, url, width, imageLocation, mediaUrl} = data + const generic = unfurl.unfurlType === T.RPCChat.UnfurlType.generic ? unfurl.generic : undefined + const titleUrlProps = Kb.useClickURL(generic?.mapInfo ? '' : (generic?.url ?? '')) + if (!generic || generic.mapInfo) { + return null + } + const {description, publishTime, favicon, media, siteName, title, url} = generic + const {height, width, isVideo, url: mediaUrl} = media || {height: 0, isVideo: false, url: '', width: 0} + const showImageOnSide = + !Kb.Styles.isMobile && height >= width && !isVideo && (title.length > 0 || !!description) + const imageLocation = isCollapsed ? 'collapsed' : showImageOnSide ? 'side' : width > 0 && height > 0 ? 'bottom' : 'none' const publisher = ( <Kb.Box2 style={styles.siteNameContainer} gap="tiny" fullWidth={true} direction="horizontal"> - {favicon ? <Kb.Image src={favicon} style={styles.favicon} /> : null} + {favicon?.url ? <Kb.Image src={favicon.url} style={styles.favicon} /> : null} <Kb.BoxGrow> <Kb.Text type="BodySmall" lineClamp={1}> {siteName} {publishTime ? ( - <Kb.Text type="BodySmall"> • Published {formatTimeForMessages(publishTime)}</Kb.Text> + <Kb.Text type="BodySmall"> • Published {formatTimeForMessages(publishTime * 1000)}</Kb.Text> ) : null} </Kb.Text> </Kb.BoxGrow> @@ -108,13 +75,13 @@ function UnfurlGeneric(p: {idx: number}) { imageLocation === 'bottom' ? ( <Kb.Box2 direction="vertical" fullWidth={true}> <UnfurlImage - url={mediaUrl || ''} + url={mediaUrl} linkURL={url} - height={height || 0} - width={width || 0} + height={height} + width={width} widthPadding={Kb.Styles.isMobile ? Kb.Styles.globalMargins.tiny : undefined} style={styles.bottomImage} - isVideo={isVideo || false} + isVideo={isVideo} autoplayVideo={false} /> </Kb.Box2> diff --git a/shared/chat/conversation/messages/text/unfurl/unfurl-list/giphy.tsx b/shared/chat/conversation/messages/text/unfurl/unfurl-list/giphy.tsx index 19a93923633a..05d35522cb29 100644 --- a/shared/chat/conversation/messages/text/unfurl/unfurl-list/giphy.tsx +++ b/shared/chat/conversation/messages/text/unfurl/unfurl-list/giphy.tsx @@ -1,47 +1,28 @@ -import * as C from '@/constants' -import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters/index' import UnfurlImage from './image' import * as T from '@/constants/types' -import {useOrdinal} from '@/chat/conversation/messages/ids-context' -import {getUnfurlInfo, useActions} from './use-state' - -function UnfurlGiphy(p: {idx: number}) { - const {idx} = p - const ordinal = useOrdinal() - - const data = Chat.useChatContext( - C.useShallow(s => { - const {unfurl, isCollapsed, unfurlMessageID, youAreAuthor} = getUnfurlInfo(s, ordinal, idx) - if (unfurl?.unfurlType !== T.RPCChat.UnfurlType.giphy) { - return null - } - const {giphy} = unfurl - const {favicon, image, video} = giphy - const {height, isVideo, url, width} = video || image || {height: 0, isVideo: false, url: '', width: 0} - - return { - favicon: favicon?.url, - height, - isCollapsed, - isVideo, - unfurlMessageID, - url, - width, - youAreAuthor, - } - }) - ) +import {useActions} from './use-state' +function UnfurlGiphy(p: { + author: string + conversationIDKey: T.Chat.ConversationIDKey + ordinal: T.Chat.Ordinal + unfurlInfo: T.RPCChat.UIMessageUnfurlInfo + youAreAuthor: boolean +}) { + const {ordinal, unfurlInfo, youAreAuthor} = p + const {isCollapsed, unfurl, unfurlMessageID} = unfurlInfo const {onClose, onToggleCollapse} = useActions( - data?.youAreAuthor ?? false, - T.Chat.numberToMessageID(data?.unfurlMessageID ?? 0), + youAreAuthor, + T.Chat.numberToMessageID(unfurlMessageID), ordinal ) - - if (data === null) return null - - const {favicon, isCollapsed, isVideo, url, width, height} = data + if (unfurl.unfurlType !== T.RPCChat.UnfurlType.giphy) { + return null + } + const {giphy} = unfurl + const {favicon, image, video} = giphy + const {height, isVideo, url, width} = video || image || {height: 0, isVideo: false, url: '', width: 0} return ( <Kb.Box2 style={styles.container} gap="tiny" direction="horizontal"> @@ -49,7 +30,7 @@ function UnfurlGiphy(p: {idx: number}) { <Kb.Box2 style={styles.innerContainer} gap="xtiny" direction="vertical"> <Kb.Box2 style={styles.siteNameContainer} gap="tiny" fullWidth={true} direction="horizontal" justifyContent="space-between"> <Kb.Box2 direction="horizontal" gap="tiny"> - {favicon ? <Kb.Image src={favicon} style={styles.favicon} /> : null} + {favicon?.url ? <Kb.Image src={favicon.url} style={styles.favicon} /> : null} <Kb.Text type="BodySmall"> Giphy </Kb.Text> diff --git a/shared/chat/conversation/messages/text/unfurl/unfurl-list/index.tsx b/shared/chat/conversation/messages/text/unfurl/unfurl-list/index.tsx index bb404d92acd7..dde60932c1b6 100644 --- a/shared/chat/conversation/messages/text/unfurl/unfurl-list/index.tsx +++ b/shared/chat/conversation/messages/text/unfurl/unfurl-list/index.tsx @@ -1,5 +1,3 @@ -import * as C from '@/constants' -import * as Chat from '@/stores/chat' import * as T from '@/constants/types' import type * as React from 'react' import UnfurlGeneric from './generic' @@ -7,30 +5,14 @@ import UnfurlGiphy from './giphy' import UnfurlMap from './map' import * as Kb from '@/common-adapters' import {useOrdinal} from '@/chat/conversation/messages/ids-context' +import {useCurrentUserState} from '@/stores/current-user' -export type UnfurlListItem = { - unfurl: T.RPCChat.UnfurlDisplay - url: string - isCollapsed: boolean - onClose?: () => void - onCollapse: () => void -} - -export type ListProps = { - isAuthor: boolean - author?: string - toggleMessagePopup: () => void - unfurls: Array<UnfurlListItem> -} - -export type UnfurlProps = { - isAuthor: boolean - author?: string - isCollapsed: boolean - onClose?: () => void - onCollapse: () => void - toggleMessagePopup: () => void - unfurl: T.RPCChat.UnfurlDisplay +type UnfurlItemProps = { + author: string + conversationIDKey: T.Chat.ConversationIDKey + ordinal: T.Chat.Ordinal + unfurlInfo: T.RPCChat.UIMessageUnfurlInfo + youAreAuthor: boolean } const styles = Kb.Styles.styleSheetCreate( @@ -49,34 +31,51 @@ const styles = Kb.Styles.styleSheetCreate( type UnfurlRenderType = 'generic' | 'map' | 'giphy' -const renderTypeToClass = new Map<UnfurlRenderType, React.ComponentType<{idx: number}>>([ +const renderTypeToClass = new Map<UnfurlRenderType, React.ComponentType<UnfurlItemProps>>([ ['generic', UnfurlGeneric], ['map', UnfurlMap], ['giphy', UnfurlGiphy], ]) -function UnfurlListContainer() { +function UnfurlListContainer({ + author, + conversationIDKey, + unfurls, +}: { + author: string + conversationIDKey: T.Chat.ConversationIDKey + unfurls?: T.Chat.UnfurlMap +}) { const ordinal = useOrdinal() - const unfurlTypes: Array<UnfurlRenderType | 'none'> = Chat.useChatContext( - C.useShallow(s => - [...(s.messageMap.get(ordinal)?.unfurls?.values() ?? [])].map(u => { - const ut = u.unfurl.unfurlType + const you = useCurrentUserState(s => s.username) + const youAreAuthor = author === you + const items = [...(unfurls?.values() ?? [])] + return ( + <Kb.Box2 direction="vertical" gap="tiny" style={styles.container}> + {items.map((unfurlInfo, idx) => { + const ut = unfurlInfo.unfurl.unfurlType + let renderType: UnfurlRenderType | 'none' switch (ut) { case T.RPCChat.UnfurlType.giphy: - return 'giphy' + renderType = 'giphy' + break case T.RPCChat.UnfurlType.generic: - return u.unfurl.generic.mapInfo ? 'map' : 'generic' + renderType = unfurlInfo.unfurl.generic.mapInfo ? 'map' : 'generic' + break default: - return 'none' + renderType = 'none' } - }) - ) - ) - return ( - <Kb.Box2 direction="vertical" gap="tiny" style={styles.container}> - {unfurlTypes.map((ut, idx) => { - const Clazz = ut === 'none' ? null : renderTypeToClass.get(ut) - return Clazz ? <Clazz key={String(idx)} idx={idx} /> : null + const Clazz = renderType === 'none' ? null : renderTypeToClass.get(renderType) + return Clazz ? ( + <Clazz + author={author} + conversationIDKey={conversationIDKey} + key={String(idx)} + ordinal={ordinal} + unfurlInfo={unfurlInfo} + youAreAuthor={youAreAuthor} + /> + ) : null })} </Kb.Box2> ) diff --git a/shared/chat/conversation/messages/text/unfurl/unfurl-list/map.tsx b/shared/chat/conversation/messages/text/unfurl/unfurl-list/map.tsx index 2cfe5c6dad44..2173db6e3002 100644 --- a/shared/chat/conversation/messages/text/unfurl/unfurl-list/map.tsx +++ b/shared/chat/conversation/messages/text/unfurl/unfurl-list/map.tsx @@ -1,64 +1,37 @@ import * as C from '@/constants' -import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters/index' import * as T from '@/constants/types' import * as React from 'react' import UnfurlImage from './image' -import {useOrdinal} from '@/chat/conversation/messages/ids-context' import {formatDurationForLocation} from '@/util/timestamp' -import {getUnfurlInfo} from './use-state' import {maxWidth} from '@/chat/conversation/messages/attachment/shared' -function UnfurlMap(p: {idx: number}) { - const {idx} = p - const ordinal = useOrdinal() +function UnfurlMap(p: { + author: string + conversationIDKey: T.Chat.ConversationIDKey + ordinal: T.Chat.Ordinal + unfurlInfo: T.RPCChat.UIMessageUnfurlInfo + youAreAuthor: boolean +}) { + const {author, conversationIDKey, unfurlInfo, youAreAuthor} = p const navigateAppend = C.Router2.navigateAppend - - const data = Chat.useChatContext( - C.useShallow(s => { - const {unfurl, youAreAuthor, author} = getUnfurlInfo(s, ordinal, idx) - if (unfurl?.unfurlType !== T.RPCChat.UnfurlType.generic) { - return null - } - const {generic} = unfurl - const {mapInfo, media, url} = generic - const {coord, isLiveLocationDone, liveLocationEndTime, time} = mapInfo || { - coord: {accuracy: 0, lat: 0, lon: 0}, - isLiveLocationDone: false, - liveLocationEndTime: 0, - time: 0, - } - const {height, width, url: imageURL} = media || {height: 0, url: '', width: 0} - const {id} = s - - return { - author, - coord, - height, - id, - imageURL, - isLiveLocationDone, - liveLocationEndTime, - time, - url, - width, - youAreAuthor, - } - }) - ) - - if (!data) { + const {unfurl} = unfurlInfo + if (unfurl.unfurlType !== T.RPCChat.UnfurlType.generic) { return null } - - const {author, url, coord, isLiveLocationDone, liveLocationEndTime} = data - const {height, width, imageURL, youAreAuthor, time, id} = data + const {generic} = unfurl + const {mapInfo, media, url} = generic + if (!mapInfo) { + return null + } + const {coord, isLiveLocationDone, liveLocationEndTime, time} = mapInfo + const {height, width, url: imageURL} = media || {height: 0, url: '', width: 0} const onViewMap = () => { navigateAppend({ name: 'chatUnfurlMapPopup', params: { author, - conversationIDKey: id, + conversationIDKey, coord, isAuthor: youAreAuthor, isLiveLocation: !!liveLocationEndTime && !isLiveLocationDone, diff --git a/shared/chat/conversation/messages/text/unfurl/unfurl-list/use-state.tsx b/shared/chat/conversation/messages/text/unfurl/unfurl-list/use-state.tsx index 666461afda8d..39d979e542f9 100644 --- a/shared/chat/conversation/messages/text/unfurl/unfurl-list/use-state.tsx +++ b/shared/chat/conversation/messages/text/unfurl/unfurl-list/use-state.tsx @@ -1,6 +1,5 @@ import * as Chat from '@/stores/chat' import type * as T from '@/constants/types' -import {useCurrentUserState} from '@/stores/current-user' export const useActions = (youAreAuthor: boolean, messageID: T.Chat.MessageID, ordinal: T.Chat.Ordinal) => { const unfurlRemove = Chat.useChatContext(s => s.dispatch.unfurlRemove) @@ -14,17 +13,3 @@ export const useActions = (youAreAuthor: boolean, messageID: T.Chat.MessageID, o return {onClose: youAreAuthor ? onClose : undefined, onToggleCollapse} } - -export const getUnfurlInfo = (state: Chat.ConvoState, ordinal: T.Chat.Ordinal, idx: number) => { - const message = state.messageMap.get(ordinal) - const author = message?.author - const you = useCurrentUserState.getState().username - const youAreAuthor = author === you - const unfurlInfo: undefined | T.RPCChat.UIMessageUnfurlInfo = [...(message?.unfurls?.values() ?? [])][idx] - - if (!unfurlInfo) - return {author: '', isCollapsed: false, unfurl: null, unfurlMessageID: 0, youAreAuthor: false} - - const {isCollapsed, unfurl, unfurlMessageID} = unfurlInfo - return {author, isCollapsed, unfurl, unfurlMessageID, youAreAuthor} -} diff --git a/shared/chat/conversation/messages/text/wrapper.tsx b/shared/chat/conversation/messages/text/wrapper.tsx index 4c99c716becd..59602f283178 100644 --- a/shared/chat/conversation/messages/text/wrapper.tsx +++ b/shared/chat/conversation/messages/text/wrapper.tsx @@ -1,10 +1,8 @@ import * as Kb from '@/common-adapters' -import * as React from 'react' import {useReply} from './reply' import {useBottom} from './bottom' import {useOrdinal} from '../ids-context' -import {SetRecycleTypeContext} from '../../recycle-type-context' -import {WrapperMessage, useCommonWithData, useMessageData, type Props} from '../wrapper/wrapper' +import {WrapperMessage, useWrapperMessageWithMessage, type Props} from '../wrapper/wrapper' import type {StyleOverride} from '@/common-adapters/markdown' import {sharedStyles} from '../shared-styles' @@ -46,30 +44,27 @@ function MessageMarkdown({style, text}: {style: Kb.Styles.StylesCrossPlatform; t } function WrapperText(p: Props) { - const {ordinal} = p - const messageData = useMessageData(ordinal) - const common = useCommonWithData(ordinal, messageData) - const {type, showCenteredHighlight} = common - const {isEditing, hasReactions} = messageData + const {ordinal, isCenteredHighlight = false} = p + const wrapper = useWrapperMessageWithMessage(ordinal, isCenteredHighlight) + const {messageData} = wrapper + const {isEditing, message, replyTo} = messageData - const {hasCoinFlip, hasUnfurlList, hasUnfurlPrompts, textType, showReplyTo, text} = messageData - const bottomChildren = useBottom({hasCoinFlip, hasUnfurlList, hasUnfurlPrompts}) - const reply = useReply(showReplyTo) - - const setRecycleType = React.useContext(SetRecycleTypeContext) - - React.useEffect(() => { - let subType = '' - if (showReplyTo) { - subType += ':reply' - } - if (hasReactions) { - subType += ':reactions' - } - if (subType.length) { - setRecycleType(ordinal, 'text' + subType) - } - }, [ordinal, showReplyTo, hasReactions, setRecycleType]) + const {hasCoinFlip, hasUnfurlList, hasUnfurlPrompts, showCenteredHighlight, text, textType, type} = + messageData + const bottomChildren = useBottom({ + author: message.author, + conversationIDKey: message.conversationIDKey, + hasCoinFlip, + hasUnfurlList, + hasUnfurlPrompts, + messageID: message.id, + unfurls: message.type === 'text' ? message.unfurls : undefined, + }) + const onReplyClick = () => { + const id = replyTo?.id ?? 0 + id && messageData.replyJump(id) + } + const reply = useReply(replyTo, onReplyClick) const style = getStyle(textType, isEditing, showCenteredHighlight) @@ -87,7 +82,7 @@ function WrapperText(p: Props) { } return ( - <WrapperMessage {...p} {...common} bottomChildren={bottomChildren} messageData={messageData}> + <WrapperMessage {...p} {...wrapper} bottomChildren={bottomChildren}> {children} </WrapperMessage> ) diff --git a/shared/chat/conversation/messages/wrapper/exploding-height-retainer/container.tsx b/shared/chat/conversation/messages/wrapper/exploding-height-retainer/container.tsx deleted file mode 100644 index febd6e851b91..000000000000 --- a/shared/chat/conversation/messages/wrapper/exploding-height-retainer/container.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import * as C from '@/constants' -import * as Chat from '@/stores/chat' -import type * as React from 'react' -import ExplodingHeightRetainer from '.' -import {useOrdinal} from '../../ids-context' - -type OwnProps = { - children: React.ReactElement -} - -function ExplodingHeightRetainerContainer(p: OwnProps) { - const ordinal = useOrdinal() - const {children} = p - const {forceAsh, exploding, exploded, explodedBy, messageKey} = Chat.useChatContext( - C.useShallow(s => { - const m = s.messageMap.get(ordinal) - const forceAsh = !!m?.explodingUnreadable - const exploding = !!m?.exploding - const exploded = !!m?.exploded - const explodedBy = m?.explodedBy - const messageKey = m ? Chat.getMessageKey(m) : '' - return {exploded, explodedBy, exploding, forceAsh, messageKey} - }) - ) - - const retainHeight = forceAsh || exploded - - const props = { - children, - exploded, - explodedBy, - exploding, - messageKey, - retainHeight, - } - - return <ExplodingHeightRetainer {...props} /> -} - -export default ExplodingHeightRetainerContainer diff --git a/shared/chat/conversation/messages/wrapper/exploding-meta.tsx b/shared/chat/conversation/messages/wrapper/exploding-meta.tsx index 9ccc5491695f..909bb3d95e34 100644 --- a/shared/chat/conversation/messages/wrapper/exploding-meta.tsx +++ b/shared/chat/conversation/messages/wrapper/exploding-meta.tsx @@ -1,43 +1,24 @@ -import * as C from '@/constants' -import * as Chat from '@/stores/chat' import * as React from 'react' -import {useIsHighlighted, useOrdinal} from '../ids-context' +import {useIsHighlighted} from '../ids-context' import * as Kb from '@/common-adapters' import {addTicker, removeTicker} from '@/util/second-timer' import {formatDurationShort} from '@/util/timestamp' import SharedTimer from './shared-timers' import {animationDuration} from './exploding-height-retainer' +import type * as T from '@/constants/types' -export type OwnProps = {onClick?: () => void} +export type OwnProps = { + exploded: boolean + exploding: boolean + explodesAt: number + messageKey: string + onClick?: () => void + submitState?: T.Chat.Message['submitState'] +} function ExplodingMetaContainer(p: OwnProps) { - const {onClick} = p - const ordinal = useOrdinal() + const {exploded, exploding, explodesAt, messageKey, onClick, submitState} = p const [now, setNow] = React.useState(() => Date.now()) - - const {exploding, exploded, submitState, explodesAt, messageKey} = Chat.useChatContext( - C.useShallow(s => { - const message = s.messageMap.get(ordinal) - if (!message || (message.type !== 'text' && message.type !== 'attachment') || !message.exploding) { - return { - exploded: false, - explodesAt: 0, - exploding: false, - messageKey: '', - submitState: '', - } - } - const messageKey = Chat.getMessageKey(message) - const {exploding, exploded, submitState, explodingTime: explodesAt} = message - return { - exploded, - explodesAt, - exploding, - messageKey, - submitState, - } - }) - ) const pending = submitState === 'pending' || submitState === 'failed' const lastMessageKeyRef = React.useRef(messageKey) diff --git a/shared/chat/conversation/messages/wrapper/index.tsx b/shared/chat/conversation/messages/wrapper/index.tsx index 111a337d0fe2..16d8e237ea13 100644 --- a/shared/chat/conversation/messages/wrapper/index.tsx +++ b/shared/chat/conversation/messages/wrapper/index.tsx @@ -1,3 +1,8 @@ +import * as Chat from '@/stores/chat' +import {chatDebugEnabled} from '@/constants/chat/debug' +import logger from '@/logger' +import {PerfProfiler} from '@/perf/react-profiler' +import type * as React from 'react' import Text from '../text/wrapper' import { WrapperAttachmentAudio, @@ -27,37 +32,80 @@ import SetChannelname from '../set-channelname/wrapper' import {type Props} from './wrapper' import type * as T from '@/constants/types' -const typeMap = { - 'attachment:audio': WrapperAttachmentAudio, - 'attachment:file': WrapperAttachmentFile, - 'attachment:image': WrapperAttachmentImage, - 'attachment:video': WrapperAttachmentVideo, - journeycard: JourneyCard, - pin: Pin, - placeholder: Placeholder, - requestPayment: Payment, - sendPayment: Payment, - setChannelname: SetChannelname, - setDescription: SetDescription, - systemAddedToTeam: SystemAddedToTeam, - systemChangeAvatar: SystemChangeAvatar, - systemChangeRetention: SystemChangeRetention, - systemCreateTeam: SystemCreateTeam, - systemGitPush: SystemGitPush, - systemInviteAccepted: SystemInviteAccepted, - systemJoined: SystemJoined, - systemLeft: SystemLeft, - systemNewChannel: SystemNewChannel, - systemSBSResolved: SystemSBSResolved, - systemSimpleToComplex: SystemSimpleToComplex, - systemText: SystemText, - systemUsersAddedToConversation: SystemUsersAddedToConv, - text: Text, -} satisfies Partial<Record<T.Chat.RenderMessageType, React.ComponentType<Props>>> as unknown as Record< - T.Chat.RenderMessageType, - React.ComponentType<Props> | undefined -> +const renderMessageRow = (type: T.Chat.RenderMessageType, p: Props): React.ReactNode => { + switch (type) { + case 'attachment:audio': + return <WrapperAttachmentAudio {...p} /> + case 'attachment:file': + return <WrapperAttachmentFile {...p} /> + case 'attachment:image': + return <WrapperAttachmentImage {...p} /> + case 'attachment:video': + return <WrapperAttachmentVideo {...p} /> + case 'journeycard': + return <JourneyCard {...p} /> + case 'pin': + return <Pin {...p} /> + case 'placeholder': + return <Placeholder {...p} /> + case 'requestPayment': + case 'sendPayment': + return <Payment {...p} /> + case 'setChannelname': + return <SetChannelname {...p} /> + case 'setDescription': + return <SetDescription {...p} /> + case 'systemAddedToTeam': + return <SystemAddedToTeam {...p} /> + case 'systemChangeAvatar': + return <SystemChangeAvatar {...p} /> + case 'systemChangeRetention': + return <SystemChangeRetention {...p} /> + case 'systemCreateTeam': + return <SystemCreateTeam {...p} /> + case 'systemGitPush': + return <SystemGitPush {...p} /> + case 'systemInviteAccepted': + return <SystemInviteAccepted {...p} /> + case 'systemJoined': + return <SystemJoined {...p} /> + case 'systemLeft': + return <SystemLeft {...p} /> + case 'systemNewChannel': + return <SystemNewChannel {...p} /> + case 'systemSBSResolved': + return <SystemSBSResolved {...p} /> + case 'systemSimpleToComplex': + return <SystemSimpleToComplex {...p} /> + case 'systemText': + return <SystemText {...p} /> + case 'systemUsersAddedToConversation': + return <SystemUsersAddedToConv {...p} /> + case 'text': + return <Text {...p} /> + case 'deleted': + return null + default: + return null + } +} -export const getMessageRender = (type: T.Chat.RenderMessageType) => { - return type === 'deleted' ? undefined : typeMap[type] +export const MessageRow = function MessageRow(p: Props) { + const {ordinal} = p + const type = Chat.useChatContext(s => s.messageTypeMap.get(ordinal) ?? 'text') + const content = renderMessageRow(type, p) + if (!content) { + if (type === 'deleted') { + return null + } + if (chatDebugEnabled) { + logger.error('[CHATDEBUG] no rendertype', {ordinal, type}) + } + return null + } + return ( + <PerfProfiler id={`Msg-${type}`}> + {content} + </PerfProfiler> + ) } diff --git a/shared/chat/conversation/messages/wrapper/long-pressable/index.native.tsx b/shared/chat/conversation/messages/wrapper/long-pressable/index.native.tsx index 5eb53f07a084..af0405059589 100644 --- a/shared/chat/conversation/messages/wrapper/long-pressable/index.native.tsx +++ b/shared/chat/conversation/messages/wrapper/long-pressable/index.native.tsx @@ -1,10 +1,12 @@ -import * as C from '@/constants' import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' import * as React from 'react' import type {Props} from '.' import {useOrdinal} from '../../ids-context' -import Swipeable, {type SwipeableMethods, SwipeDirection} from 'react-native-gesture-handler/ReanimatedSwipeable' +import Swipeable, { + type SwipeableMethods, + SwipeDirection, +} from 'react-native-gesture-handler/ReanimatedSwipeable' import {Pressable, Keyboard} from 'react-native' import {FocusContext} from '@/chat/conversation/normal/context' import * as Reanimated from 'react-native-reanimated' @@ -27,22 +29,18 @@ function LongPressable(props: Props) { const onPress = () => Keyboard.dismiss() const inner = ( - <Pressable - style={[styles.pressable, style]} - onLongPress={onLongPress} - onPress={onPress} - > + <Pressable style={[styles.pressable, style]} onLongPress={onLongPress} onPress={onPress}> {children} </Pressable> ) - const makeAction = (_progress: Reanimated.SharedValue<number>, translation: Reanimated.SharedValue<number>) => ( - <ReplyIcon progress={translation} /> - ) + const makeAction = ( + _progress: Reanimated.SharedValue<number>, + translation: Reanimated.SharedValue<number> + ) => <ReplyIcon progress={translation} /> - const {toggleThreadSearch, setReplyTo} = Chat.useChatContext( - C.useShallow(s => ({setReplyTo: s.dispatch.setReplyTo, toggleThreadSearch: s.dispatch.toggleThreadSearch})) - ) + const toggleThreadSearch = Chat.useChatContext(s => s.dispatch.toggleThreadSearch) + const setReplyTo = Chat.useChatUIContext(s => s.dispatch.setReplyTo) const ordinal = useOrdinal() const {focusInput} = React.useContext(FocusContext) const onSwipeLeft = () => { diff --git a/shared/chat/conversation/messages/wrapper/send-indicator.tsx b/shared/chat/conversation/messages/wrapper/send-indicator.tsx index eeebfae55d31..ac464cd4692c 100644 --- a/shared/chat/conversation/messages/wrapper/send-indicator.tsx +++ b/shared/chat/conversation/messages/wrapper/send-indicator.tsx @@ -1,6 +1,3 @@ -import * as C from '@/constants' -import * as Chat from '@/stores/chat' -import {useOrdinal} from '../ids-context' import * as React from 'react' import * as Kb from '@/common-adapters' import {useColorScheme} from 'react-native' @@ -48,24 +45,15 @@ const statusToIconDarkExploding = { const shownEncryptingSet = new Set() -function SendIndicatorContainer() { - const ordinal = useOrdinal() - - const {isExploding, sent, failed, id} = Chat.useChatContext( - C.useShallow(s => { - const message = s.messageMap.get(ordinal) - return { - failed: - (message?.type === 'text' || message?.type === 'attachment') && message.submitState === 'failed', - id: message?.timestamp, - isExploding: !!message?.exploding, - sent: - (message?.type !== 'text' && message?.type !== 'attachment') || - !message.submitState || - message.exploded, - } - }) - ) +type OwnProps = { + failed: boolean + id: number + isExploding: boolean + sent: boolean +} + +function SendIndicatorContainer(p: OwnProps) { + const {failed, id, isExploding, sent} = p const [status, setStatus] = React.useState<AnimationStatus>( sent ? 'sent' : failed ? 'error' : !shownEncryptingSet.has(id) ? 'encrypting' : 'sending' diff --git a/shared/chat/conversation/messages/wrapper/wrapper.tsx b/shared/chat/conversation/messages/wrapper/wrapper.tsx index 15607be023b8..732b4e2dcd06 100644 --- a/shared/chat/conversation/messages/wrapper/wrapper.tsx +++ b/shared/chat/conversation/messages/wrapper/wrapper.tsx @@ -4,7 +4,7 @@ import * as Kb from '@/common-adapters' import * as React from 'react' import {MessageContext, useOrdinal} from '../ids-context' import EmojiRow from '../emoji-row' -import ExplodingHeightRetainer from './exploding-height-retainer/container' +import ExplodingHeightRetainer from './exploding-height-retainer' import ExplodingMeta from './exploding-meta' import LongPressable from './long-pressable' import {useMessagePopup} from '../message-popup' @@ -14,8 +14,14 @@ import * as T from '@/constants/types' import capitalize from 'lodash/capitalize' import {useEdited} from './edited' import {useCurrentUserState} from '@/stores/current-user' +import {useTeamsState} from '@/stores/teams' +import {useTrackerState} from '@/stores/tracker' +import {navToProfile} from '@/constants/router' +import {formatTimeForChat} from '@/util/timestamp' +import type {ConvoState, ConvoUIState} from '@/stores/convostate' export type Props = { + isCenteredHighlight?: boolean ordinal: T.Chat.Ordinal } @@ -45,18 +51,175 @@ const messageShowsPopup = (type?: T.Chat.Message['type']) => // If there is no matching message treat it like a deleted const missingMessage = Chat.makeMessageDeleted({}) -// Pure helper functions - moved outside hooks to avoid recreating them per message -const getReactionsPopupPosition = ( - ordinal: T.Chat.Ordinal, - ordinals: ReadonlyArray<T.Chat.Ordinal>, - hasReactions: boolean, - message: T.Chat.Message -) => { - if (C.isMobile) return 'none' as const - if (hasReactions) return 'none' as const - const validMessage = Chat.isMessageWithReactions(message) - if (!validMessage) return 'none' as const - return ordinals.at(-1) === ordinal ? ('last' as const) : ('middle' as const) +type AuthorProps = { + author: string + botAlias: string + isAdhocBot: boolean + teamID: T.Teams.TeamID + teamType: T.Chat.TeamType + teamname: string + timestamp: number + showUsername: string +} + +type RowActions = Pick< + ConvoState['dispatch'], + 'messageDelete' | 'messageRetry' | 'replyJump' | 'toggleMessageReaction' +> & + Pick<ConvoUIState['dispatch'], 'setEditing' | 'setReplyTo'> + +type EditCancelRetryData = { + failureDescription: string + outboxID?: T.Chat.OutboxID +} + +type FlatAuthorData = { + author: string + botAlias: string + isAdhocBot: boolean + showUsername: string + teamID: T.Teams.TeamID + teamType: T.Chat.TeamType + teamname: string + timestamp: number +} + +const emptyAuthorData: FlatAuthorData = { + author: '', + botAlias: '', + isAdhocBot: false, + showUsername: '', + teamID: '' as T.Teams.TeamID, + teamType: 'adhoc', + teamname: '', + timestamp: 0, +} + +const getRowActions = ( + dispatch: ConvoState['dispatch'], + uiDispatch: Pick<ConvoUIState['dispatch'], 'setEditing' | 'setReplyTo'> +): RowActions => { + const {messageDelete, messageRetry, replyJump, toggleMessageReaction} = dispatch + const {setEditing, setReplyTo} = uiDispatch + return {messageDelete, messageRetry, replyJump, setEditing, setReplyTo, toggleMessageReaction} +} + +function AuthorSection(p: AuthorProps) { + const {author, botAlias, isAdhocBot, teamID, teamType, teamname, timestamp, showUsername} = p + + const authorRoleInTeam = useTeamsState(s => s.teamIDToMembers.get(teamID)?.get(author)?.type) + const showUser = useTrackerState(s => s.dispatch.showUser) + + const onAuthorClick = () => { + if (C.isMobile) { + navToProfile(showUsername) + } else { + showUser(showUsername, true) + } + } + + const authorIsOwner = authorRoleInTeam === 'owner' + const authorIsAdmin = authorRoleInTeam === 'admin' + const authorIsBot = teamname + ? authorRoleInTeam === 'restrictedbot' || authorRoleInTeam === 'bot' + : isAdhocBot + const allowCrown = teamType !== 'adhoc' && (authorIsOwner || authorIsAdmin) + + const usernameNode = ( + <Kb.ConnectedUsernames + colorBroken={true} + colorFollowing={true} + colorYou={true} + onUsernameClicked={onAuthorClick} + type="BodySmallBold" + usernames={showUsername} + virtualText={true} + className="separator-text" + /> + ) + + const ownerAdminTooltipIcon = allowCrown ? ( + <Kb.Box2 direction="vertical" tooltip={authorIsOwner ? 'Owner' : 'Admin'}> + <Kb.Icon + color={authorIsOwner ? Kb.Styles.globalColors.yellowDark : Kb.Styles.globalColors.black_35} + fontSize={10} + type="iconfont-crown-owner" + /> + </Kb.Box2> + ) : null + + const botIcon = authorIsBot ? ( + <Kb.Box2 direction="vertical" tooltip="Bot"> + <Kb.Icon fontSize={13} color={Kb.Styles.globalColors.black_35} type="iconfont-bot" /> + </Kb.Box2> + ) : null + + const botAliasOrUsername = botAlias ? ( + <Kb.Text type="BodySmallBold" style={styles.botAlias} lineClamp={1} className="separator-text"> + {botAlias} {' [' + showUsername + ']'} + </Kb.Text> + ) : ( + usernameNode + ) + + return ( + <> + <Kb.Avatar size={32} username={showUsername} onClick={onAuthorClick} style={styles.avatar} /> + <Kb.Box2 + pointerEvents="box-none" + key="author" + direction="horizontal" + style={styles.authorContainer} + gap="tiny" + > + <Kb.Box2 + pointerEvents="box-none" + direction="horizontal" + gap="xtiny" + fullWidth={true} + style={styles.usernameCrown} + > + {botAliasOrUsername} + {ownerAdminTooltipIcon} + {botIcon} + <Kb.Text type="BodyTiny" virtualText={true} className="separator-text"> + {formatTimeForChat(timestamp)} + </Kb.Text> + </Kb.Box2> + </Kb.Box2> + </> + ) +} + +const getAuthorData = ( + message: T.Chat.Message, + meta: ConvoState['meta'], + participants: ConvoState['participants'], + showUsername: string +): FlatAuthorData => { + if (!showUsername) { + return emptyAuthorData + } + const {author, timestamp} = message + const {teamID, botAliases, teamType, teamname} = meta + const participantInfoNames = participants.name + const isAdhocBot = + teamType === 'adhoc' && participantInfoNames.length > 0 ? !participantInfoNames.includes(author) : false + return { + author, + botAlias: botAliases[author] ?? '', + isAdhocBot, + showUsername, + teamID, + teamType, + teamname, + timestamp, + } +} + +function AuthorHeader(p: FlatAuthorData) { + if (!p.showUsername) return null + return <AuthorSection {...p} /> } const getEcrType = (message: T.Chat.Message, you: string) => { @@ -80,79 +243,189 @@ const getEcrType = (message: T.Chat.Message, you: string) => { return EditCancelRetryType.RETRY_CANCEL } -// Combined selector hook that fetches all message data in a single subscription -export const useMessageData = (ordinal: T.Chat.Ordinal) => { +const getCommonMessageData = ({ + accountsInfoMap, + editing, + isCenteredHighlight, + message, + messageCenterOrdinal, + ordinal, + paymentStatusMap, + unfurlPrompt, + you, +}: { + accountsInfoMap: ConvoState['accountsInfoMap'] + editing: T.Chat.Ordinal + isCenteredHighlight?: boolean + message: T.Chat.Message + messageCenterOrdinal: ConvoState['messageCenterOrdinal'] + ordinal: T.Chat.Ordinal + paymentStatusMap: ReturnType<typeof Chat.useChatState.getState>['paymentStatusMap'] + unfurlPrompt: ConvoState['unfurlPrompt'] + you: string +}) => { + const {submitState, author, id, botUsername} = message + const type = message.type + const exploded = !!message.exploded + const idMatchesOrdinal = T.Chat.ordinalToNumber(message.ordinal) === T.Chat.messageIDToNumber(id) + const exploding = !!message.exploding + const decorate = !exploded && !message.errorReason + const isShowingUploadProgressBar = + you === author && message.type === 'attachment' && message.inlineVideoPlayable + const showSendIndicator = + !!submitState && !exploded && you === author && !idMatchesOrdinal && !isShowingUploadProgressBar + const showRevoked = !!message.deviceRevokedAt + const showExplodingCountdown = !!exploding && !exploded && submitState !== 'failed' + const showCoinsIcon = hasSuccessfulInlinePayments(paymentStatusMap, message) + const hasReactions = (message.reactions?.size ?? 0) > 0 + const botname = botUsername === author ? '' : (botUsername ?? '') + const canShowReactionsPopup = Chat.isMessageWithReactions(message) + const ecrType = getEcrType(message, you) + const shouldShowPopup = Chat.shouldShowPopup(accountsInfoMap, message) + const hasBeenEdited = message.hasBeenEdited ?? false + const hasCoinFlip = message.type === 'text' && !!message.flipGameID + const hasUnfurlList = (message.unfurls?.size ?? 0) > 0 + const hasUnfurlPrompts = !!id && !!unfurlPrompt.get(id)?.size + const textType: 'error' | 'sent' | 'pending' = message.errorReason + ? 'error' + : !submitState + ? 'sent' + : 'pending' + const replyTo = message.type === 'text' ? message.replyTo : undefined + const reactions = message.reactions + const isExplodingMessage = message.type === 'text' || message.type === 'attachment' + const showReplyTo = !!replyTo + const text = + message.type === 'text' ? (message.decoratedText?.stringValue() ?? message.text.stringValue()) : '' + const showCenteredHighlight = + isCenteredHighlight ?? + !!( + messageCenterOrdinal && + messageCenterOrdinal.highlightMode !== 'none' && + messageCenterOrdinal.ordinal === ordinal + ) + + return { + botname, + canShowReactionsPopup, + decorate, + ecrType, + exploded, + explodedBy: isExplodingMessage ? message.explodedBy : undefined, + explodesAt: isExplodingMessage ? message.explodingTime : 0, + exploding, + forceExplodingRetainer: isExplodingMessage ? !!message.explodingUnreadable : false, + hasBeenEdited, + hasCoinFlip, + hasReactions, + hasUnfurlList, + hasUnfurlPrompts, + isEditing: editing === ordinal, + messageKey: isExplodingMessage ? Chat.getMessageKey(message) : '', + reactions, + replyTo, + sendIndicatorFailed: + (message.type === 'text' || message.type === 'attachment') && message.submitState === 'failed', + sendIndicatorID: message.timestamp, + sendIndicatorSent: + (message.type !== 'text' && message.type !== 'attachment') || !message.submitState || message.exploded, + shouldShowPopup, + showCenteredHighlight, + showCoinsIcon, + showExplodingCountdown, + showReplyTo, + showRevoked, + showSendIndicator, + submitState, + text, + textType, + type, + } +} + +const getEditCancelRetryData = ( + ecrType: EditCancelRetryType, + message: T.Chat.Message +): EditCancelRetryData => { + const reason = message.errorReason ?? '' + return { + failureDescription: + ecrType === EditCancelRetryType.NOACTION + ? reason + : `This message failed to send${reason ? '. ' : ''}${capitalize(reason)}`, + outboxID: message.outboxID, + } +} + +// Combined selector hook that fetches all common wrapper data in a single subscription. +export const useMessageData = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: boolean) => { const you = useCurrentUserState(s => s.username) + const editing = Chat.useChatUIContext(s => s.editing) + const uiDispatch = Chat.useChatUIContext( + C.useShallow(s => ({setEditing: s.dispatch.setEditing, setReplyTo: s.dispatch.setReplyTo})) + ) return Chat.useChatContext( C.useShallow(s => { - const accountsInfoMap = s.accountsInfoMap - const m = s.messageMap.get(ordinal) ?? missingMessage - const isEditing = s.editing === ordinal - const ordinals = s.messageOrdinals - const {exploded, submitState, author, id, botUsername} = m - const type = m.type - const idMatchesOrdinal = T.Chat.ordinalToNumber(m.ordinal) === T.Chat.messageIDToNumber(id) - const youSent = m.author === you && !idMatchesOrdinal - const exploding = !!m.exploding - const decorate = !exploded && !m.errorReason - const isShowingUploadProgressBar = you === author && m.type === 'attachment' && m.inlineVideoPlayable - const showSendIndicator = - !!submitState && !exploded && you === author && !idMatchesOrdinal && !isShowingUploadProgressBar - const showRevoked = !!m.deviceRevokedAt - const showExplodingCountdown = !!exploding && !exploded && submitState !== 'failed' - const paymentStatusMap = Chat.useChatState.getState().paymentStatusMap - const showCoinsIcon = hasSuccessfulInlinePayments(paymentStatusMap, m) - const hasReactions = (m.reactions?.size ?? 0) > 0 - const botname = botUsername === author ? '' : (botUsername ?? '') - const reactionsPopupPosition = getReactionsPopupPosition(ordinal, ordinals ?? [], hasReactions, m) - const ecrType = getEcrType(m, you) - const shouldShowPopup = Chat.shouldShowPopup(accountsInfoMap, m) - // Inline highlight mode check to avoid separate selector - const centeredOrdinalType = s.messageCenterOrdinal - const showCenteredHighlight = - centeredOrdinalType?.ordinal === ordinal && centeredOrdinalType.highlightMode !== 'none' - // Fields lifted from child components to consolidate subscriptions - const hasBeenEdited = m.hasBeenEdited ?? false - const hasCoinFlip = m.type === 'text' && !!m.flipGameID - const hasUnfurlList = (m.unfurls?.size ?? 0) > 0 - const hasUnfurlPrompts = !!id && !!s.unfurlPrompt.get(id)?.size - const textType: 'error' | 'sent' | 'pending' = m.errorReason ? 'error' : !submitState ? 'sent' : 'pending' - const showReplyTo = m.type === 'text' ? !!m.replyTo : false - const text = m.type === 'text' ? (m.decoratedText?.stringValue() ?? m.text.stringValue()) : '' - + const message = s.messageMap.get(ordinal) ?? missingMessage + const commonData = getCommonMessageData({ + accountsInfoMap: s.accountsInfoMap, + editing, + isCenteredHighlight, + message, + messageCenterOrdinal: s.messageCenterOrdinal, + ordinal, + paymentStatusMap: Chat.useChatState.getState().paymentStatusMap, + unfurlPrompt: s.unfurlPrompt, + you, + }) return { - botname, - decorate, - ecrType, - exploding, - hasBeenEdited, - hasCoinFlip, - hasReactions, - hasUnfurlList, - hasUnfurlPrompts, - isEditing, - reactionsPopupPosition, - shouldShowPopup, - showCenteredHighlight, - showCoinsIcon, - showExplodingCountdown, - showReplyTo, - showRevoked, - showSendIndicator, - text, - textType, - type, + ...commonData, + ...getEditCancelRetryData(commonData.ecrType, message), + ...getRowActions(s.dispatch, uiDispatch), + ...getAuthorData(message, s.meta, s.participants, s.showUsernameMap.get(ordinal) ?? ''), + } + }) + ) +} + +const useMessageDataWithMessage = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: boolean) => { + const you = useCurrentUserState(s => s.username) + const editing = Chat.useChatUIContext(s => s.editing) + const uiDispatch = Chat.useChatUIContext( + C.useShallow(s => ({setEditing: s.dispatch.setEditing, setReplyTo: s.dispatch.setReplyTo})) + ) + + return Chat.useChatContext( + C.useShallow(s => { + const message = s.messageMap.get(ordinal) ?? missingMessage + const commonData = getCommonMessageData({ + accountsInfoMap: s.accountsInfoMap, + editing, + isCenteredHighlight, + message, + messageCenterOrdinal: s.messageCenterOrdinal, + ordinal, + paymentStatusMap: Chat.useChatState.getState().paymentStatusMap, + unfurlPrompt: s.unfurlPrompt, you, - youSent, + }) + return { + ...commonData, + ...getEditCancelRetryData(commonData.ecrType, message), + ...getRowActions(s.dispatch, uiDispatch), + ...getAuthorData(message, s.meta, s.participants, s.showUsernameMap.get(ordinal) ?? ''), + message, } }) ) } -// Version that accepts pre-fetched data to avoid duplicate selector calls -export const useCommonWithData = (ordinal: T.Chat.Ordinal, data: ReturnType<typeof useMessageData>) => { - const {type, shouldShowPopup, showCenteredHighlight} = data +const useWrapperPopup = ( + ordinal: T.Chat.Ordinal, + data: Pick<ReturnType<typeof useMessageData>, 'shouldShowPopup' | 'type'> +) => { + const {type, shouldShowPopup} = data const shouldShow = () => { return messageShowsPopup(type) && shouldShowPopup @@ -162,35 +435,27 @@ export const useCommonWithData = (ordinal: T.Chat.Ordinal, data: ReturnType<type shouldShow, style: styles.messagePopupContainer, }) - return {popup, popupAnchor, showCenteredHighlight, showPopup, showingPopup, type} + return {popup, popupAnchor, showPopup, showingPopup} } -// Legacy version for backward compatibility with other wrappers -export const useCommon = (ordinal: T.Chat.Ordinal) => { - const data = useMessageData(ordinal) - const {type, shouldShowPopup, showCenteredHighlight} = data +export const useWrapperMessage = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: boolean) => { + const messageData = useMessageData(ordinal, isCenteredHighlight) + return {...useWrapperPopup(ordinal, messageData), messageData} +} - const shouldShow = () => { - return messageShowsPopup(type) && shouldShowPopup - } - const {showPopup, showingPopup, popup, popupAnchor} = useMessagePopup({ - ordinal, - shouldShow, - style: styles.messagePopupContainer, - }) - return {popup, popupAnchor, showCenteredHighlight, showPopup, showingPopup, type} +export const useWrapperMessageWithMessage = (ordinal: T.Chat.Ordinal, isCenteredHighlight?: boolean) => { + const messageData = useMessageDataWithMessage(ordinal, isCenteredHighlight) + return {...useWrapperPopup(ordinal, messageData), messageData} } -type WMProps = { +type WrapperMessageProps = { children: React.ReactNode bottomChildren?: React.ReactNode - showCenteredHighlight: boolean showPopup: () => void showingPopup: boolean popup: React.ReactNode popupAnchor: React.RefObject<Kb.MeasureRef | null> - // Optional: if provided, avoids calling useMessageData again - messageData?: ReturnType<typeof useMessageData> + messageData: ReturnType<typeof useMessageData> } & Props const successfulInlinePaymentStatuses = ['completed', 'claimable'] @@ -213,27 +478,44 @@ const hasSuccessfulInlinePayments = ( type TSProps = { botname: string bottomChildren: React.ReactNode + canShowReactionsPopup: boolean children: React.ReactNode decorate: boolean ecrType: EditCancelRetryType exploding: boolean + exploded: boolean + explodedBy?: string + explodesAt: number + forceExplodingRetainer: boolean hasBeenEdited: boolean hasReactions: boolean hasUnfurlList: boolean isHighlighted: boolean + messageKey: string + messageDelete: RowActions['messageDelete'] + messageRetry: RowActions['messageRetry'] + ordinal: T.Chat.Ordinal + outboxID?: T.Chat.OutboxID popupAnchor: React.RefObject<Kb.MeasureRef | null> - reactionsPopupPosition: 'none' | 'last' | 'middle' + reactions?: T.Chat.Reactions + sendIndicatorFailed: boolean + sendIndicatorID: number + sendIndicatorSent: boolean + setEditing: RowActions['setEditing'] + setReplyTo: RowActions['setReplyTo'] setShowingPicker: (s: boolean) => void shouldShowPopup: boolean showCoinsIcon: boolean showExplodingCountdown: boolean + failureDescription: string showRevoked: boolean showSendIndicator: boolean showingPicker: boolean showingPopup: boolean showPopup: () => void + submitState?: T.Chat.Message['submitState'] + toggleMessageReaction: RowActions['toggleMessageReaction'] type: T.Chat.MessageType - you: string } const NormalWrapper = ({ @@ -251,10 +533,20 @@ const NormalWrapper = ({ } function TextAndSiblings(p: TSProps) { - const {botname, bottomChildren, children, decorate, hasBeenEdited, hasUnfurlList, isHighlighted} = p - const {showingPopup, ecrType, exploding, hasReactions, popupAnchor} = p - const {type, reactionsPopupPosition, setShowingPicker, showCoinsIcon, shouldShowPopup} = p - const {showPopup, showExplodingCountdown, showRevoked, showSendIndicator, showingPicker} = p + const { + botname, + bottomChildren, + canShowReactionsPopup, + children, + decorate, + hasBeenEdited, + hasUnfurlList, + isHighlighted, + } = p + const {showingPopup, ecrType, exploding, exploded, explodedBy, explodesAt, forceExplodingRetainer} = p + const {hasReactions, popupAnchor, reactions, sendIndicatorFailed, sendIndicatorID} = p + const {sendIndicatorSent, type, setShowingPicker, showCoinsIcon, shouldShowPopup} = p + const {showPopup, showExplodingCountdown, showRevoked, showSendIndicator, showingPicker, submitState} = p const pressableProps = Kb.Styles.isMobile ? { onLongPress: decorate ? showPopup : undefined, @@ -272,7 +564,14 @@ function TextAndSiblings(p: TSProps) { const content = exploding ? ( <Kb.Box2 direction="horizontal" fullWidth={true}> - <ExplodingHeightRetainer>{children as React.ReactElement}</ExplodingHeightRetainer> + <ExplodingHeightRetainer + explodedBy={explodedBy} + exploding={exploding} + messageKey={p.messageKey} + retainHeight={forceExplodingRetainer || exploded} + > + {children as React.ReactElement} + </ExplodingHeightRetainer> </Kb.Box2> ) : ( children @@ -280,32 +579,55 @@ function TextAndSiblings(p: TSProps) { return ( <LongPressable {...pressableProps}> - <Kb.Box2 direction="vertical" flex={1} relative={true} style={styles.middle} fullWidth={!Kb.Styles.isMobile}> + <Kb.Box2 + direction="vertical" + flex={1} + relative={true} + style={styles.middle} + fullWidth={!Kb.Styles.isMobile} + > <NormalWrapper style={styles.background}> {content} <BottomSide ecrType={ecrType} + exploding={exploding} + failureDescription={p.failureDescription} hasBeenEdited={hasBeenEdited} - hasUnfurlList={hasUnfurlList} - messageType={type} - reactionsPopupPosition={reactionsPopupPosition} hasReactions={hasReactions} bottomChildren={bottomChildren} - showPopup={showPopup} + canShowReactionsPopup={canShowReactionsPopup} + hasUnfurlList={hasUnfurlList} + messageDelete={p.messageDelete} + messageRetry={p.messageRetry} + messageType={type} + ordinal={p.ordinal} + outboxID={p.outboxID} + reactions={reactions} + setEditing={p.setEditing} + setReplyTo={p.setReplyTo} setShowingPicker={setShowingPicker} showingPopup={showingPopup} + toggleMessageReaction={p.toggleMessageReaction} /> </NormalWrapper> </Kb.Box2> <RightSide shouldShowPopup={shouldShowPopup} botname={botname} + explodesAt={explodesAt} + exploded={exploded} + exploding={exploding} + messageKey={p.messageKey} + sendIndicatorFailed={sendIndicatorFailed} + sendIndicatorID={sendIndicatorID} + sendIndicatorSent={sendIndicatorSent} showSendIndicator={showSendIndicator} showExplodingCountdown={showExplodingCountdown} showRevoked={showRevoked} showCoinsIcon={showCoinsIcon} showPopup={showPopup} popupAnchor={popupAnchor} + submitState={submitState} /> </LongPressable> ) @@ -319,30 +641,17 @@ enum EditCancelRetryType { EDIT_CANCEL, RETRY_CANCEL, } -function EditCancelRetry(p: {ecrType: EditCancelRetryType}) { - const {ecrType} = p +function EditCancelRetry(p: { + ecrType: EditCancelRetryType + exploding: boolean + failureDescription: string + messageDelete: RowActions['messageDelete'] + messageRetry: RowActions['messageRetry'] + outboxID?: T.Chat.OutboxID + setEditing: RowActions['setEditing'] +}) { + const {ecrType, exploding, failureDescription, messageDelete, messageRetry, outboxID, setEditing} = p const ordinal = useOrdinal() - const {failureDescription, outboxID, exploding, messageDelete, messageRetry, setEditing} = Chat.useChatContext( - C.useShallow(s => { - const m = s.messageMap.get(ordinal) - const outboxID = m?.outboxID - const reason = m?.errorReason ?? '' - const exploding = m?.exploding ?? false - const failureDescription = - ecrType === EditCancelRetryType.NOACTION - ? reason - : `This message failed to send${reason ? '. ' : ''}${capitalize(reason)}` - const {messageDelete, messageRetry, setEditing} = s.dispatch - return { - exploding, - failureDescription, - messageDelete, - messageRetry, - outboxID, - setEditing, - } - }) - ) const onCancel = () => { messageDelete(ordinal) } @@ -397,33 +706,60 @@ function EditCancelRetry(p: {ecrType: EditCancelRetryType}) { } type BProps = { - showPopup: () => void showingPopup: boolean setShowingPicker: (s: boolean) => void bottomChildren?: React.ReactNode + canShowReactionsPopup: boolean + exploding: boolean + failureDescription: string hasBeenEdited: boolean hasReactions: boolean hasUnfurlList: boolean messageType: T.Chat.MessageType - reactionsPopupPosition: 'none' | 'last' | 'middle' + messageDelete: RowActions['messageDelete'] + messageRetry: RowActions['messageRetry'] + ordinal: T.Chat.Ordinal + outboxID?: T.Chat.OutboxID + reactions?: T.Chat.Reactions + setEditing: RowActions['setEditing'] + setReplyTo: RowActions['setReplyTo'] + toggleMessageReaction: RowActions['toggleMessageReaction'] ecrType: EditCancelRetryType } // reactions function BottomSide(p: BProps) { - const {showingPopup, setShowingPicker, bottomChildren, ecrType, hasBeenEdited} = p - const {hasReactions, hasUnfurlList, messageType, reactionsPopupPosition} = p + const {showingPopup, setShowingPicker, bottomChildren, canShowReactionsPopup, ecrType, hasBeenEdited} = p + const {exploding, failureDescription, hasReactions, hasUnfurlList, messageType, ordinal, reactions} = p + const {messageDelete, messageRetry, outboxID, setEditing, setReplyTo, toggleMessageReaction} = p - const reactionsRow = hasReactions ? <ReactionsRow /> : null + const onReact = (emoji: string) => { + toggleMessageReaction(ordinal, emoji) + } + const onReply = () => { + setReplyTo(ordinal) + } + + const reactionsRow = hasReactions ? ( + <ReactionsRow + hasUnfurls={hasUnfurlList} + messageType={messageType} + onReact={onReact} + onReply={onReply} + reactions={reactions} + /> + ) : null - // this exists and is shown using css to avoid thrashing + const canShowDesktopReactionsPopup = !C.isMobile && !hasReactions && canShowReactionsPopup const desktopReactionsPopup = - !C.isMobile && reactionsPopupPosition !== 'none' && !showingPopup ? ( + canShowDesktopReactionsPopup && !showingPopup ? ( <EmojiRow className={Kb.Styles.classNames('WrapperMessage-emojiButton', 'hover-visible')} hasUnfurls={hasUnfurlList} messageType={messageType} + onReact={onReact} + onReply={onReply} onShowingEmojiPicker={setShowingPicker} - style={reactionsPopupPosition === 'last' ? styles.emojiRowLast : styles.emojiRow} + style={styles.emojiRow} /> ) : null @@ -433,7 +769,17 @@ function BottomSide(p: BProps) { <> {edited} {bottomChildren ?? null} - {ecrType !== EditCancelRetryType.NONE ? <EditCancelRetry ecrType={ecrType} /> : null} + {ecrType !== EditCancelRetryType.NONE ? ( + <EditCancelRetry + ecrType={ecrType} + exploding={exploding} + failureDescription={failureDescription} + messageDelete={messageDelete} + messageRetry={messageRetry} + outboxID={outboxID} + setEditing={setEditing} + /> + ) : null} {reactionsRow} {desktopReactionsPopup} </> @@ -448,15 +794,39 @@ type RProps = { showRevoked: boolean showCoinsIcon: boolean botname: string + exploded: boolean + exploding: boolean + explodesAt: number + messageKey: string shouldShowPopup: boolean popupAnchor: React.RefObject<Kb.MeasureRef | null> + sendIndicatorFailed: boolean + sendIndicatorID: number + sendIndicatorSent: boolean + submitState?: T.Chat.Message['submitState'] } function RightSide(p: RProps) { const {showPopup, showSendIndicator, showCoinsIcon, popupAnchor} = p const {showExplodingCountdown, showRevoked, botname, shouldShowPopup} = p - const sendIndicator = showSendIndicator ? <SendIndicator /> : null + const sendIndicator = showSendIndicator ? ( + <SendIndicator + failed={p.sendIndicatorFailed} + id={p.sendIndicatorID} + isExploding={p.exploding} + sent={p.sendIndicatorSent} + /> + ) : null - const explodingCountdown = showExplodingCountdown ? <ExplodingMeta onClick={showPopup} /> : null + const explodingCountdown = showExplodingCountdown ? ( + <ExplodingMeta + exploded={p.exploded} + exploding={p.exploding} + explodesAt={p.explodesAt} + messageKey={p.messageKey} + onClick={showPopup} + submitState={p.submitState} + /> + ) : null const revokedIcon = showRevoked ? ( <Kb.Box2 direction="vertical" tooltip="Revoked device" className="tooltip-bottom-left"> @@ -525,33 +895,50 @@ function RightSide(p: RProps) { ) } -export function WrapperMessage(p: WMProps) { - const {ordinal, bottomChildren, children, messageData: mdataProp} = p - const {showCenteredHighlight, showPopup, showingPopup, popup, popupAnchor} = p +export function WrapperMessage(p: WrapperMessageProps) { + const {ordinal, bottomChildren, children, messageData: mdata} = p + const {showPopup, showingPopup, popup, popupAnchor} = p const [showingPicker, setShowingPicker] = React.useState(false) - // Use provided messageData if available, otherwise fetch it - const mdataFetched = useMessageData(ordinal) - const mdata = mdataProp ?? mdataFetched - const {decorate, type, hasReactions, isEditing, shouldShowPopup} = mdata - const {ecrType, showSendIndicator, showRevoked, showExplodingCountdown, exploding} = mdata - const {reactionsPopupPosition, showCoinsIcon, botname, you, hasBeenEdited, hasUnfurlList} = mdata + const {canShowReactionsPopup, ecrType, exploded, explodesAt, forceExplodingRetainer, messageKey} = mdata + const {reactions, sendIndicatorFailed, sendIndicatorID, sendIndicatorSent, submitState} = mdata + const {showSendIndicator, showRevoked, showExplodingCountdown, exploding} = mdata + const {showCoinsIcon, botname, hasBeenEdited, hasUnfurlList, showCenteredHighlight} = mdata + const {failureDescription, messageDelete, messageRetry, outboxID} = mdata + const {setEditing, setReplyTo, toggleMessageReaction} = mdata + const {author, botAlias, isAdhocBot, showUsername, teamID, teamType, teamname, timestamp} = mdata const isHighlighted = showCenteredHighlight || isEditing const tsprops = { botname, bottomChildren, + canShowReactionsPopup, children, decorate, ecrType, + exploded, + explodedBy: mdata.explodedBy, + explodesAt, exploding, + failureDescription, + forceExplodingRetainer, hasBeenEdited, hasReactions, hasUnfurlList, isHighlighted, + messageDelete, + messageKey, + messageRetry, + ordinal, + outboxID, popupAnchor, - reactionsPopupPosition, + reactions, + sendIndicatorFailed, + sendIndicatorID, + sendIndicatorSent, + setEditing, + setReplyTo, setShowingPicker, shouldShowPopup, showCoinsIcon, @@ -561,15 +948,28 @@ export function WrapperMessage(p: WMProps) { showSendIndicator, showingPicker, showingPopup, + submitState, + toggleMessageReaction, type, - you, } const messageContext = {isHighlighted: showCenteredHighlight, ordinal} return ( <MessageContext value={messageContext}> - <TextAndSiblings {...tsprops} /> + <Kb.Box2 direction="vertical" relative={true} fullWidth={true}> + <AuthorHeader + author={author} + botAlias={botAlias} + isAdhocBot={isAdhocBot} + showUsername={showUsername} + teamID={teamID} + teamType={teamType} + teamname={teamname} + timestamp={timestamp} + /> + <TextAndSiblings {...tsprops} /> + </Kb.Box2> {popup} </MessageContext> ) @@ -578,10 +978,39 @@ export function WrapperMessage(p: WMProps) { const styles = Kb.Styles.styleSheetCreate( () => ({ + authorContainer: Kb.Styles.platformStyles({ + common: { + alignItems: 'flex-start', + alignSelf: 'flex-start', + marginLeft: Kb.Styles.isMobile ? 48 : 56, + }, + isElectron: { + marginBottom: 0, + marginTop: 0, + }, + isMobile: {marginTop: 8}, + }), + avatar: Kb.Styles.platformStyles({ + common: {position: 'absolute', top: 4}, + isElectron: { + left: Kb.Styles.globalMargins.small, + top: 4, + zIndex: 2, + }, + isMobile: {left: Kb.Styles.globalMargins.tiny}, + }), background: { alignSelf: 'stretch', flexShrink: 1, }, + botAlias: Kb.Styles.platformStyles({ + common: {color: Kb.Styles.globalColors.black}, + isElectron: { + maxWidth: 240, + wordBreak: 'break-all', + }, + isMobile: {maxWidth: 120}, + }), ellipsis: Kb.Styles.platformStyles({ isElectron: {paddingTop: 2}, isMobile: {paddingTop: 4}, @@ -598,18 +1027,6 @@ const styles = Kb.Styles.styleSheetCreate( zIndex: 2, }, }), - emojiRowLast: Kb.Styles.platformStyles({ - isElectron: { - backgroundColor: Kb.Styles.globalColors.white, - border: `1px solid ${Kb.Styles.globalColors.black_10}`, - borderRadius: Kb.Styles.borderRadius, - paddingRight: Kb.Styles.globalMargins.xtiny, - position: 'absolute', - right: 96, - top: -Kb.Styles.globalMargins.medium + 5, - zIndex: 2, - }, - }), fail: {color: Kb.Styles.globalColors.redDark}, failExploding: {color: Kb.Styles.globalColors.black_50}, failUnderline: {color: Kb.Styles.globalColors.redDark, textDecorationLine: 'underline'}, @@ -642,5 +1059,14 @@ const styles = Kb.Styles.styleSheetCreate( }, isElectron: {minHeight: 14}, }), + usernameCrown: Kb.Styles.platformStyles({ + isElectron: { + alignItems: 'baseline', + marginRight: 48, + position: 'relative', + top: -2, + }, + isMobile: {alignItems: 'center'}, + }), }) as const ) diff --git a/shared/chat/conversation/normal/index.desktop.tsx b/shared/chat/conversation/normal/index.desktop.tsx index 4edf77c7dfaf..1c92e11b8d3b 100644 --- a/shared/chat/conversation/normal/index.desktop.tsx +++ b/shared/chat/conversation/normal/index.desktop.tsx @@ -10,6 +10,7 @@ import ListArea from '../list-area' import PinnedMessage from '../pinned-message' import ThreadLoadStatus from '../load-status' import ThreadSearch from '../search' +import {useThreadSearchRoute} from '../thread-search-route' import {readImageFromClipboard} from '@/util/clipboard.desktop' import '../conversation.css' import {indefiniteArticle} from '@/util/string' @@ -39,7 +40,7 @@ const Conversation = function Conversation() { params: {conversationIDKey, pathAndOutboxIDs}, })) } - const showThreadSearch = Chat.useChatContext(s => s.threadSearchInfo.visible) + const showThreadSearch = !!useThreadSearchRoute() const cannotWrite = Chat.useChatContext(s => s.meta.cannotWrite) const threadLoadedOffline = Chat.useChatContext(s => s.meta.offline) const dragAndDropRejectReason = Chat.useChatContext(s => { diff --git a/shared/chat/conversation/recycle-type-context.tsx b/shared/chat/conversation/recycle-type-context.tsx deleted file mode 100644 index 8e1fc5847429..000000000000 --- a/shared/chat/conversation/recycle-type-context.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import * as React from 'react' -import type * as T from '@/constants/types' - -export const SetRecycleTypeContext = React.createContext((_ordinal: T.Chat.Ordinal, _type: string) => {}) diff --git a/shared/chat/conversation/reply-preview.tsx b/shared/chat/conversation/reply-preview.tsx index 5a177afba7a3..6dad6fdb7169 100644 --- a/shared/chat/conversation/reply-preview.tsx +++ b/shared/chat/conversation/reply-preview.tsx @@ -3,7 +3,7 @@ import * as Kb from '@/common-adapters' import * as T from '@/constants/types' const ReplyPreview = () => { - const rordinal = Chat.useChatContext(s => s.replyTo) + const rordinal = Chat.useChatUIContext(s => s.replyTo) const message = Chat.useChatContext(s => { return rordinal ? s.messageMap.get(rordinal) : null }) @@ -30,7 +30,7 @@ const ReplyPreview = () => { const imageWidth = attachment?.previewWidth const username = message?.author ?? '' const sizing = imageWidth && imageHeight ? Chat.zoomImage(imageWidth, imageHeight, 80) : null - const setReplyTo = Chat.useChatContext(s => s.dispatch.setReplyTo) + const setReplyTo = Chat.useChatUIContext(s => s.dispatch.setReplyTo) const onCancel = () => { setReplyTo(T.Chat.numberToOrdinal(0)) } diff --git a/shared/chat/conversation/search.tsx b/shared/chat/conversation/search.tsx index 0d11d8c13635..b49aff433aab 100644 --- a/shared/chat/conversation/search.tsx +++ b/shared/chat/conversation/search.tsx @@ -1,69 +1,220 @@ import * as C from '@/constants' +import * as Message from '@/constants/chat/message' import * as Chat from '@/stores/chat' import type * as Styles from '@/styles' +import * as T from '@/constants/types' import * as React from 'react' import * as Kb from '@/common-adapters' +import {RPCError} from '@/util/errors' import {formatTimeForMessages} from '@/util/timestamp' +import {useCurrentUserState} from '@/stores/current-user' +import {useThreadSearchRoute} from './thread-search-route' type OwnProps = {style?: Styles.StylesCrossPlatform} +type SearchState = { + hits: Array<T.Chat.Message> + status: T.Chat.ThreadSearchInfo['status'] +} + const useCommon = (ownProps: OwnProps) => { const {style} = ownProps - - const data = Chat.useChatContext( - C.useShallow(s => { - const {id: conversationIDKey, threadSearchInfo, threadSearchQuery: initialText, dispatch} = s - const {hits: _hits, status} = threadSearchInfo - const {loadMessagesCentered, setThreadSearchQuery, toggleThreadSearch, threadSearch} = dispatch - return { - _hits, - conversationIDKey, - initialText, - loadMessagesCentered, - setThreadSearchQuery, - status, - threadSearch, - toggleThreadSearch, - } - }) + const initialQuery = useThreadSearchRoute()?.query ?? '' + const {conversationIDKey, loadMessagesCentered, toggleThreadSearch} = Chat.useChatContext( + C.useShallow(s => ({ + conversationIDKey: s.id, + loadMessagesCentered: s.dispatch.loadMessagesCentered, + toggleThreadSearch: s.dispatch.toggleThreadSearch, + })) ) - - const {conversationIDKey, _hits, status, initialText} = data - const {loadMessagesCentered, setThreadSearchQuery, toggleThreadSearch, threadSearch} = data const onToggleThreadSearch = () => { toggleThreadSearch() } - const numHits = _hits.length - const hits = _hits.map(h => ({ + const [searchState, setSearchState] = React.useState<SearchState>({hits: [], status: 'initial'}) + const {hits: messageHits, status} = searchState + const numHits = messageHits.length + const hits = messageHits.map(h => ({ author: h.author, summary: h.bodySummary.stringValue(), timestamp: h.timestamp, })) - const [selectedIndex, setSelectedIndex] = React.useState(0) const [text, setText] = React.useState('') const [lastSearch, setLastSearch] = React.useState('') + const searchOrdinalRef = React.useRef(0) + const hitsRef = React.useRef(messageHits) + const flushTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined) + const pendingHitsRef = React.useRef<Array<T.Chat.Message>>([]) + const pendingReplaceHitsRef = React.useRef<Array<T.Chat.Message> | undefined>(undefined) + React.useEffect(() => { + hitsRef.current = messageHits + }, [messageHits]) + + const clearPendingFlush = React.useEffectEvent(() => { + if (flushTimeoutRef.current) { + clearTimeout(flushTimeoutRef.current) + flushTimeoutRef.current = undefined + } + pendingHitsRef.current = [] + pendingReplaceHitsRef.current = undefined + }) + + const runThreadSearch = React.useEffectEvent((query: string) => { + const requestOrdinal = searchOrdinalRef.current + 1 + searchOrdinalRef.current = requestOrdinal + clearPendingFlush() + setSearchState({hits: [], status: query ? 'inprogress' : 'done'}) + if (!query) { + return + } + + const {deviceName, username} = useCurrentUserState.getState() + const getLastOrdinal = () => + Chat.getConvoState(conversationIDKey).messageOrdinals?.at(-1) ?? T.Chat.numberToOrdinal(0) + const updateIfCurrent = (updater: (state: SearchState) => SearchState) => { + if (searchOrdinalRef.current !== requestOrdinal) { + return + } + setSearchState(state => (searchOrdinalRef.current === requestOrdinal ? updater(state) : state)) + } + const flushPendingHits = (statusOverride?: SearchState['status']) => { + if (flushTimeoutRef.current) { + clearTimeout(flushTimeoutRef.current) + flushTimeoutRef.current = undefined + } + const pendingReplaceHits = pendingReplaceHitsRef.current + const pendingHits = pendingHitsRef.current + pendingReplaceHitsRef.current = undefined + pendingHitsRef.current = [] + if (!pendingReplaceHits && !pendingHits.length && statusOverride === undefined) { + return + } + updateIfCurrent(state => { + let nextHits = state.hits + if (pendingReplaceHits) { + nextHits = pendingReplaceHits + } else if (pendingHits.length) { + const seen = new Set(nextHits.map(hit => hit.id)) + nextHits = [...nextHits] + pendingHits.forEach(hit => { + if (!seen.has(hit.id)) { + seen.add(hit.id) + nextHits.push(hit) + } + }) + } + return {hits: nextHits, status: statusOverride ?? state.status} + }) + } + const scheduleFlush = () => { + if (flushTimeoutRef.current) { + return + } + flushTimeoutRef.current = setTimeout(() => { + flushPendingHits() + }, 16) + } + const onDone = () => { + flushPendingHits('done') + } + + const f = async () => { + try { + await T.RPCChat.localSearchInboxRpcListener({ + incomingCallMap: { + 'chat.1.chatUi.chatSearchDone': onDone, + 'chat.1.chatUi.chatSearchHit': hit => { + const message = Message.uiMessageToMessage( + conversationIDKey, + hit.searchHit.hitMessage, + username, + getLastOrdinal, + deviceName + ) + if (!message) { + return + } + pendingHitsRef.current.push(message) + scheduleFlush() + }, + 'chat.1.chatUi.chatSearchInboxDone': onDone, + 'chat.1.chatUi.chatSearchInboxHit': resp => { + const messages = (resp.searchHit.hits || []).reduce<Array<T.Chat.Message>>((result, hit) => { + const message = Message.uiMessageToMessage( + conversationIDKey, + hit.hitMessage, + username, + getLastOrdinal, + deviceName + ) + if (message) { + result.push(message) + } + return result + }, []) + pendingHitsRef.current = [] + pendingReplaceHitsRef.current = messages + scheduleFlush() + }, + 'chat.1.chatUi.chatSearchInboxStart': () => { + updateIfCurrent(state => ({...state, status: 'inprogress'})) + }, + }, + params: { + identifyBehavior: T.RPCGen.TLFIdentifyBehavior.chatGui, + namesOnly: false, + opts: { + afterContext: 0, + beforeContext: 0, + convID: Chat.getConvoState(conversationIDKey).getConvID(), + isRegex: false, + matchMentions: false, + maxBots: 0, + maxConvsHit: 0, + maxConvsSearched: 0, + maxHits: 1000, + maxMessages: -1, + maxNameConvs: 0, + maxTeams: 0, + reindexMode: T.RPCChat.ReIndexingMode.postsearchSync, + sentAfter: 0, + sentBefore: 0, + sentBy: '', + sentTo: '', + skipBotCache: false, + }, + query, + }, + }) + } catch (error) { + if (error instanceof RPCError) { + updateIfCurrent(state => ({...state, status: 'done'})) + } + } + } + C.ignorePromise(f()) + }) + const submitSearch = () => { setLastSearch(text) setSelectedIndex(0) - threadSearch(text) + runThreadSearch(text) } - const hitsRef = React.useRef(_hits) - React.useEffect(() => { - hitsRef.current = _hits - }, [_hits]) const [selectResult] = React.useState(() => (index: number) => { - const message = hitsRef.current[index] || Chat.makeMessageText() - if (message.id > 0) { + const message = hitsRef.current[index] + if (message?.id) { loadMessagesCentered(message.id, 'always') } setSelectedIndex(index) }) const onUp = () => { + if (!numHits) { + return + } if (selectedIndex >= numHits - 1) { selectResult(0) return @@ -80,6 +231,9 @@ const useCommon = (ownProps: OwnProps) => { } const onDown = () => { + if (!numHits) { + return + } if (selectedIndex <= 0) { selectResult(numHits - 1) return @@ -95,11 +249,33 @@ const useCommon = (ownProps: OwnProps) => { const hasResults = status === 'done' || numHits > 0 React.useEffect(() => { - if (initialText) { - setThreadSearchQuery('') - setText(initialText) + searchOrdinalRef.current += 1 + clearPendingFlush() + setSearchState({hits: [], status: 'initial'}) + setLastSearch('') + setSelectedIndex(0) + setText('') + }, [conversationIDKey]) + + React.useEffect(() => { + if (!initialQuery) { + return + } + setText(initialQuery) + setLastSearch(initialQuery) + setSelectedIndex(0) + runThreadSearch(initialQuery) + }, [conversationIDKey, initialQuery]) + + React.useEffect(() => { + return () => { + searchOrdinalRef.current += 1 + clearPendingFlush() + C.ignorePromise( + T.RPCChat.localCancelActiveSearchRpcPromise().catch(() => {}) + ) } - }, [initialText, setThreadSearchQuery]) + }, []) const hasHits = numHits > 0 const hadHitsRef = React.useRef(false) diff --git a/shared/chat/conversation/thread-search-route.ts b/shared/chat/conversation/thread-search-route.ts new file mode 100644 index 000000000000..d36dc0e84353 --- /dev/null +++ b/shared/chat/conversation/thread-search-route.ts @@ -0,0 +1,15 @@ +import {getRouteParamsFromRoute, type RootRouteProps} from '@/router-v2/route-params' +import {useRoute} from '@react-navigation/native' + +export type ThreadSearchRoute = { + query?: string +} + +export type ThreadSearchRouteProps = { + threadSearch?: ThreadSearchRoute +} + +export const useThreadSearchRoute = () => { + const route = useRoute<RootRouteProps<'chatConversation'> | RootRouteProps<'chatRoot'>>() + return getRouteParamsFromRoute<'chatConversation' | 'chatRoot'>(route)?.threadSearch +} diff --git a/shared/chat/inbox-and-conversation-header.tsx b/shared/chat/inbox-and-conversation-header.tsx index 955b3fa7206c..4ca66af4ca3b 100644 --- a/shared/chat/inbox-and-conversation-header.tsx +++ b/shared/chat/inbox-and-conversation-header.tsx @@ -8,8 +8,9 @@ import {useRoute, type RouteProp} from '@react-navigation/native' import {useUsersState} from '@/stores/users' import {useCurrentUserState} from '@/stores/current-user' import * as Teams from '@/stores/teams' +import type {ThreadSearchRouteProps} from './conversation/thread-search-route' -type ChatRootParams = { +type ChatRootParams = ThreadSearchRouteProps & { conversationIDKey?: string infoPanel?: object } diff --git a/shared/chat/inbox-and-conversation.tsx b/shared/chat/inbox-and-conversation.tsx index e57b86e7bca4..9ecd83505100 100644 --- a/shared/chat/inbox-and-conversation.tsx +++ b/shared/chat/inbox-and-conversation.tsx @@ -8,8 +8,9 @@ import Conversation from './conversation/container' import Inbox from './inbox' import InboxSearch from './inbox-search' import InfoPanel, {type Panel} from './conversation/info-panel' +import type {ThreadSearchRouteProps} from './conversation/thread-search-route' -type Props = { +type Props = ThreadSearchRouteProps & { conversationIDKey?: T.Chat.ConversationIDKey infoPanel?: {tab?: Panel} } diff --git a/shared/chat/inbox/row/small-team/index.tsx b/shared/chat/inbox/row/small-team/index.tsx index d1f75a34875c..763b5e28264e 100644 --- a/shared/chat/inbox/row/small-team/index.tsx +++ b/shared/chat/inbox/row/small-team/index.tsx @@ -45,61 +45,65 @@ const SmallTeam = (p: Props) => { const participantOne = teamDisplayName ? '' : participants[0] ?? '' const participantTwo = teamDisplayName ? '' : participants[1] ?? '' + const className = Kb.Styles.classNames('small-row', {selected: isSelected}) + const containerStyle = Kb.Styles.isTablet + ? Kb.Styles.collapseStyles([styles.container, {backgroundColor}]) + : styles.container + const rowContents = ( + <Kb.Box2 direction="horizontal" alignItems="center" fullWidth={true} style={styles.rowContainer}> + {teamDisplayName ? ( + <TeamAvatar teamname={teamDisplayName} isMuted={isMuted} isSelected={isSelected} isHovered={false} /> + ) : ( + <Avatars + backgroundColor={backgroundColor} + isMuted={isMuted} + isLocked={isLocked} + isSelected={isSelected} + participantOne={participantOne} + participantTwo={participantTwo} + /> + )} + <Kb.Box2 direction="vertical" style={styles.conversationRow}> + <Kb.Box2 direction="vertical" justifyContent="flex-end" style={styles.withBottomLine} fullWidth={true}> + <TopLine + conversationIDKey={conversationIDKey} + participants={participants} + teamDisplayName={teamDisplayName} + timestamp={timestamp} + hasBadge={hasBadge} + hasUnread={hasUnread} + isSelected={isSelected} + backgroundColor={backgroundColor} + /> + </Kb.Box2> + <BottomLineDisplay + snippet={displaySnippet} + snippetDecoration={snippetDecoration} + backgroundColor={backgroundColor} + isSelected={isSelected} + hasUnread={hasUnread} + draft={draft} + hasResetUsers={hasResetUsers} + youNeedToRekey={youNeedToRekey} + youAreReset={youAreReset} + participantNeedToRekey={participantNeedToRekey} + isDecryptingSnippet={isDecryptingSnippet} + /> + </Kb.Box2> + </Kb.Box2> + ) return ( <SwipeConvActions conversationIDKey={conversationIDKey} onPress={onSelectConversation}> - <Kb.ClickableBox2 - onClick={onSelectConversation} - className={Kb.Styles.classNames('small-row', {selected: isSelected})} - testID="inboxRow" - style={ - Kb.Styles.isTablet - ? Kb.Styles.collapseStyles([styles.container, {backgroundColor}]) - : styles.container - } - > - <Kb.Box2 direction="horizontal" alignItems="center" fullWidth={true} style={styles.rowContainer}> - {teamDisplayName ? ( - <TeamAvatar teamname={teamDisplayName} isMuted={isMuted} isSelected={isSelected} isHovered={false} /> - ) : ( - <Avatars - backgroundColor={backgroundColor} - isMuted={isMuted} - isLocked={isLocked} - isSelected={isSelected} - participantOne={participantOne} - participantTwo={participantTwo} - /> - )} - <Kb.Box2 direction="vertical" style={styles.conversationRow}> - <Kb.Box2 direction="vertical" justifyContent="flex-end" style={styles.withBottomLine} fullWidth={true}> - <TopLine - conversationIDKey={conversationIDKey} - participants={participants} - teamDisplayName={teamDisplayName} - timestamp={timestamp} - hasBadge={hasBadge} - hasUnread={hasUnread} - isSelected={isSelected} - backgroundColor={backgroundColor} - /> - </Kb.Box2> - <BottomLineDisplay - snippet={displaySnippet} - snippetDecoration={snippetDecoration} - backgroundColor={backgroundColor} - isSelected={isSelected} - hasUnread={hasUnread} - draft={draft} - hasResetUsers={hasResetUsers} - youNeedToRekey={youNeedToRekey} - youAreReset={youAreReset} - participantNeedToRekey={participantNeedToRekey} - isDecryptingSnippet={isDecryptingSnippet} - /> - </Kb.Box2> + {Kb.Styles.isMobile ? ( + <Kb.Box2 direction="vertical" style={containerStyle}> + {rowContents} </Kb.Box2> - </Kb.ClickableBox2> + ) : ( + <Kb.ClickableBox2 onClick={onSelectConversation} className={className} testID="inboxRow" style={containerStyle}> + {rowContents} + </Kb.ClickableBox2> + )} </SwipeConvActions> ) } diff --git a/shared/chat/inbox/row/small-team/swipe-conv-actions/index.native.tsx b/shared/chat/inbox/row/small-team/swipe-conv-actions/index.native.tsx index 7c884263249c..cc5dd4c2fa6f 100644 --- a/shared/chat/inbox/row/small-team/swipe-conv-actions/index.native.tsx +++ b/shared/chat/inbox/row/small-team/swipe-conv-actions/index.native.tsx @@ -4,7 +4,7 @@ import * as React from 'react' import * as Reanimated from 'react-native-reanimated' import * as RowSizes from '../../sizes' import type {Props} from '.' -import {Pressable, View} from 'react-native' +import {View} from 'react-native' import {RectButton} from 'react-native-gesture-handler' import Swipeable, {type SwipeableMethods} from 'react-native-gesture-handler/ReanimatedSwipeable' import {useOpenedRowState} from '../../opened-row-state' @@ -126,11 +126,15 @@ function SwipeConvActions(p: Props) { } const inner = onPress ? ( - <Pressable onPress={onPress} style={styles.touchable}> - {children} - </Pressable> + <RectButton onPress={onPress} style={styles.touchable} testID="inboxRow"> + <View accessible={false} style={styles.touchable}> + {children} + </View> + </RectButton> ) : ( - children + <View style={styles.touchable} testID="inboxRow"> + {children} + </View> ) return ( @@ -175,6 +179,7 @@ const styles = Kb.Styles.styleSheetCreate( }, touchable: { height: RowSizes.smallRowHeight, + width: '100%', }, }) as const ) diff --git a/shared/chat/send-to-chat/index.tsx b/shared/chat/send-to-chat/index.tsx index fae63e6dd300..6d7a1eb2499d 100644 --- a/shared/chat/send-to-chat/index.tsx +++ b/shared/chat/send-to-chat/index.tsx @@ -36,7 +36,7 @@ export const MobileSendToChat = (props: Props) => { const fileContext = useFSState(s => s.fileContext) const onSelect = (conversationIDKey: T.Chat.ConversationIDKey, tlfName: string) => { const {dispatch} = Chat.getConvoState(conversationIDKey) - text && dispatch.injectIntoInput(text) + text && Chat.getConvoUIState(conversationIDKey).dispatch.injectIntoInput(text) if (sendPaths?.length) { navigateAppend({ name: 'chatAttachmentGetTitles', diff --git a/shared/common-adapters/avatar/avatar-line.tsx b/shared/common-adapters/avatar/avatar-line.tsx index 046e34dd2c34..6820fafcd440 100644 --- a/shared/common-adapters/avatar/avatar-line.tsx +++ b/shared/common-adapters/avatar/avatar-line.tsx @@ -57,42 +57,42 @@ const getTextSize = (size: AvatarSize) => (size >= 48 ? 'BodySmallBold' : 'BodyT const getSizeStyle = (size: AvatarSize) => ({ horizontal: Kb.Styles.styleSheetCreate(() => ({ avatar: { - marginRight: -size / 3, + marginRight: -Math.round(size / 3), }, container: { marginLeft: 2, - marginRight: size / 3 + 2, + marginRight: Math.round(size / 3) + 2, }, overflowBox: { backgroundColor: Kb.Styles.globalColors.grey, borderBottomRightRadius: size, borderTopRightRadius: size, height: size, - paddingLeft: size / 2, + paddingLeft: Math.round(size / 2), }, text: { color: Kb.Styles.globalColors.black_50, - paddingRight: size / 5, + paddingRight: Math.round(size / 5), }, })), vertical: Kb.Styles.styleSheetCreate(() => ({ avatar: { - marginBottom: -size / 3, + marginBottom: -Math.round(size / 3), }, container: { - marginBottom: size / 3 + 2, + marginBottom: Math.round(size / 3) + 2, marginTop: 2, }, overflowBox: { backgroundColor: Kb.Styles.globalColors.grey, borderBottomLeftRadius: size, borderBottomRightRadius: size, - paddingTop: size / 2, + paddingTop: Math.round(size / 2), width: size, }, text: { color: Kb.Styles.globalColors.black_50, - paddingBottom: size / 5, + paddingBottom: Math.round(size / 5), }, })), }) diff --git a/shared/constants/chat/message.tsx b/shared/constants/chat/message.tsx index 1ee7d91a253d..34e0284f9c22 100644 --- a/shared/constants/chat/message.tsx +++ b/shared/constants/chat/message.tsx @@ -63,6 +63,18 @@ export const isMessageWithReactions = (message: T.Chat.Message) => { !message.errorReason ) } + +export const getReactionOrder = (reactions: ReadonlyMap<string, T.Chat.ReactionDesc>): Array<string> => { + const keys = [...reactions.keys()] + const scoreMap = new Map( + keys.map(emoji => [ + emoji, + reactions.get(emoji)!.users.reduce((min, r) => Math.min(min, r.timestamp), Infinity), + ]) + ) + return keys.sort((a, b) => scoreMap.get(a)! - scoreMap.get(b)!) +} + export const getMessageID = (m: T.RPCChat.UIMessage) => { switch (m.state) { case T.RPCChat.MessageUnboxedState.valid: diff --git a/shared/constants/init/index.native.tsx b/shared/constants/init/index.native.tsx index ee6149d76aa2..c52c23f5332d 100644 --- a/shared/constants/init/index.native.tsx +++ b/shared/constants/init/index.native.tsx @@ -35,7 +35,7 @@ import {initPushListener, getStartupDetailsFromInitialPush} from './push-listene import {initSharedSubscriptions, _onEngineIncoming} from './shared' import {noConversationIDKey} from '../types/chat/common' import {getSelectedConversation} from '../chat/common' -import {getConvoState} from '@/stores/convostate' +import {getConvoState, getConvoUIState} from '@/stores/convostate' import { requestLocationPermission, saveAttachmentToCameraRoll, @@ -168,7 +168,7 @@ const ensureBackgroundTask = () => { } const setPermissionDeniedCommandStatus = (conversationIDKey: T.Chat.ConversationIDKey, text: string) => { - getConvoState(conversationIDKey).dispatch.setCommandStatusInfo({ + getConvoUIState(conversationIDKey).dispatch.setCommandStatusInfo({ actions: [T.RPCChat.UICommandStatusActionTyp.appsettings], displayText: text, displayType: T.RPCChat.UICommandStatusDisplayTyp.error, diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index a023d089e168..f0dcd797e579 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -12,9 +12,7 @@ import { } from '@react-navigation/core' import type {StaticScreenProps} from '@react-navigation/core' import type { - AllOptionalParamRouteKeys, - NoParamRouteKeys, - ParamRouteKeys, + NavigateAppendType, RouteKeys, RootParamList as KBRootParamList, } from '@/router-v2/route-params' @@ -255,18 +253,7 @@ export const navUpToScreen = (name: RouteKeys) => { n.dispatch(StackActions.popTo(typeof name === 'string' ? name : String(name))) } -export function navigateAppend<RouteName extends NoParamRouteKeys | AllOptionalParamRouteKeys>( - path: RouteName, - replace?: boolean -): void -export function navigateAppend<RouteName extends ParamRouteKeys>( - path: {name: RouteName; params: KBRootParamList[RouteName]}, - replace?: boolean -): void -export function navigateAppend( - path: RouteKeys | {name: RouteKeys; params: object | undefined}, - replace?: boolean -) { +export function navigateAppend(path: NavigateAppendType, replace?: boolean) { DEBUG_NAV && console.log('[Nav] navigateAppend', {path}) const n = _getNavigator() if (!n) { @@ -389,19 +376,27 @@ export const setChatRootParams = (params: Partial<NonNullable<KBRootParamList['c }) } -export const navToThread = (conversationIDKey: T.Chat.ConversationIDKey) => { +type ThreadSearchNavParams = { + threadSearch?: {query?: string} +} + +export const navToThread = ( + conversationIDKey: T.Chat.ConversationIDKey, + navParams?: ThreadSearchNavParams +) => { DEBUG_NAV && console.log('[Nav] navToThread', conversationIDKey) const n = _getNavigator() if (!n) return const rs = getRootState() if (!rs?.key) return + const params = {conversationIDKey, threadSearch: navParams?.threadSearch} if (isSplit) { // Desktop/tablet: reset the tab navigator state to switch to chatTab with chatRoot params. // All tab stacks share the same screen config, so navigate('chatRoot') would target the // current tab. Separate switchTab + navigateAppend has a race (stale state between dispatches). // A single reset on the tab navigator atomically switches tabs and sets params. - setChatRootParams({conversationIDKey}) + setChatRootParams(params) } else { // Phone: switch to the chat tab, then push the conversation above the tabs. const nextState = { @@ -413,7 +408,7 @@ export const navToThread = (conversationIDKey: T.Chat.ConversationIDKey) => { routes: [{name: Tabs.chatTab, state: {index: 0, routes: [{name: 'chatRoot', params: {}}]}}], }, }, - {name: 'chatConversation', params: {conversationIDKey}}, + {name: 'chatConversation', params}, ], } n.dispatch({ diff --git a/shared/incoming-share/index.tsx b/shared/incoming-share/index.tsx index 2ed4eae5ba72..f767e1a0c0a7 100644 --- a/shared/incoming-share/index.tsx +++ b/shared/incoming-share/index.tsx @@ -184,7 +184,7 @@ const IncomingShare = (props: IncomingShareWithSelectionProps) => { if (!canDirectNav || hasNavigatedRef.current) return hasNavigatedRef.current = true const {dispatch} = Chat.getConvoState(selectedConversationIDKey) - text && dispatch.injectIntoInput(text) + text && Chat.getConvoUIState(selectedConversationIDKey).dispatch.injectIntoInput(text) dispatch.navigateToThread('extension') if (sendPaths.length > 0) { const meta = Chat.getConvoState(selectedConversationIDKey).meta diff --git a/shared/people/announcement.tsx b/shared/people/announcement.tsx index 97ed1c660659..4c48835ae581 100644 --- a/shared/people/announcement.tsx +++ b/shared/people/announcement.tsx @@ -50,7 +50,7 @@ const Container = (ownProps: OwnProps) => { case T.RPCGen.AppLinkType.git: switchTab(C.isMobile ? C.Tabs.settingsTab : C.Tabs.gitTab) if (C.isMobile) { - navigateAppend(Settings.settingsGitTab) + navigateAppend({name: Settings.settingsGitTab, params: {}}) } break case T.RPCGen.AppLinkType.devices: diff --git a/shared/people/todo.tsx b/shared/people/todo.tsx index b390fec71405..6b3a2e98819f 100644 --- a/shared/people/todo.tsx +++ b/shared/people/todo.tsx @@ -189,7 +189,7 @@ const GitRepoTask = (props: TodoOwnProps) => { const {navigateAppend, switchTab} = useRouterNavigation() const onConfirm = (isTeam: boolean) => { if (C.isMobile) { - navigateAppend(settingsGitTab) + navigateAppend({name: settingsGitTab, params: {}}) } else { switchTab(C.Tabs.gitTab) } diff --git a/shared/router-v2/route-params.tsx b/shared/router-v2/route-params.tsx index ae7573dd636f..a6706d17be2b 100644 --- a/shared/router-v2/route-params.tsx +++ b/shared/router-v2/route-params.tsx @@ -46,17 +46,10 @@ export type NoParamRouteKeys = { [K in RouteKeys]: RootParamList[K] extends undefined ? K : never }[RouteKeys] export type ParamRouteKeys = Exclude<RouteKeys, NoParamRouteKeys> -// Routes with required params would break if navigated to without params. -// Routes where all params are optional can be safely navigated to with just a name string. -export type AllOptionalParamRouteKeys = { - [K in ParamRouteKeys]: {} extends NonNullable<RootParamList[K]> ? K : never -}[ParamRouteKeys] export type NavigateAppendArg<RouteName extends RouteKeys> = RouteName extends RouteName ? RootParamList[RouteName] extends undefined ? RouteName - : {} extends NonNullable<RootParamList[RouteName]> - ? RouteName | {name: RouteName; params: RootParamList[RouteName]} - : {name: RouteName; params: RootParamList[RouteName]} + : {name: RouteName; params: RootParamList[RouteName]} : never export type NavigateAppendType = NavigateAppendArg<RouteKeys> export type RootRouteProps<RouteName extends keyof RootParamList> = RouteProp<RootParamList, RouteName> diff --git a/shared/settings/root-phone.tsx b/shared/settings/root-phone.tsx index f098cebfdd14..aefb55cf3fc7 100644 --- a/shared/settings/root-phone.tsx +++ b/shared/settings/root-phone.tsx @@ -87,7 +87,7 @@ function SettingsNav() { badgeNumber: badgeNumbers.get(C.Tabs.gitTab), icon: 'iconfont-nav-2-git', onClick: () => { - navigateAppend(Settings.settingsGitTab) + navigateAppend({name: Settings.settingsGitTab, params: {}}) }, text: 'Git', }, @@ -136,7 +136,7 @@ function SettingsNav() { }, { onClick: () => { - navigateAppend(Settings.settingsFeedbackTab) + navigateAppend({name: Settings.settingsFeedbackTab, params: {}}) }, text: 'Feedback', }, diff --git a/shared/stores/chat.tsx b/shared/stores/chat.tsx index df2eff96649b..1a342b3cf5ba 100644 --- a/shared/stores/chat.tsx +++ b/shared/stores/chat.tsx @@ -890,13 +890,15 @@ export const useChatState = Z.createZustand<State>('chat', (set, get) => { query = selected?.query } - storeRegistry.getConvoState(conversationIDKey).dispatch.navigateToThread('inboxSearch') if (query) { - const cs = storeRegistry.getConvoState(conversationIDKey) - cs.dispatch.setThreadSearchQuery(query) - cs.dispatch.toggleThreadSearch(false) - cs.dispatch.threadSearch(query) + storeRegistry.getConvoState(conversationIDKey).dispatch.navigateToThread( + 'inboxSearch', + undefined, + undefined, + query + ) } else { + storeRegistry.getConvoState(conversationIDKey).dispatch.navigateToThread('inboxSearch') get().dispatch.toggleInboxSearch(false) } }, diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 64b2a4ab4c5f..4a501fc47370 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -54,12 +54,6 @@ import type {useChatState, RefreshReason} from '@/stores/chat' const {darwinCopyToChatTempUploadFile} = KB2.functions -const makeThreadSearchInfo = (): T.Chat.ThreadSearchInfo => ({ - hits: [], - status: 'initial', - visible: false, -}) - const noParticipantInfo: T.Chat.ParticipantInfo = { all: [], contactName: new Map(), @@ -116,12 +110,8 @@ type ConvoStore = T.Immutable<{ botSettings: Map<string, T.RPCGen.TeamBotSettings | undefined> botTeamRoleMap: Map<string, T.Teams.TeamRoleType | undefined> commandMarkdown?: T.RPCChat.UICommandMarkdown - commandStatus?: T.Chat.CommandStatusInfo dismissedInviteBanners: boolean - editing: T.Chat.Ordinal // current message being edited, explodingMode: number // seconds to exploding message expiration, - giphyResult?: T.RPCChat.GiphySearchResults - giphyWindow: boolean loaded: boolean // did we ever load this thread yet markedAsUnread: T.Chat.Ordinal messageCenterOrdinal?: T.Chat.CenterOrdinal // ordinals to center threads on, @@ -136,20 +126,38 @@ type ConvoStore = T.Immutable<{ participants: T.Chat.ParticipantInfo pendingJumpMessageID?: T.Chat.MessageID pendingOutboxToOrdinal: Map<T.Chat.OutboxID, T.Chat.Ordinal> // messages waiting to be sent, - reactionOrderMap: Map<T.Chat.Ordinal, ReadonlyArray<string>> - replyTo: T.Chat.Ordinal + rowRecycleTypeMap: Map<T.Chat.Ordinal, string> separatorMap: Map<T.Chat.Ordinal, T.Chat.Ordinal> showUsernameMap: Map<T.Chat.Ordinal, string> threadLoadStatus: T.RPCChat.UIChatThreadStatusTyp - threadSearchInfo: T.Chat.ThreadSearchInfo - threadSearchQuery: string typing: ReadonlySet<string> unfurlPrompt: Map<T.Chat.MessageID, Set<string>> unread: number - unsentText?: string validatedOrdinalRange?: {from: T.Chat.Ordinal; to: T.Chat.Ordinal} }> +export type ConvoUIStore = T.Immutable<{ + commandStatus?: T.Chat.CommandStatusInfo + editing: T.Chat.Ordinal + giphyResult?: T.RPCChat.GiphySearchResults + giphyWindow: boolean + replyTo: T.Chat.Ordinal + unsentText?: string +}> + +export interface ConvoUIState extends ConvoUIStore { + dispatch: { + injectIntoInput: (text?: string) => void + resetState: () => void + setCommandStatusInfo: (info?: T.Chat.CommandStatusInfo) => void + setEditing: (ordinal: T.Chat.Ordinal | 'last' | 'clear') => void + setGiphyResult: (result?: T.RPCChat.GiphySearchResults) => void + setGiphyWindow: (show: boolean) => void + setReplyTo: (ordinal: T.Chat.Ordinal) => void + toggleGiphyPrefill: () => void + } +} + const initialConvoStore: ConvoStore = { accountsInfoMap: new Map(), attachmentViewMap: new Map(), @@ -158,12 +166,8 @@ const initialConvoStore: ConvoStore = { botSettings: new Map(), botTeamRoleMap: new Map(), commandMarkdown: undefined, - commandStatus: undefined, dismissedInviteBanners: false, - editing: T.Chat.numberToOrdinal(0), explodingMode: 0, - giphyResult: undefined, - giphyWindow: false, id: noConversationIDKey, loaded: false, markedAsUnread: T.Chat.numberToOrdinal(0), @@ -179,20 +183,25 @@ const initialConvoStore: ConvoStore = { participants: noParticipantInfo, pendingJumpMessageID: undefined, pendingOutboxToOrdinal: new Map(), - reactionOrderMap: new Map(), - replyTo: T.Chat.numberToOrdinal(0), + rowRecycleTypeMap: new Map(), separatorMap: new Map(), showUsernameMap: new Map(), threadLoadStatus: T.RPCChat.UIChatThreadStatusTyp.none, - threadSearchInfo: makeThreadSearchInfo(), - threadSearchQuery: '', typing: new Set(), unfurlPrompt: new Map(), unread: 0, - unsentText: undefined, validatedOrdinalRange: undefined, } +const initialConvoUIStore: ConvoUIStore = { + commandStatus: undefined, + editing: T.Chat.numberToOrdinal(0), + giphyResult: undefined, + giphyWindow: false, + replyTo: T.Chat.numberToOrdinal(0), + unsentText: undefined, +} + type LoadMoreMessagesParams = { forceContainsLatestCalc?: boolean messageIDControl?: T.RPCChat.MessageIDControl @@ -261,7 +270,6 @@ export interface ConvoState extends ConvoStore { ) => void giphySend: (result: T.RPCChat.GiphySearchResult) => void hideConversation: (hide: boolean) => void - injectIntoInput: (text?: string) => void joinConversation: () => void jumpToRecent: () => void leaveConversation: (navToInbox?: boolean) => void @@ -291,7 +299,12 @@ export interface ConvoState extends ConvoStore { ordinals?: ReadonlyArray<T.Chat.Ordinal> }) => void mute: (m: boolean) => void - navigateToThread: (reason: NavReason, highlightMessageID?: T.Chat.MessageID, pushBody?: string) => void + navigateToThread: ( + reason: NavReason, + highlightMessageID?: T.Chat.MessageID, + pushBody?: string, + threadSearchQuery?: string + ) => void openFolder: () => void onEngineIncoming: (action: EngineGen.Actions) => void onIncomingMessage: (incoming: T.RPCChat.IncomingMessage) => void @@ -312,24 +325,18 @@ export interface ConvoState extends ConvoStore { selectedConversation: () => void sendAudioRecording: (path: string, duration: number, amps: ReadonlyArray<number>) => Promise<void> sendMessage: (text: string) => void - setCommandStatusInfo: (info?: T.Chat.CommandStatusInfo) => void setConvRetentionPolicy: (policy: T.Retention.RetentionPolicy) => void - setEditing: (ordinal: T.Chat.Ordinal | 'last' | 'clear') => void setExplodingMode: (seconds: number, incoming?: boolean) => void setMarkAsUnread: (readMsgID?: T.Chat.MessageID | false) => void setMeta: (m?: T.Chat.ConversationMeta) => void setMinWriterRole: (role: T.Teams.TeamRoleType) => void setParticipants: (p: ConvoState['participants']) => void - setReplyTo: (o: T.Chat.Ordinal) => void - setThreadSearchQuery: (query: string) => void setTyping: DebouncedFunc<(t: Set<string>) => void> showInfoPanel: (show: boolean, tab: 'settings' | 'members' | 'attachments' | 'bots' | undefined) => void tabSelected: () => void - threadSearch: (query: string) => void - toggleGiphyPrefill: () => void toggleMessageCollapse: (messageID: T.Chat.MessageID, ordinal: T.Chat.Ordinal) => void toggleMessageReaction: (ordinal: T.Chat.Ordinal, emoji: string) => void - toggleThreadSearch: (hide?: boolean) => void + toggleThreadSearch: (hide?: boolean, query?: string) => void unfurlResolvePrompt: ( messageID: T.Chat.MessageID, domain: string, @@ -510,9 +517,13 @@ const loadThreadMessageTypes = enumKeys(T.RPCChat.MessageType).reduce<Array<T.RP ) const createSlice = - (id: T.Chat.ConversationIDKey = noConversationIDKey): Z.ImmerStateCreator<ConvoState> => + ( + id: T.Chat.ConversationIDKey = noConversationIDKey, + getLinkedUIState: () => ConvoUIState = () => getConvoUIState(id) + ): Z.ImmerStateCreator<ConvoState> => (set, get) => { const defer = convoDeferImpl ?? stubDefer + const getUI = getLinkedUIState const getLastOrdinal = () => get().messageOrdinals?.at(-1) ?? T.Chat.numberToOrdinal(0) const getCurrentUser = () => { const s = useCurrentUserState.getState() @@ -585,17 +596,6 @@ const createSlice = return clientPrev || T.Chat.numberToMessageID(0) } - const getReactionOrder = (reactions: ReadonlyMap<string, T.Chat.ReactionDesc>): Array<string> => { - const keys = [...reactions.keys()] - const scoreMap = new Map( - keys.map(emoji => [ - emoji, - reactions.get(emoji)!.users.reduce((min, r) => Math.min(min, r.timestamp), Infinity), - ]) - ) - return keys.sort((a, b) => scoreMap.get(a)! - scoreMap.get(b)!) - } - const clearMessageIDIndexForOrdinal = ( state: Z.WritableDraft<ConvoState>, ordinal: T.Chat.Ordinal, @@ -623,25 +623,93 @@ const createSlice = ) => messageIDToOrdinal(state.messageMap, state.pendingOutboxToOrdinal, messageID, state.messageIDToOrdinal) - const syncSeparatorMap = (s: Z.WritableDraft<ConvoState>) => { + const findOrdinalIndex = (ordinals: ReadonlyArray<T.Chat.Ordinal>, ordinal: T.Chat.Ordinal) => { + let low = 0 + let high = ordinals.length + while (low < high) { + const mid = Math.floor((low + high) / 2) + if (ordinals[mid]! < ordinal) { + low = mid + 1 + } else { + high = mid + } + } + return low + } + + const refreshDerivedMetadata = ( + s: Z.WritableDraft<ConvoState>, + changedOrdinals: ReadonlySet<T.Chat.Ordinal> + ) => { + if (changedOrdinals.size === 0) { + return + } + + const messageOrdinals = s.messageOrdinals ?? [] const you = useCurrentUserState.getState().username - const mo = s.messageOrdinals ?? [] - const sm = new Map<T.Chat.Ordinal, T.Chat.Ordinal>() - const um = new Map<T.Chat.Ordinal, string>() - const rm = new Map<T.Chat.Ordinal, Array<string>>() - let p = T.Chat.numberToOrdinal(0) - let pMessage: T.Chat.Message | undefined = undefined - for (const o of mo) { - sm.set(o, p) - const m = s.messageMap.get(o) - if (m) um.set(o, getUsernameToShow(m, pMessage, you)) - if (m?.reactions?.size) rm.set(o, getReactionOrder(m.reactions)) - pMessage = m as T.Chat.Message | undefined - p = o + const ordinalsToRefresh = new Set(changedOrdinals) + + for (const ordinal of changedOrdinals) { + const idx = findOrdinalIndex(messageOrdinals, ordinal) + const maybeCurrent = messageOrdinals[idx] + const nextOrdinal = maybeCurrent === ordinal ? messageOrdinals[idx + 1] : maybeCurrent + if (nextOrdinal !== undefined) { + ordinalsToRefresh.add(nextOrdinal) + } + } + + for (const ordinal of ordinalsToRefresh) { + const idx = findOrdinalIndex(messageOrdinals, ordinal) + if (messageOrdinals[idx] !== ordinal) { + s.rowRecycleTypeMap.delete(ordinal) + s.separatorMap.delete(ordinal) + s.showUsernameMap.delete(ordinal) + continue + } + + const previousOrdinal = idx > 0 ? messageOrdinals[idx - 1]! : T.Chat.numberToOrdinal(0) + const message = s.messageMap.get(ordinal) + if (!message) { + s.rowRecycleTypeMap.delete(ordinal) + s.separatorMap.delete(ordinal) + s.showUsernameMap.delete(ordinal) + continue + } + + s.separatorMap.set(ordinal, previousOrdinal) + const previousMessage = idx > 0 ? s.messageMap.get(previousOrdinal) : undefined + s.showUsernameMap.set(ordinal, getUsernameToShow(message, previousMessage, you)) + setRowRenderDerivedMetadata(s, ordinal, message) + } + } + + const getRowRecycleType = (message: T.Chat.Message): string | undefined => { + if (message.type !== 'text') { + return undefined + } + + let rowRecycleType = 'text' + if (message.replyTo) { + rowRecycleType += ':reply' + } + if (message.reactions?.size) { + rowRecycleType += ':reactions' + } + + return rowRecycleType === 'text' ? undefined : rowRecycleType + } + + const setRowRenderDerivedMetadata = ( + s: Z.WritableDraft<ConvoState>, + ordinal: T.Chat.Ordinal, + message: T.Chat.Message + ) => { + const rowRecycleType = getRowRecycleType(message) + if (rowRecycleType) { + s.rowRecycleTypeMap.set(ordinal, rowRecycleType) + } else { + s.rowRecycleTypeMap.delete(ordinal) } - s.separatorMap = sm - s.showUsernameMap = um - s.reactionOrderMap = rm } const mergeMessage = ( @@ -731,6 +799,7 @@ const createSlice = set(s => { // Build set of incoming regular ordinals for ordinal management const incomingOrdinals = new Set<T.Chat.Ordinal>() + const touchedOrdinals = new Set<T.Chat.Ordinal>() for (const m of messages) { if (m.conversationMessage !== false && m.type !== 'deleted') { incomingOrdinals.add(m.ordinal) @@ -742,6 +811,7 @@ const createSlice = const regularMessage = m.conversationMessage !== false if (regularMessage && m.type === 'deleted') { + touchedOrdinals.add(m.ordinal) clearMessageIDIndexForOrdinal(s, m.ordinal) s.messageMap.delete(m.ordinal) s.messageTypeMap.delete(m.ordinal) @@ -773,6 +843,10 @@ const createSlice = m.ordinal = mapOrdinal } + if (regularMessage) { + touchedOrdinals.add(mapOrdinal) + } + const existingMsg = s.messageMap.get(mapOrdinal) if (existingMsg?.type === m.type) { if (existingMsg.id && existingMsg.id !== m.id) { @@ -829,6 +903,7 @@ const createSlice = if (validatedRange) { for (const o of existing) { if (o >= validatedRange.from && o <= validatedRange.to && !incomingOrdinals.has(o)) { + touchedOrdinals.add(o) clearMessageIDIndexForOrdinal(s, o) existing.delete(o) s.messageMap.delete(o) @@ -851,7 +926,7 @@ const createSlice = s.messageOrdinals = [...existing].sort((a, b) => a - b) } - syncSeparatorMap(s) + refreshDerivedMetadata(s, touchedOrdinals) }) if (markAsRead) { @@ -925,11 +1000,9 @@ const createSlice = ) => { const {show, clearInput} = action.payload.params if (clearInput) { - get().dispatch.injectIntoInput('') + getUI().dispatch.injectIntoInput('') } - set(s => { - s.giphyWindow = show - }) + getUI().dispatch.setGiphyWindow(show) } const refreshMutualTeamsInConv = () => { @@ -984,7 +1057,7 @@ const createSlice = users: [{timestamp: Date.now(), username}], }) } - s.reactionOrderMap.set(targetOrdinal, getReactionOrder(m.reactions)) + setRowRenderDerivedMetadata(s, targetOrdinal, m) } }) } @@ -1199,7 +1272,7 @@ const createSlice = if (s.messageOrdinals) { s.messageOrdinals = s.messageOrdinals.filter(o => o !== toDelOrdinal) } - syncSeparatorMap(s) + refreshDerivedMetadata(s, new Set([toDelOrdinal])) }) } @@ -1260,7 +1333,7 @@ const createSlice = } const _messageEdit = (ordinal: T.Chat.Ordinal, text: string) => { - get().dispatch.injectIntoInput('') + getUI().dispatch.injectIntoInput('') const m = get().messageMap.get(ordinal) if (!m || !(m.type === 'text' || m.type === 'attachment')) { logger.warn("Can't find message to edit", ordinal) @@ -1268,10 +1341,10 @@ const createSlice = } // Skip if the content is the same if (m.type === 'text' && m.text.stringValue() === text) { - get().dispatch.setEditing('clear') + getUI().dispatch.setEditing('clear') return } else if (m.type === 'attachment' && m.title === text) { - get().dispatch.setEditing('clear') + getUI().dispatch.setEditing('clear') return } set(s => { @@ -1280,7 +1353,7 @@ const createSlice = m1.submitState = 'editing' } }) - get().dispatch.setEditing('clear') + getUI().dispatch.setEditing('clear') const f = async () => { await T.RPCChat.localPostEditNonblockRpcPromise({ @@ -1301,12 +1374,12 @@ const createSlice = } const _messageSend = (text: string, replyTo?: T.Chat.MessageID, waitingKey?: string) => { - get().dispatch.injectIntoInput('') - get().dispatch.setReplyTo(T.Chat.numberToOrdinal(0)) + getUI().dispatch.injectIntoInput('') + getUI().dispatch.setReplyTo(T.Chat.numberToOrdinal(0)) set(s => { s.commandMarkdown = undefined - s.giphyWindow = false }) + getUI().dispatch.setGiphyWindow(false) const f = async () => { const meta = get().meta const tlfName = meta.tlfname @@ -1329,7 +1402,7 @@ const createSlice = incomingCallMap: { 'chat.1.chatUi.chatStellarDone': ({canceled}) => { if (canceled) { - get().dispatch.injectIntoInput(text) + getUI().dispatch.injectIntoInput(text) } }, 'chat.1.chatUi.chatStellarShowConfirm': () => {}, @@ -1638,14 +1711,12 @@ const createSlice = ignorePromise(f()) }, giphySend: result => { - set(s => { - s.giphyWindow = false - }) + getUI().dispatch.setGiphyWindow(false) const f = async () => { try { await T.RPCChat.localTrackGiphySelectRpcPromise({result}) } catch {} - const replyTo = get().messageMap.get(get().replyTo)?.id + const replyTo = get().messageMap.get(getUI().replyTo)?.id _messageSend(result.targetUrl, replyTo) } ignorePromise(f()) @@ -1676,11 +1747,6 @@ const createSlice = } ignorePromise(f()) }, - injectIntoInput: text => { - set(s => { - s.unsentText = text - }) - }, joinConversation: () => { const f = async () => { await T.RPCChat.localJoinConversationByIDLocalRpcPromise({convID: get().getConvID()}) @@ -2304,7 +2370,7 @@ const createSlice = } const text = formatTextForQuoting(message.text.stringValue()) - getConvoState(newThreadCID).dispatch.injectIntoInput(text) + getConvoUIState(newThreadCID).dispatch.injectIntoInput(text) get().dispatch.defer.chatMetasReceived([meta]) getConvoState(newThreadCID).dispatch.navigateToThread('createdMessagePrivately') } @@ -2334,8 +2400,10 @@ const createSlice = s.messageMap.clear() s.messageOrdinals = undefined s.messageTypeMap.clear() + s.rowRecycleTypeMap.clear() + s.separatorMap.clear() + s.showUsernameMap.clear() s.validatedOrdinalRange = undefined - syncSeparatorMap(s) }) }, messagesExploded: (messageIDs, explodedBy) => { @@ -2349,7 +2417,9 @@ const createSlice = m.explodedBy = explodedBy || '' m.reactions = new Map() m.unfurls = new Map() - if (ordinal) s.reactionOrderMap.set(ordinal, []) + if (ordinal) { + setRowRenderDerivedMetadata(s, ordinal, m) + } if (m.type === 'text') { m.flipGameID = '' m.mentionsAt = new Set() @@ -2396,7 +2466,7 @@ const createSlice = if (s.messageOrdinals) { s.messageOrdinals = s.messageOrdinals.filter(o => !allOrdinals.has(o)) } - syncSeparatorMap(s) + refreshDerivedMetadata(s, allOrdinals) }) }, mute: m => { @@ -2409,9 +2479,8 @@ const createSlice = } ignorePromise(f()) }, - navigateToThread: (_reason, highlightMessageID, _pushBody) => { + navigateToThread: (_reason, highlightMessageID, _pushBody, threadSearchQuery) => { set(s => { - s.threadSearchInfo.visible = false // force loaded if we're an error if (s.id === T.Chat.pendingErrorConversationIDKey) { s.loaded = true @@ -2436,11 +2505,12 @@ const createSlice = } // we select the chat tab and change the params + const threadSearch = threadSearchQuery ? {query: threadSearchQuery} : undefined if (Common.isSplit) { - navToThread(conversationIDKey) + navToThread(conversationIDKey, {threadSearch}) // immediately switch stack to an inbox | thread stack } else if (reason === 'push' || reason === 'savedLastState') { - navToThread(conversationIDKey) + navToThread(conversationIDKey, {threadSearch}) return } else { // replace if looking at the pending / waiting screen @@ -2453,7 +2523,7 @@ const createSlice = clearModals() } - navigateAppend({name: Common.threadRouteName, params: {conversationIDKey}}, replace) + navigateAppend({name: Common.threadRouteName, params: {conversationIDKey, threadSearch}}, replace) } } updateNav() @@ -2472,7 +2542,7 @@ const createSlice = } case 'chat.1.chatUi.chatCommandStatus': { const {displayText, typ, actions} = action.payload.params - get().dispatch.setCommandStatusInfo({ + getUI().dispatch.setCommandStatusInfo({ actions: T.castDraft(actions) || [], displayText, displayType: typ, @@ -2492,9 +2562,7 @@ const createSlice = break } case 'chat.1.chatUi.chatGiphySearchResults': - set(s => { - s.giphyResult = T.castDraft(action.payload.params.results) - }) + getUI().dispatch.setGiphyResult(action.payload.params.results) break case 'chat.1.NotifyChat.ChatRequestInfo': { @@ -2841,19 +2909,14 @@ const createSlice = } }, sendMessage: text => { - const editOrdinal = get().editing + const editOrdinal = getUI().editing if (editOrdinal) { _messageEdit(editOrdinal, text) } else { - const replyTo = get().messageMap.get(get().replyTo)?.id + const replyTo = get().messageMap.get(getUI().replyTo)?.id _messageSend(text, replyTo) } }, - setCommandStatusInfo: info => { - set(s => { - s.commandStatus = info - }) - }, setConvRetentionPolicy: _policy => { const f = async () => { const convID = get().getConvID() @@ -2871,56 +2934,6 @@ const createSlice = } ignorePromise(f()) }, - setEditing: e => { - // clearing - if (e === 'clear') { - set(s => { - s.editing = T.Chat.numberToOrdinal(0) - }) - get().dispatch.injectIntoInput('') - return - } - - const messageMap = get().messageMap - - let ordinal = T.Chat.numberToOrdinal(0) - // Editing last message - if (e === 'last') { - const editLastUser = useCurrentUserState.getState().username - // Editing your last message - const ordinals = get().messageOrdinals - const found = - !!ordinals && - findLast(ordinals, o => { - const message = messageMap.get(o) - return !!( - (message?.type === 'text' || message?.type === 'attachment') && - message.author === editLastUser && - !message.exploded && - message.isEditable - ) - }) - if (!found) return - ordinal = found - } else { - ordinal = e - } - - if (!ordinal) { - return - } - const message = messageMap.get(ordinal) - if (message?.type === 'text' || message?.type === 'attachment') { - set(s => { - s.editing = ordinal - }) - if (message.type === 'text') { - get().dispatch.injectIntoInput(message.text.stringValue()) - } else { - get().dispatch.injectIntoInput(message.title) - } - } - }, setExplodingMode: (seconds, incoming) => { set(s => { s.explodingMode = seconds @@ -3089,11 +3102,10 @@ const createSlice = const isGood = get().isMetaGood() if (!wasGood && isGood) { // got a good meta, adopt the draft once - set(s => { - // bail on if there is something - if (s.unsentText !== undefined) return - s.unsentText = s.meta.draft.length ? s.meta.draft : undefined - }) + const ui = getUI() + if (ui.unsentText === undefined) { + ui.dispatch.injectIntoInput(get().meta.draft.length ? get().meta.draft : undefined) + } } }, setMinWriterRole: role => { @@ -3120,16 +3132,6 @@ const createSlice = }) queueInboxRowUpdate(get().id) }, - setReplyTo: o => { - set(s => { - s.replyTo = o - }) - }, - setThreadSearchQuery: query => { - set(s => { - s.threadSearchQuery = query - }) - }, setTyping: throttle((t: Set<string>) => { set(s => { if (!isEqual(s.typing, t)) { @@ -3162,109 +3164,6 @@ const createSlice = get().dispatch.loadMoreMessages({reason: 'tab selected'}) get().dispatch.markThreadAsRead() }, - threadSearch: query => { - set(s => { - s.threadSearchInfo.hits = [] - }) - const f = async () => { - const conversationIDKey = get().id - const {username, devicename} = getCurrentUser() - const onDone = () => { - set(s => { - s.threadSearchInfo.status = 'done' - }) - } - try { - await T.RPCChat.localSearchInboxRpcListener({ - incomingCallMap: { - 'chat.1.chatUi.chatSearchDone': onDone, - 'chat.1.chatUi.chatSearchHit': hit => { - const message = Message.uiMessageToMessage( - conversationIDKey, - hit.searchHit.hitMessage, - username, - getLastOrdinal, - devicename - ) - - if (message) { - set(s => { - // Only add if not already present (idempotent - safe for out-of-order callbacks) - if (!s.threadSearchInfo.hits.find(h => h.id === message.id)) { - s.threadSearchInfo.hits.push(T.castDraft(message)) - } - }) - } - }, - 'chat.1.chatUi.chatSearchInboxDone': onDone, - 'chat.1.chatUi.chatSearchInboxHit': resp => { - const messages = (resp.searchHit.hits || []).reduce<Array<T.Chat.Message>>((l, h) => { - const uiMsg = Message.uiMessageToMessage( - conversationIDKey, - h.hitMessage, - username, - getLastOrdinal, - devicename - ) - if (uiMsg) { - l.push(uiMsg) - } - return l - }, []) - set(s => { - if (messages.length > 0) { - // entirely replace - s.threadSearchInfo.hits = T.castDraft(messages) - } - }) - }, - 'chat.1.chatUi.chatSearchInboxStart': () => { - set(s => { - s.threadSearchInfo.status = 'inprogress' - }) - }, - }, - params: { - identifyBehavior: T.RPCGen.TLFIdentifyBehavior.chatGui, - namesOnly: false, - opts: { - afterContext: 0, - beforeContext: 0, - convID: get().getConvID(), - isRegex: false, - matchMentions: false, - maxBots: 0, - maxConvsHit: 0, - maxConvsSearched: 0, - maxHits: 1000, - maxMessages: -1, - maxNameConvs: 0, - maxTeams: 0, - reindexMode: T.RPCChat.ReIndexingMode.postsearchSync, - sentAfter: 0, - sentBefore: 0, - sentBy: '', - sentTo: '', - skipBotCache: false, - }, - query, - }, - }) - } catch (error) { - if (error instanceof RPCError) { - logger.error('search failed: ' + error.message) - set(s => { - s.threadSearchInfo.status = 'done' - }) - } - } - } - ignorePromise(f()) - }, - toggleGiphyPrefill: () => { - // if the window is up, just blow it away - get().dispatch.injectIntoInput(get().giphyWindow ? '' : '/giphy ') - }, toggleMessageCollapse: (messageID, ordinal) => { const f = async () => { const m = get().messageMap.get(ordinal) @@ -3324,26 +3223,30 @@ const createSlice = } ignorePromise(f()) }, - toggleThreadSearch: hide => { + toggleThreadSearch: (hide, query) => { + const conversationIDKey = get().id + const visible = getVisibleScreen() + const params = visible?.params as + | {conversationIDKey?: T.Chat.ConversationIDKey; threadSearch?: {query?: string}} + | undefined + const nextVisible = hide !== undefined ? !hide : !params?.threadSearch set(s => { - const {threadSearchInfo} = s - threadSearchInfo.hits = [] - threadSearchInfo.status = 'initial' - if (hide !== undefined) { - threadSearchInfo.visible = !hide - } else { - threadSearchInfo.visible = !threadSearchInfo.visible - } - - if (!threadSearchInfo.visible) { + if (!nextVisible) { s.messageCenterOrdinal = undefined } else if (s.messageCenterOrdinal) { s.messageCenterOrdinal.highlightMode = 'none' } }) + const threadSearch = nextVisible ? (query ? {query} : {}) : undefined + if (Common.isSplit) { + setChatRootParams({conversationIDKey, threadSearch}) + } else { + navigateAppend({name: Common.threadRouteName, params: {conversationIDKey, threadSearch}}, true) + } + const f = async () => { - if (!get().threadSearchInfo.visible) { + if (!nextVisible) { await T.RPCChat.localCancelActiveSearchRpcPromise() } } @@ -3511,7 +3414,7 @@ const createSlice = } m.reactions = T.castDraft(newReactions) } - s.reactionOrderMap.set(targetOrdinal, m.reactions ? getReactionOrder(m.reactions) : []) + setRowRenderDerivedMetadata(s, targetOrdinal, m) } }) } @@ -3546,13 +3449,102 @@ const createSlice = } type MadeStore = UseBoundStore<StoreApi<ConvoState>> +type MadeUIStore = UseBoundStore<StoreApi<ConvoUIState>> + +const createConvoUISlice = + ( + id: T.Chat.ConversationIDKey, + getLinkedConvoState: () => ConvoState = () => getConvoState(id) + ): Z.ImmerStateCreator<ConvoUIState> => + (set, get) => ({ + ...initialConvoUIStore, + dispatch: { + injectIntoInput: text => { + set(s => { + s.unsentText = text + }) + }, + resetState: Z.defaultReset, + setCommandStatusInfo: info => { + set(s => { + s.commandStatus = info ? T.castDraft(info) : undefined + }) + }, + setEditing: e => { + if (e === 'clear') { + set(s => { + s.editing = T.Chat.numberToOrdinal(0) + s.unsentText = '' + }) + return + } + + const messageMap = getLinkedConvoState().messageMap + let ordinal = T.Chat.numberToOrdinal(0) + if (e === 'last') { + const editLastUser = useCurrentUserState.getState().username + const ordinals = getLinkedConvoState().messageOrdinals + const found = + !!ordinals && + findLast(ordinals, o => { + const message = messageMap.get(o) + return !!( + (message?.type === 'text' || message?.type === 'attachment') && + message.author === editLastUser && + !message.exploded && + message.isEditable + ) + }) + if (!found) return + ordinal = found + } else { + ordinal = e + } + + if (!ordinal) return + const message = messageMap.get(ordinal) + if (message?.type === 'text' || message?.type === 'attachment') { + set(s => { + s.editing = ordinal + s.unsentText = message.type === 'text' ? message.text.stringValue() : message.title + }) + } + }, + setGiphyResult: result => { + set(s => { + s.giphyResult = result ? T.castDraft(result) : undefined + }) + }, + setGiphyWindow: show => { + set(s => { + s.giphyWindow = show + }) + }, + setReplyTo: ordinal => { + set(s => { + s.replyTo = ordinal + }) + }, + toggleGiphyPrefill: () => { + const shouldClear = get().giphyWindow + set(s => { + s.unsentText = shouldClear ? '' : '/giphy ' + }) + }, + }, + }) export const chatStores: Map<T.Chat.ConversationIDKey, MadeStore> = __DEV__ ? ((globalThis.__hmr_chatStores ??= new Map()) as Map<T.Chat.ConversationIDKey, MadeStore>) : new Map() +export const convoUIStores: Map<T.Chat.ConversationIDKey, MadeUIStore> = __DEV__ + ? (((globalThis as any).__hmr_convoUIStores ??= new Map()) as Map<T.Chat.ConversationIDKey, MadeUIStore>) + : new Map() + export const clearChatStores = () => { chatStores.clear() + convoUIStores.clear() } registerDebugClear(() => { @@ -3567,8 +3559,32 @@ const createConvoStore = (id: T.Chat.ConversationIDKey) => { return next } +const createConvoUIStore = (id: T.Chat.ConversationIDKey) => { + const existing = convoUIStores.get(id) + if (existing) return existing + const next = Z.createZustand<ConvoUIState>(createConvoUISlice(id)) + convoUIStores.set(id, next) + return next +} + export const createConvoStoreForTesting = (id: T.Chat.ConversationIDKey) => { - return Z.createZustand<ConvoState>(createSlice(id)) + return createConvoStoresForTesting(id).convoStore +} + +export const createConvoStoresForTesting = (id: T.Chat.ConversationIDKey) => { + const pair = {} as { + convoStore: UseBoundStore<StoreApi<ConvoState>> + uiStore: UseBoundStore<StoreApi<ConvoUIState>> + } + const convoStore = Z.createZustand<ConvoState>(createSlice(id, () => pair.uiStore.getState())) + const uiStore = Z.createZustand<ConvoUIState>(createConvoUISlice(id, () => pair.convoStore.getState())) + pair.convoStore = convoStore + pair.uiStore = uiStore + return {convoStore, uiStore} +} + +export const createConvoUIStoreForTesting = (id: T.Chat.ConversationIDKey) => { + return createConvoStoresForTesting(id).uiStore } // debug only @@ -3582,6 +3598,11 @@ export function getConvoState(id: T.Chat.ConversationIDKey) { return store.getState() } +export function getConvoUIState(id: T.Chat.ConversationIDKey) { + const store = createConvoUIStore(id) + return store.getState() +} + const Context = React.createContext<MadeStore | null>(null) type ConvoProviderProps = React.PropsWithChildren<{ @@ -3613,12 +3634,22 @@ export function useChatContext<T>(selector: (state: ConvoState) => T): T { return useStore(store, selector) } +export function useChatUIContext<T>(selector: (state: ConvoUIState) => T): T { + const id = useChatContext(s => s.id) + return useConvoUIState(id, selector) +} + // unusual, usually you useContext, but maybe in teams export function useConvoState<T>(id: T.Chat.ConversationIDKey, selector: (state: ConvoState) => T): T { const store = createConvoStore(id) return useStore(store, selector) } +export function useConvoUIState<T>(id: T.Chat.ConversationIDKey, selector: (state: ConvoUIState) => T): T { + const store = createConvoUIStore(id) + return useStore(store, selector) +} + type ChatRouteParams = {conversationIDKey?: T.Chat.ConversationIDKey} type RouteParams = { diff --git a/shared/stores/tests/convostate.test.ts b/shared/stores/tests/convostate.test.ts index 36c621d1aa9a..a2c0e674b491 100644 --- a/shared/stores/tests/convostate.test.ts +++ b/shared/stores/tests/convostate.test.ts @@ -4,7 +4,12 @@ import * as Message from '../../constants/chat/message' import * as T from '../../constants/types' import HiddenString from '../../util/hidden-string' import {useCurrentUserState} from '../current-user' -import {createConvoStoreForTesting, type ConvoState} from '../convostate' +import { + createConvoStoreForTesting, + createConvoStoresForTesting, + type ConvoState, + type ConvoUIState, +} from '../convostate' jest.mock('../inbox-rows', () => ({ queueInboxRowUpdate: jest.fn(), @@ -134,6 +139,26 @@ const makeMeta = (override?: Partial<T.Chat.ConversationMeta>) => ({ ...override, }) +test('getReactionOrder sorts emojis by earliest reaction timestamp', () => { + const reactions = new Map([ + [':fire:', makeReaction('carol', 70)], + [ + ':+1:', + { + decorated: ':+1:', + users: [ + {timestamp: 50, username: 'alice'}, + {timestamp: 30, username: 'bob'}, + ], + }, + ], + [':wave:', makeReaction('bob', 60)], + [':eyes:', makeReaction('dave', 40)], + ]) + + expect(Message.getReactionOrder(reactions)).toEqual([':+1:', ':eyes:', ':wave:', ':fire:']) +}) + const applyState = ( store: {getState: () => any; setState: (state: any) => void}, partial: Partial<ConvoState> & {messageIDToOrdinal?: ReadonlyMap<T.Chat.MessageID, T.Chat.Ordinal>} @@ -149,6 +174,18 @@ const applyState = ( }) } +const applyUIState = ( + store: {getState: () => any; setState: (state: any) => void}, + partial: Partial<ConvoUIState> +) => { + const current = store.getState() + store.setState({ + ...current, + ...partial, + dispatch: current.dispatch, + }) +} + const createStore = () => createConvoStoreForTesting(convID) const seedStore = ( @@ -186,7 +223,6 @@ const seedStore = ( messageTypeMap, meta: makeMeta(), pendingOutboxToOrdinal, - reactionOrderMap: new Map(), separatorMap: new Map(), showUsernameMap: new Map(), ...extra, @@ -204,7 +240,6 @@ const seedStoreWithAnchoredMessage = () => { messageTypeMap: new Map(), meta: makeMeta(), pendingOutboxToOrdinal: new Map([[outboxID, ordinal]]), - reactionOrderMap: new Map(), separatorMap: new Map([[ordinal, T.Chat.numberToOrdinal(0)]]), showUsernameMap: new Map([[ordinal, 'alice']]), } @@ -252,11 +287,42 @@ test('onMessagesUpdated adds messages and recomputes derived thread maps', () => expect(store.getState().messageTypeMap.size).toBe(0) }) +test('message updates refresh derived metadata for the following row', () => { + const firstOrdinal = T.Chat.numberToOrdinal(301) + const secondOrdinal = T.Chat.numberToOrdinal(302) + const firstMsgID = T.Chat.numberToMessageID(301) + const store = seedStore([ + makeTextMessage({ + author: 'bob', + id: firstMsgID, + ordinal: firstOrdinal, + outboxID: T.Chat.stringToOutboxID('first'), + timestamp: 100, + }), + makeTextMessage({ + author: 'bob', + id: T.Chat.numberToMessageID(302), + ordinal: secondOrdinal, + outboxID: T.Chat.stringToOutboxID('second'), + timestamp: 101, + }), + ]) + + store.getState().dispatch.onMessagesUpdated({ + convID: T.Chat.keyToConversationID(convID), + updates: [makeValidTextUIMessage(firstMsgID, 'edited first', {author: 'alice', timestamp: 100})], + }) + + expect(store.getState().showUsernameMap.get(firstOrdinal)).toBe('alice') + expect(store.getState().showUsernameMap.get(secondOrdinal)).toBe('bob') + expect(store.getState().separatorMap.get(secondOrdinal)).toBe(firstOrdinal) +}) + test('reaction updates preserve outbox-anchored row identity', () => { const store = seedStoreWithAnchoredMessage() const reactions = new Map([[':+1:', makeReaction('bob', 5)]]) store.getState().dispatch.updateReactions([{reactions, targetMsgID: msgID}]) - expect(store.getState().reactionOrderMap.get(ordinal)?.[0]).toBe(':+1:') + expect(Message.getReactionOrder(store.getState().messageMap.get(ordinal)?.reactions ?? new Map())[0]).toBe(':+1:') expect(store.getState().pendingOutboxToOrdinal.get(outboxID)).toBe(ordinal) }) @@ -278,10 +344,10 @@ test('reaction updates keep existing emoji order and sort new emojis by first ti store.getState().dispatch.updateReactions([{reactions, targetMsgID: msgID}]) - expect(store.getState().reactionOrderMap.get(ordinal)).toEqual([':+1:', ':eyes:', ':fire:', ':wave:']) const message = store.getState().messageMap.get(ordinal) expect(Message.isMessageWithReactions(message!)).toBe(true) if (message && Message.isMessageWithReactions(message)) { + expect(Message.getReactionOrder(message.reactions ?? new Map())).toEqual([':+1:', ':eyes:', ':fire:', ':wave:']) expect([...(message.reactions?.keys() ?? [])]).toEqual([':+1:', ':wave:', ':eyes:', ':fire:']) } }) @@ -291,7 +357,6 @@ test('reaction updates clear message reactions when the server sends none', () = store.getState().dispatch.updateReactions([{targetMsgID: msgID}]) const message = store.getState().messageMap.get(ordinal) expect(message && Message.isMessageWithReactions(message) ? message.reactions : undefined).toBeUndefined() - expect(store.getState().reactionOrderMap.get(ordinal)).toEqual([]) }) test('reaction updates ignore deleted and placeholder rows', () => { @@ -317,7 +382,6 @@ test('reaction updates ignore deleted and placeholder rows', () => { {reactions, targetMsgID: placeholderMsgID}, ]) - expect(store.getState().reactionOrderMap.size).toBe(0) expect(store.getState().messageMap.get(T.Chat.numberToOrdinal(401))?.type).toBe('deleted') expect(store.getState().messageMap.get(T.Chat.numberToOrdinal(402))?.type).toBe('placeholder') }) @@ -330,6 +394,34 @@ test('message deletion removes the row but preserves the outbox anchor', () => { expect(store.getState().messageIDToOrdinal.has(msgID)).toBe(false) }) +test('message deletion refreshes derived metadata for the next row', () => { + const firstOrdinal = T.Chat.numberToOrdinal(401) + const secondOrdinal = T.Chat.numberToOrdinal(402) + const store = seedStore([ + makeTextMessage({ + author: 'bob', + id: T.Chat.numberToMessageID(401), + ordinal: firstOrdinal, + outboxID: T.Chat.stringToOutboxID('first-delete'), + timestamp: 100, + }), + makeTextMessage({ + author: 'bob', + id: T.Chat.numberToMessageID(402), + ordinal: secondOrdinal, + outboxID: T.Chat.stringToOutboxID('second-delete'), + timestamp: 101, + }), + ]) + + store.getState().dispatch.messagesWereDeleted({ordinals: [firstOrdinal]}) + + expect(store.getState().messageOrdinals).toEqual([secondOrdinal]) + expect(store.getState().separatorMap.has(firstOrdinal)).toBe(false) + expect(store.getState().showUsernameMap.get(secondOrdinal)).toBe('bob') + expect(store.getState().separatorMap.get(secondOrdinal)).toBe(T.Chat.numberToOrdinal(0)) +}) + test('message deletion up to a message ID honors deletable message types', () => { const earlyText = makeTextMessage({ id: T.Chat.numberToMessageID(501), @@ -379,14 +471,12 @@ test('explode-now clears text content and transient metadata in place', () => { expect(message?.type === 'text' ? [...(message.mentionsAt ?? [])] : undefined).toEqual([]) expect(message?.reactions?.size ?? 0).toBe(0) expect(message?.unfurls?.size ?? 0).toBe(0) - expect(state.reactionOrderMap.get(ordinal)).toEqual([]) }) test('messagesClear resets all message indexes and maps', () => { const store = seedStoreWithAnchoredMessage() applyState(store, { loaded: true, - reactionOrderMap: new Map([[ordinal, [':+1:']]]), separatorMap: new Map([[ordinal, T.Chat.numberToOrdinal(0)]]), showUsernameMap: new Map([[ordinal, 'alice']]), validatedOrdinalRange: {from: ordinal, to: ordinal}, @@ -398,7 +488,6 @@ test('messagesClear resets all message indexes and maps', () => { expect(store.getState().messageTypeMap.size).toBe(0) expect(store.getState().pendingOutboxToOrdinal.size).toBe(0) expect(store.getState().messageIDToOrdinal.size).toBe(0) - expect(store.getState().reactionOrderMap.size).toBe(0) expect(store.getState().separatorMap.size).toBe(0) expect(store.getState().showUsernameMap.size).toBe(0) expect(store.getState().validatedOrdinalRange).toBeUndefined() @@ -416,7 +505,6 @@ test('server ack preserves the outbox-anchored ordinal and later msgID lookups h messageTypeMap: new Map(), meta: makeMeta(), pendingOutboxToOrdinal: new Map([[outboxID, pendingOrdinal]]), - reactionOrderMap: new Map(), separatorMap: new Map([[pendingOrdinal, T.Chat.numberToOrdinal(0)]]), showUsernameMap: new Map([[pendingOrdinal, 'alice']]), } @@ -437,7 +525,9 @@ test('server ack preserves the outbox-anchored ordinal and later msgID lookups h const reactions = new Map([[':+1:', makeReaction('bob', 5)]]) store.getState().dispatch.updateReactions([{reactions, targetMsgID: serverMsgID}]) - expect(store.getState().reactionOrderMap.get(pendingOrdinal)?.[0]).toBe(':+1:') + expect(Message.getReactionOrder(store.getState().messageMap.get(pendingOrdinal)?.reactions ?? new Map())[0]).toBe( + ':+1:' + ) store.getState().dispatch.messagesWereDeleted({messageIDs: [serverMsgID]}) @@ -504,7 +594,8 @@ test('onMessageErrored marks the pending message as failed and leaves unknown ou test('setEditing last picks the latest editable local message and injects its content', () => { const attachmentOrdinal = T.Chat.numberToOrdinal(703) - const store = seedStore([ + const {convoStore: store, uiStore} = createConvoStoresForTesting(convID) + applyState(store, seedStore([ makeTextMessage({ author: 'bob', id: T.Chat.numberToMessageID(701), @@ -524,42 +615,42 @@ test('setEditing last picks the latest editable local message and injects its co outboxID: T.Chat.stringToOutboxID('editable-attachment'), title: 'picked attachment title', }), - ]) + ]).getState()) - store.getState().dispatch.setEditing('last') + uiStore.getState().dispatch.setEditing('last') - expect(store.getState().editing).toBe(attachmentOrdinal) - expect(store.getState().unsentText).toBe('picked attachment title') + expect(uiStore.getState().editing).toBe(attachmentOrdinal) + expect(uiStore.getState().unsentText).toBe('picked attachment title') }) test('setEditing clear resets editing state and clears unsent text', () => { - const store = createStore() - applyState(store, { + const {uiStore} = createConvoStoresForTesting(convID) + applyUIState(uiStore, { editing: ordinal, unsentText: 'draft text', }) - store.getState().dispatch.setEditing('clear') + uiStore.getState().dispatch.setEditing('clear') - expect(store.getState().editing).toBe(T.Chat.numberToOrdinal(0)) - expect(store.getState().unsentText).toBe('') + expect(uiStore.getState().editing).toBe(T.Chat.numberToOrdinal(0)) + expect(uiStore.getState().unsentText).toBe('') }) test('setMeta adopts the server draft once when the meta becomes good', () => { - const store = createStore() + const {convoStore: store, uiStore} = createConvoStoresForTesting(convID) store.getState().dispatch.setMeta(makeMeta({draft: 'server draft'})) expect(store.getState().isMetaGood()).toBe(true) - expect(store.getState().unsentText).toBe('server draft') + expect(uiStore.getState().unsentText).toBe('server draft') - store.getState().dispatch.injectIntoInput('local draft') + uiStore.getState().dispatch.injectIntoInput('local draft') store.getState().dispatch.setMeta(makeMeta({draft: 'new server draft'})) - expect(store.getState().unsentText).toBe('local draft') + expect(uiStore.getState().unsentText).toBe('local draft') }) -test('local setters update participants, reply target, search query, and badge', () => { - const store = createStore() +test('local setters update participants, reply target, and badge', () => { + const {convoStore: store, uiStore} = createConvoStoresForTesting(convID) const participants: ConvoState['participants'] = { all: ['alice', 'bob'], contactName: new Map([['bob', 'Bobby']]), @@ -567,31 +658,21 @@ test('local setters update participants, reply target, search query, and badge', } store.getState().dispatch.setParticipants(participants) - store.getState().dispatch.setReplyTo(ordinal) - store.getState().dispatch.setThreadSearchQuery('hello world') + uiStore.getState().dispatch.setReplyTo(ordinal) store.getState().dispatch.badgesUpdated(3) expect(store.getState().participants).toEqual(participants) - expect(store.getState().replyTo).toBe(ordinal) - expect(store.getState().threadSearchQuery).toBe('hello world') + expect(uiStore.getState().replyTo).toBe(ordinal) expect(store.getState().badge).toBe(3) }) -test('toggleThreadSearch resets hits and removes center highlight when opening search', () => { +test('toggleThreadSearch removes center highlight when opening search', () => { const store = createStore() applyState(store, { messageCenterOrdinal: {highlightMode: 'always', ordinal}, - threadSearchInfo: { - hits: [makeTextMessage()], - status: 'done', - visible: false, - }, }) store.getState().dispatch.toggleThreadSearch() - expect(store.getState().threadSearchInfo.visible).toBe(true) - expect(store.getState().threadSearchInfo.hits).toEqual([]) - expect(store.getState().threadSearchInfo.status).toBe('initial') expect(store.getState().messageCenterOrdinal?.highlightMode).toBe('none') }) diff --git a/shared/teams/add-members-wizard/add-from-where.tsx b/shared/teams/add-members-wizard/add-from-where.tsx index 47144f3b3443..01717569d547 100644 --- a/shared/teams/add-members-wizard/add-from-where.tsx +++ b/shared/teams/add-members-wizard/add-from-where.tsx @@ -14,7 +14,7 @@ const AddFromWhere = () => { const onContinueKeybase = () => appendNewTeamBuilder(teamID) const onContinuePhone = () => nav.safeNavigateAppend('teamAddToTeamPhone') const onContinueContacts = () => nav.safeNavigateAppend('teamAddToTeamContacts') - const onContinueEmail = () => nav.safeNavigateAppend('teamAddToTeamEmail') + const onContinueEmail = () => nav.safeNavigateAppend({name: 'teamAddToTeamEmail', params: {}}) return ( <> diff --git a/shared/teams/add-members-wizard/confirm.tsx b/shared/teams/add-members-wizard/confirm.tsx index 2563c8245547..1f9f33d722ad 100644 --- a/shared/teams/add-members-wizard/confirm.tsx +++ b/shared/teams/add-members-wizard/confirm.tsx @@ -202,7 +202,7 @@ const AddMoreMembers = () => { const onAddKeybase = () => appendNewTeamBuilder(teamID) const onAddContacts = () => nav.safeNavigateAppend('teamAddToTeamContacts') const onAddPhone = () => nav.safeNavigateAppend('teamAddToTeamPhone') - const onAddEmail = () => nav.safeNavigateAppend('teamAddToTeamEmail') + const onAddEmail = () => nav.safeNavigateAppend({name: 'teamAddToTeamEmail', params: {}}) return ( <Kb.FloatingMenu attachTo={attachTo}