diff --git a/.cursor/skills/ralph-protocol/SKILL.md b/.cursor/skills/ralph-protocol/SKILL.md index 57bdb5e3e7..40dbdef1ff 100644 --- a/.cursor/skills/ralph-protocol/SKILL.md +++ b/.cursor/skills/ralph-protocol/SKILL.md @@ -1,6 +1,6 @@ --- name: ralph-protocol -description: Collaboration protocol for Ralph loop (Plan → Act → Reflect → Refine). Use when working from goal.md, plan.md, state.json, decisions.md; when executing tasks in a shared plan; or when the user mentions Ralph, multi-agent, or file-based collaboration. +description: Collaboration protocol for Ralph loop (Plan → Act → Reflect → Refine). Use when working from spec.md, plan.md, state.json, decisions.md; when executing tasks in a shared plan; or when the user mentions Ralph, multi-agent, or file-based collaboration. --- # Ralph Protocol (Agent Collaboration) @@ -11,14 +11,14 @@ Files are the source of truth. All agents share memory via files. No silent deci | File | Purpose | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **goal.md** | What we achieve; success criteria; constraints; non-goals. Read first. Only change if goal actually changes. No implementation details. | +| **spec.md** | What we achieve; success criteria; constraints; non-goals. Read first. Only change if scope or constraints actually change. No implementation details. | | **plan.md** | How we achieve it. Ordered tasks, ownership, dependencies, status (`pending \| in-progress \| done \| blocked`). Propose changes before big deviations; don't rewrite completed sections. | | **state.json** | Current memory. Task statuses, flags (`blocked`, `needs-review`, etc.). Update immediately after acting. Read before assuming anything. | | **decisions.md** | Log of non-trivial decisions (what + why). Append only; never delete. Prevents reopening or contradicting past choices. | ## Workflow -**Before acting:** Read goal.md → plan.md → state.json → decisions.md. +**Before acting:** Read spec.md → plan.md → state.json → decisions.md. **During:** Follow the plan; no overlapping work unless coordinated; no undocumented decisions. @@ -42,6 +42,6 @@ When acceptance criteria involve UI: use Playwright (MCP or project config). Tak ## Loop reminder -Each iteration: Plan (update plan.md if needed) → Act (scoped work) → Reflect (learnings) → Refine (plan or decisions). +Each iteration: Plan (update plan.md if needed) → Act (scoped work) → Reflect (learnings) → Refine (plan or decision log). For decision log format and state.json example, see [reference.md](reference.md). Plan structure and worktrees: use make-plans and worktrees skills. diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index d998881bbe..e64dc9aa44 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -5,40 +5,43 @@ import { ChannelSort, LocalMessage, TextComposerMiddleware, - createCommandInjectionMiddleware, - createDraftCommandInjectionMiddleware, createActiveCommandGuardMiddleware, + createCommandInjectionMiddleware, createCommandStringExtractionMiddleware, + createDraftCommandInjectionMiddleware, } from 'stream-chat'; import { AIStateIndicator, - Channel, + ChannelSlot, ChannelAvatar, ChannelHeader, ChannelList, + ChannelListSlot, Chat, ChatView, MessageInput, + MessageList, + ReactionsList, Thread, + ThreadListSlot, + ThreadSlot, ThreadList, - useCreateChatClient, - // VirtualizedMessageList as MessageList, - MessageList, Window, WithComponents, - ReactionsList, WithDragAndDropUpload, - useChatContext, defaultReactionOptions, - ReactionOptions, mapEmojiMartData, + useChannel, + useChatContext, + useCreateChatClient, + type ReactionOptions, } from 'stream-chat-react'; import { createTextComposerEmojiMiddleware, EmojiPicker } from 'stream-chat-react/emojis'; +import data from '@emoji-mart/data'; import { init, SearchIndex } from 'emoji-mart'; -import data from '@emoji-mart/data/sets/14/native.json'; import { humanId } from 'human-id'; -import { chatViewSelectorItemSet } from './Sidebar/ChatViewSelectorItemSet.tsx'; import { useAppSettingsState } from './AppSettings'; +import { chatViewSelectorItemSet } from './Sidebar/ChatViewSelectorItemSet.tsx'; init({ data }); @@ -196,18 +199,20 @@ const App = () => { }} > - + - - - + + + + + @@ -222,15 +227,28 @@ const App = () => { + + - + - + - - + + + + - + + + + + + + + + + @@ -240,7 +258,8 @@ const App = () => { }; const ChannelExposer = () => { - const { channel, client } = useChatContext(); + const channel = useChannel(); + const { client } = useChatContext(); // @ts-expect-error expose client and channel for debugging purposes window.client = client; // @ts-expect-error expose client and channel for debugging purposes diff --git a/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx b/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx index f572518964..d4f6deb8c9 100644 --- a/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx +++ b/examples/vite/src/AppSettings/tabs/Reactions/ReactionsTab.tsx @@ -1,14 +1,12 @@ import { Button, - ChannelActionProvider, - ChannelStateProvider, + ChannelInstanceProvider, ComponentProvider, Message, useComponentContext, } from 'stream-chat-react'; import { appSettingsStore, useAppSettingsState } from '../../state'; import { - reactionsPreviewChannelActions, reactionsPreviewChannelState, reactionsPreviewMessage, reactionsPreviewOptions, @@ -108,24 +106,24 @@ export const ReactionsTab = () => {
Preview
- - - -
  • - -
  • -
    -
    -
    + + +
  • + +
  • +
    +
    diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index 55da708313..0617ac59a0 100644 --- a/examples/vite/src/index.scss +++ b/examples/vite/src/index.scss @@ -24,7 +24,7 @@ body { } @layer stream-overrides { - .str-chat { + .str-chat:not(.str-chat__channel) { height: 100%; width: 100%; } @@ -36,6 +36,10 @@ body { min-height: 0; } + .str-chat__channel { + flex: 1; + } + .str-chat__chat-view { height: 100%; container-type: inline-size; @@ -46,6 +50,7 @@ body { gap: 0; } + .str-chat__thread-list-container, .str-chat__channel-list { flex: 0 0 300px; max-width: 300px; @@ -90,7 +95,7 @@ body { } .str-chat__dropzone-root--thread, - .str-chat__thread-list-container, + //.str-chat__thread-list-container, .str-chat__thread-container { //flex: 0 0 360px; width: 100%; diff --git a/examples/vite/src/stream-imports-layout.scss b/examples/vite/src/stream-imports-layout.scss index 486b8a1b76..3c360d7d95 100644 --- a/examples/vite/src/stream-imports-layout.scss +++ b/examples/vite/src/stream-imports-layout.scss @@ -4,7 +4,7 @@ //@use 'stream-chat-react/dist/scss/v2/Avatar/Avatar-layout'; //@use 'stream-chat-react/dist/scss/v2/AttachmentList/AttachmentList-layout'; -//@use 'stream-chat-react/dist/scss/v2/AttachmentPreviewList/AttachmentPreviewList-layout'; // X +//@use 'stream-chat-react/dist/scss/v2/AttachmentPreviewList/AttachmentPreviewList-layout'; //@use 'stream-chat-react/dist/scss/v2/Autocomplete/Autocomplete-layout'; //@use 'stream-chat-react/dist/scss/v2/AudioRecorder/AudioRecorder-layout'; //@use 'stream-chat-react/dist/scss/v2/BaseImage/BaseImage-layout'; @@ -17,19 +17,19 @@ @use 'stream-chat-react/dist/scss/v2/common/CircleFAButton/CircleFAButton-layout'; //@use 'stream-chat-react/dist/scss/v2/Dialog/Dialog-layout'; //@use 'stream-chat-react/dist/scss/v2/DragAndDropContainer/DragAndDropContainer-layout'; -//@use 'stream-chat-react/dist/scss/v2/DropzoneContainer/DropzoneContainer-layout'; // X +//@use 'stream-chat-react/dist/scss/v2/DropzoneContainer/DropzoneContainer-layout'; //@use 'stream-chat-react/dist/scss/v2/EditMessageForm/EditMessageForm-layout'; //@use 'stream-chat-react/dist/scss/v2/Form/Form-layout'; //@use 'stream-chat-react/dist/scss/v2/ImageCarousel/ImageCarousel-layout'; //@use 'stream-chat-react/dist/scss/v2/Icon/Icon-layout'; @use 'stream-chat-react/dist/scss/v2/InfiniteScrollPaginator/InfiniteScrollPaginator-layout'; -//@use 'stream-chat-react/dist/scss/v2/LinkPreview/LinkPreview-layout'; // X +//@use 'stream-chat-react/dist/scss/v2/LinkPreview/LinkPreview-layout'; @use 'stream-chat-react/dist/scss/v2/LoadingIndicator/LoadingIndicator-layout'; @use 'stream-chat-react/dist/scss/v2/Location/Location-layout'; //@use 'stream-chat-react/dist/scss/v2/Message/Message-layout'; //@use 'stream-chat-react/dist/scss/v2/MessageActionsBox/MessageActionsBox-layout'; //@use 'stream-chat-react/dist/scss/v2/MessageBouncePrompt/MessageBouncePrompt-layout'; -//@use 'stream-chat-react/dist/scss/v2/MessageInput/MessageInput-layout'; // X +//@use 'stream-chat-react/dist/scss/v2/MessageInput/MessageInput-layout'; //@use 'stream-chat-react/dist/scss/v2/MessageList/MessageList-layout'; //@use 'stream-chat-react/dist/scss/v2/MessageList/VirtualizedMessageList-layout'; // @use 'stream-chat-react/dist/scss/v2/MessageReactions/MessageReactions-layout'; diff --git a/specs/layout-controller/decisions.md b/specs/layout-controller/decisions.md new file mode 100644 index 0000000000..5bf9d36940 --- /dev/null +++ b/specs/layout-controller/decisions.md @@ -0,0 +1,1524 @@ +# Layout Controller Decisions + +## Decision: Use spec.md as the Ralph scope document + +**Date:** 2026-02-26 +**Context:** +Ralph protocol previously referenced `goal.md`, while this project scope is already centered on `spec.md` for implementation requirements. + +**Decision:** +Switch Ralph protocol references from `goal.md` to `spec.md` while keeping the decision log filename as `decisions.md`. + +**Reasoning:** +This keeps collaboration files aligned with the existing ChatView layout workflow and avoids duplicate source-of-truth documents. + +**Alternatives considered:** + +- Keep `goal.md` in protocol and add another mapping rule — rejected because it adds translation overhead. +- Use `decision.md` singular filename — rejected to preserve existing plural convention. + +**Tradeoffs / Consequences:** +Older plan areas using `goal.md` should be migrated or treated as legacy until updated. + +## Decision: Record Task 1 as complete in plan state + +**Date:** 2026-02-26 +**Context:** +Task 1 implementation (`layoutControllerTypes.ts`, `LayoutController.ts`) has been added and typechecked in the worktree. + +**Decision:** +Mark Task 1 as done in `state.json` and assign Task 1 ownership in `plan.md` to Codex. + +**Reasoning:** +This keeps the plan memory synchronized with actual repository state and prevents rework. + +**Alternatives considered:** + +- Leave task status pending until tests are added — rejected because Task 1 acceptance criteria are scoped to implementation and compilation. +- Mark as in-progress — rejected because implementation and typecheck already completed. + +**Tradeoffs / Consequences:** +Follow-up tasks should treat Task 1 APIs as the baseline and only refine via explicit plan updates. + +## Decision: Adopt unified slot navigation model for next implementation phase + +**Date:** 2026-02-27 +**Context:** +New requirements were introduced after the initial controller implementation to support one-slot mobile back navigation, unified slot treatment for channel list/search, min-slot fallbacks, mount-preserving hide/unhide, deep-link restore, and a less intimidating DX API. + +**Decision:** +Evolve the spec and plan with a unified slot model: + +- add per-slot parent stacks, +- model `channelList` as an entity slot (no dedicated entity-list-pane state), +- support `minSlots` with fallback content, +- introduce a mount-preserving `Slot` primitive for hide/unhide, +- add `openView` and serializer/restore contract, +- move high-level domain methods (`openChannel`, `openThread`, etc.) into `useChatViewNavigation()` and keep `LayoutController` low-level. + +**Reasoning:** +This design cleanly handles mobile one-slot navigation, avoids divergent list-pane semantics, improves deep-link behavior, and makes common integration paths easier without removing advanced low-level control. + +**Alternatives considered:** + +- Keep current entity-list-pane as a special layout region and only patch back behavior — rejected because it still blocks list replacement in the same slot and creates split semantics. +- Keep all high-level methods on `LayoutController` — rejected because it keeps DX intimidating and mixes domain-level workflow into low-level layout primitives. + +**Tradeoffs / Consequences:** +The implementation requires a second phase touching controller types, ChatView contexts, ChannelHeader behavior, and tests. Existing APIs remain usable during migration but should converge on the new navigation hook model. + +## Decision: Implement resolver composition as pure slot resolvers + +**Date:** 2026-02-26 +**Context:** +Task 2 requires a reusable resolver registry and a default resolver chain for channel-centric layouts. + +**Decision:** +Add `src/components/ChatView/layoutSlotResolvers.ts` with exported pure resolver functions (`requestedSlotResolver`, `firstFree`, `existingThreadSlotForThread`, `existingThreadSlotForChannel`, `earliestOccupied`, `activeOrLast`, `replaceActive`, `replaceLast`, `rejectWhenFull`), plus `composeResolvers`, and define `resolveTargetSlotChannelDefault` as composed chain: +`requestedSlotResolver -> firstFree -> existingThreadSlotForThread -> existingThreadSlotForChannel -> earliestOccupied -> activeOrLast`. + +**Reasoning:** +Pure resolver functions are independently testable and reusable by integrators. Composition preserves deterministic fallback behavior without coupling resolver logic to controller mutation logic. + +**Alternatives considered:** + +- Implement only one monolithic default resolver — rejected because it reduces reuse and test granularity. +- Keep resolvers private inside `LayoutController` — rejected because Task 2 requires exported, reusable strategies. + +**Tradeoffs / Consequences:** +`replaceActive` and `activeOrLast` currently resolve identically by design; keeping both exported names improves API clarity for different integration intents. + +## Decision: Keep activeChatView as a compatibility alias over controller activeView + +**Date:** 2026-02-26 +**Context:** +Task 3 introduces `layoutController` as the source of truth in ChatView context, but existing consumers and selectors read `activeChatView` and call `setActiveChatView`. + +**Decision:** +Expose both `activeView`/`setActiveView` and compatibility aliases `activeChatView`/`setActiveChatView` from `useChatViewContext()`, all mapped to `layoutController.state.activeView` and `layoutController.setActiveView`. + +**Reasoning:** +This keeps existing ChatView usage stable while enabling the new controller-first API without forcing immediate downstream migration. + +**Alternatives considered:** + +- Remove old names and migrate all call sites at once — rejected because it would be a broad breaking change outside Task 3 scope. + +**Tradeoffs / Consequences:** +Context temporarily carries duplicate field names until follow-up cleanup/migration tasks. + +## Decision: Use default channel resolver fallback for internally created controllers + +**Date:** 2026-02-26 +**Context:** +Task 3 requires ChatView to wire a default resolver fallback when `resolveTargetSlot` is absent. + +**Decision:** +When ChatView creates its internal controller, default `resolveTargetSlot` to `resolveTargetSlotChannelDefault`; external `layoutController` instances are left untouched. + +**Reasoning:** +This gives predictable out-of-the-box replacement behavior for the built-in path while respecting externally managed controller policy. + +**Alternatives considered:** + +- Leave resolver undefined and rely on controller fallback only — rejected because it does not satisfy Task 3 acceptance and weakens default DX. +- Force `maxSlots` and resolver onto external controllers — rejected because external controllers should remain authoritative. + +**Tradeoffs / Consequences:** +Internal and external controller paths may differ by integrator design, which is intentional for flexibility. + +## Decision: ChannelHeader toggle now defaults to ChatView layout controller + +**Date:** 2026-02-26 +**Context:** +Task 4 requires ChannelHeader's sidebar toggle to be driven by ChatView layout state, while still allowing external override handlers. + +**Decision:** +Update `ChannelHeader` so the toggle button uses `layoutController.toggleEntityListPane()` by default, add an optional `onSidebarToggle` prop that takes precedence when provided, and derive `sidebarCollapsed` from `!entityListPaneOpen` when `sidebarCollapsed` is not controlled by props. + +**Reasoning:** +This aligns header behavior with the new ChatView layout-controller source of truth and preserves integrator escape hatches for custom sidebar behavior. + +**Alternatives considered:** + +- Keep using `ChatContext.openMobileNav` as default toggle path — rejected because layout responsibilities are being moved to ChatView. +- Require `sidebarCollapsed` to always be controlled by the parent — rejected because default controller-driven behavior should work out of the box. + +**Tradeoffs / Consequences:** +When `ChannelHeader` is rendered outside a ChatView provider, it falls back to the default ChatView context controller state rather than `openMobileNav`; follow-up integration tests in Task 6 should validate expected host usage patterns. + +## Decision: Add opt-in built-in ChatView workspace layout with kind-based slot renderers + +**Date:** 2026-02-26 +**Context:** +Task 5 requires a two-step DX path so integrators can render a nav-rail/entity-list/workspace shell without building custom `DynamicSlotsLayout` and `SlotOutlet` components. + +**Decision:** +Extend `ChatView` with optional `layout='nav-rail-entity-list-workspace'` and `slotRenderers` props. In this mode, `ChatView` renders: + +- nav rail (`ChatViewSelector`) +- entity list pane (`ChannelList` when `activeView='channels'`, `ThreadList` when `activeView='threads'`) controlled by `entityListPaneOpen` +- workspace slots from `availableSlots`, where each bound entity is rendered by `slotRenderers[entity.kind]`. + +The layout container is implemented in `src/components/ChatView/layout/WorkspaceLayout.tsx`, while existing custom-children behavior remains the default when `layout` is not provided. + +**Reasoning:** +This provides the requested low-friction two-step integration while preserving the advanced/custom layout escape hatch and existing usage patterns. + +**Alternatives considered:** + +- Replace current `children` composition model entirely — rejected because it would break advanced/custom integrations. +- Hardcode slot rendering for built-in entity kinds — rejected because it would reduce extensibility and conflict with the spec’s renderer-by-kind design. + +**Tradeoffs / Consequences:** +Built-in mode uses default `ChannelList`/`ThreadList` props; deeper pane customization remains available through custom layout mode until dedicated built-in pane configuration is introduced. + +## Decision: Add Task 6 coverage across controller, resolver, ChatView integration, and ChannelHeader toggle behavior + +**Date:** 2026-02-26 +**Context:** +Task 6 requires tests for resolver behavior, controller `open` outcomes/`occupiedAt`, thread-to-channel integration flow, and ChannelHeader entity list pane toggling. + +**Decision:** +Add: + +- `src/components/ChatView/__tests__/layoutController.test.ts` for controller open statuses (`opened`/`replaced`/`rejected`), `occupiedAt` lifecycle, duplicate policies (`reject`/`move`), and `resolveTargetSlotChannelDefault` replacement fallbacks. +- `src/components/ChatView/__tests__/ChatView.test.tsx` for integration coverage of switching `activeView` from threads to channels while opening a channel, plus built-in workspace mode rendering with `slotRenderers` and custom children mode preservation. +- new ChannelHeader tests in `src/components/ChannelHeader/__tests__/ChannelHeader.test.js` to assert default ChatView-driven entity pane toggle and `onSidebarToggle` precedence. + +**Reasoning:** +This directly maps to Task 6 acceptance criteria while keeping tests in module-local `__tests__` folders and reusing existing repository test patterns. + +**Alternatives considered:** + +- Add only controller unit tests and defer integration/header coverage — rejected because Task 6 explicitly requires both integration and toggle behavior checks. +- Add integration tests only to story-level/e2e suites — rejected because Task 6 scope is unit/integration tests in component modules. + +**Tradeoffs / Consequences:** +In this local environment, executing Jest is blocked by missing runtime dependency (`@babel/runtime/helpers/interopRequireDefault`) from linked `stream-chat-js`; typecheck passes, and full Jest verification should be rerun once dependency linkage is fixed. + +## Decision: Align spec with currently implemented Task 1-6 API surface + +**Date:** 2026-02-27 +**Context:** +`spec.md` had drifted toward planned future tasks (`openView`, slot history, unified `channelList` slot entity, and `useChatViewNavigation`) that are not implemented yet. Task 7 requires spec-to-code alignment and migration guidance based on current exports. + +**Decision:** +Rewrite `spec.md` as an implementation snapshot for completed tasks only: + +- document current `LayoutController` contract (`bind`, `clear`, `open`, domain open helpers, `setActiveView`, `setMode`, `setEntityListPaneOpen`, `toggleEntityListPane`), +- document current state shape (`entityListPaneOpen`, `slotBindings`, `slotMeta`, `availableSlots`), +- document current resolver registry and default chain, +- document built-in ChatView layout mode (`layout='nav-rail-entity-list-workspace'` + `slotRenderers`), +- add migration notes and low-level vs high-level usage examples, +- explicitly list deferred/future APIs as non-goals for this iteration. + +**Reasoning:** +Keeping the spec strictly aligned with shipped code avoids false integration assumptions while still preserving roadmap context. + +**Alternatives considered:** + +- Keep future API proposals inline as if implemented — rejected because it contradicts Task 7 acceptance criteria. +- Remove future references entirely — rejected because briefly flagging non-goals clarifies why some planned items are still pending. + +**Tradeoffs / Consequences:** +Spec consumers now get accurate implementation guidance, while future tasks (8+) remain documented as pending in `plan.md`. + +## Decision: Add per-slot parent history and header back-priority behavior in Task 8 + +**Date:** 2026-02-27 +**Context:** +Task 8 requires deterministic back navigation inside a slot and header behavior that prefers back when slot history exists. + +**Decision:** +Extend layout controller state with per-slot `slotHistory`, add low-level commands `pushParent`, `popParent`, and `close`, and make `open(...)` push replaced entities onto slot history before rebinding. Update `ChannelHeader` so the leading action uses `close(activeSlot)` (with back icon/label) whenever the active slot has parent history; otherwise it keeps existing list-toggle behavior (`onSidebarToggle` override first, then `toggleEntityListPane`). + +**Reasoning:** +Controller-managed history keeps navigation deterministic and domain-agnostic, while header logic can remain presentation-first and state-driven. + +**Alternatives considered:** + +- Track history only in ChannelHeader local/UI state — rejected because navigation state must survive outside header lifecycles. +- Add back logic only for threads in header — rejected because Task 8 requires generic per-slot stack behavior. + +**Tradeoffs / Consequences:** +`slotHistory` is optional at the type level for compatibility with existing typed state fixtures, but initialized and maintained by controller internals. Additional slot-model unification (`channelList` as slot entity) is deferred to Task 9. + +## Decision: Unify entity-list rendering via channelList slot without introducing a WorkspaceLayout variant prop + +**Date:** 2026-02-27 +**Context:** +Task 9 requires treating channel list as a regular slot entity and removing layout-path dependence on `entityListPaneOpen`. An intermediate proposal added a `variant` flag on `WorkspaceLayout` slots to identify list-pane placement. + +**Decision:** +Do not add a `variant` prop. Instead: + +- `ChatView` derives the entity-list pane by finding the slot binding with `kind: 'channelList'`, +- `ChatView` passes that slot as `entityListSlot` to `WorkspaceLayout`, +- remaining slot entries are passed as workspace slots, +- `ChannelHeader` toggles list visibility by binding/clearing `channelList` slot entities (with `onSidebarToggle` override still supported when no back-history action is active). + +**Reasoning:** +Binding kind already provides the semantic signal; adding a second classification prop would duplicate source-of-truth and increase mismatch risk. + +**Alternatives considered:** + +- Add `variant: 'entity-list' | 'workspace'` on each slot — rejected as redundant metadata. +- Keep dedicated `entityListPaneOpen` rendering gate — rejected because Task 9 requires slot-model unification. + +**Tradeoffs / Consequences:** +Controller legacy fields (`entityListPaneOpen` and related methods) still exist for backward compatibility, but built-in layout paths now derive list visibility from slot bindings rather than that flag. + +## Decision: Implement Task 10 min-slot initialization and unbound-slot fallback rendering in ChatView built-in layout + +**Date:** 2026-02-27 +**Context:** +Task 10 requires minimum slot rendering before entity selection and fallback content for unbound slots while preserving `maxSlots` as the upper bound. + +**Decision:** +Add `minSlots` to `ChatViewProps` and initialize internal `availableSlots` count from a clamped value `minSlots..maxSlots`. Add optional `slotFallbackRenderer` prop and default fallback content for unbound workspace slots in built-in layout mode. Extend layout state type with optional `minSlots` and `maxSlots` metadata. + +**Reasoning:** +This guarantees a visible empty workspace pane (e.g., alongside `channelList`) before channel selection, while keeping existing resolver and slot binding behavior intact. + +**Alternatives considered:** + +- Keep initialization at `maxSlots` only and rely on blank slots — rejected because `minSlots` would have no practical effect. +- Render fallback only via consumer-provided renderer — rejected because acceptance requires out-of-the-box empty workspace behavior. + +**Tradeoffs / Consequences:** +Built-in fallback text is currently a simple default string unless `slotFallbackRenderer` is provided. Additional localization/styling refinements can be layered later without changing slot semantics. + +## Decision: Replace function-based fallback API with component-based fallback API supporting per-slot overrides + +**Date:** 2026-02-27 +**Context:** +The initial Task 10 fallback API used `slotFallbackRenderer(props)`, but customization needs are better expressed as mountable React components and per-slot overrides. + +**Decision:** +Change ChatView fallback API to: + +- `SlotFallback?: ComponentType<{ slot: string }>` as global fallback component, +- `slotFallbackComponents?: Partial>>` for per-slot overrides, +- resolution order: per-slot component -> global component -> SDK default fallback component. + +**Reasoning:** +Component-based API improves composability (hooks/context/local state in fallback UIs) and allows explicit per-slot customization without conditional render logic in userland callback functions. + +**Alternatives considered:** + +- Keep `slotFallbackRenderer` function — rejected due weaker composability and harder per-slot specialization ergonomics. +- Accept only per-slot components without global default — rejected because a global fallback component remains convenient for common cases. + +**Tradeoffs / Consequences:** +This is an API rename from `slotFallbackRenderer` to `SlotFallback`/`slotFallbackComponents`; consumers using the previous prop must migrate. + +## Decision: Implement generic Slot primitive with hidden-state class contract in WorkspaceLayout + +**Date:** 2026-02-27 +**Context:** +Task 11 requires mount-preserving hide/unhide semantics and a consistent slot-level CSS contract for visibility. + +**Decision:** +Add `src/components/ChatView/layout/Slot.tsx` as a generic slot wrapper and migrate `WorkspaceLayout` to render both entity-list and workspace entries through this component. `Slot` exposes: + +- root class `str-chat__chat-view__slot`, +- hidden modifier class `str-chat__chat-view__slot--hidden`, +- `hidden` prop (mapped to `aria-hidden` and CSS class) while keeping the subtree mounted. + +Add corresponding ChatView SCSS classes for workspace layout shell and slot visibility. + +**Reasoning:** +Centralizing slot visibility behavior in one primitive avoids duplicating hide logic and ensures a stable class contract for future hidden-slot controller state wiring. + +**Alternatives considered:** + +- Keep raw `section` tags and toggle `hidden` directly in each caller — rejected because visibility contract becomes fragmented. +- Add explicit `--visible` modifier class — rejected as unnecessary; hidden state alone is sufficient and simpler. + +**Tradeoffs / Consequences:** +Current Task 11 implementation uses existing layout visibility inputs (`entityListHidden` / slot `hidden`) and class contract. Dedicated controller APIs for arbitrary hidden slots remain follow-up work. + +## Decision: Add openView + snapshot serialization/restore helpers with safe default entity handling + +**Date:** 2026-02-27 +**Context:** +Task 12 requires view-first navigation (`openView`) and layout snapshot round-tripping including slot bindings, hidden slots, and parent history while avoiding unsafe assumptions for non-serializable runtime entities. + +**Decision:** +Update controller and types to include: + +- `openView(view, options?)` on `LayoutController`, +- `hiddenSlots` in layout state and `setSlotHidden(slot, hidden)` command, +- typed snapshot model (`ChatViewLayoutSnapshot`) and serializer contracts in `layoutControllerTypes.ts`. + +Add `src/components/ChatView/layoutController/serialization.ts` with: + +- `serializeLayoutState(...)` / `restoreLayoutState(...)`, +- `serializeLayoutControllerState(...)` / `restoreLayoutControllerState(...)`, +- default serializer/deserializer that only handles plain-data entity kinds (`channelList`, `userList`, `searchResults`) and skips unresolved kinds unless custom serializer/deserializer callbacks are provided. + +**Reasoning:** +This enables deep-link and persistence flows without trying to serialize non-plain runtime objects (e.g., channel/thread instances), while still preserving history/visibility semantics for serializable bindings. + +**Alternatives considered:** + +- Attempt default serialization for all entity kinds — rejected due unsafe/non-deterministic runtime object encoding. +- Store only active view and drop slot state — rejected because Task 12 explicitly requires preserving stack/visibility semantics. + +**Tradeoffs / Consequences:** +Out-of-the-box round-trip fully preserves serializable entity kinds; channel/thread restoration requires consumer-provided deserialize hooks in the restore options. + +## Decision: Add dedicated ChatViewNavigation context/hook for high-level domain actions and route ChannelHeader through it + +**Date:** 2026-02-27 +**Context:** +Task 13 requires a less intimidating DX path for common navigation flows and a context split between low-level layout control and high-level domain actions. + +**Decision:** +Add `ChatViewNavigationContext.tsx` with `useChatViewNavigation()` and a provider mounted inside `ChatView`. The navigation hook exposes: + +- `openChannel`, `closeChannel`, +- `openThread`, `closeThread`, +- `hideChannelList`, `unhideChannelList`, +- `openView`. + +Update `ChannelHeader` to use `useChatViewNavigation()` for list hide/unhide behavior while keeping back action semantics based on slot history. Export the navigation context/hook via `ChatView/index.tsx`. + +**Reasoning:** +This gives consumers a domain-focused API without forcing direct `LayoutController` command orchestration for common flows, while still preserving low-level controller access for advanced integrations. + +**Alternatives considered:** + +- Keep all navigation logic in `ChannelHeader` and expose no new hook — rejected because it does not improve consumer DX. +- Replace low-level controller APIs entirely — rejected because advanced workflows still require low-level primitives. + +**Tradeoffs / Consequences:** +Some pre-existing high-level helpers on `LayoutController` remain available for compatibility, but the recommended consumer path is now `useChatViewNavigation()`. + +## Decision: Re-scope remaining roadmap by inserting Thread adaptation as Task 14 and renumbering tests to Task 15 + +**Date:** 2026-02-27 +**Context:** +After Task 13 completion, remaining work was a broad test task. New priority requires adapting `Thread.tsx` to the layout-controller API before final test stabilization. + +**Decision:** +Update collaboration artifacts to: + +- add new **Task 14**: `Thread.tsx` layout-controller adaptation, +- move existing tests task to **Task 15** and add dependency on Task 14, +- update execution phases and file-ownership summary accordingly, +- update `state.json` task keys and `spec.md` remaining-work notes to match new sequencing. + +**Reasoning:** +Thread behavior must align with layout-controller navigation semantics first; test stabilization should run after this integration change to avoid churn. + +**Alternatives considered:** + +- Keep tests as Task 14 and fold Thread adaptation into tests task — rejected because it mixes implementation and verification scopes. +- Insert Thread adaptation later without renumbering — rejected because user explicitly requested new Task 14 and renumbered tests Task 15. + +**Tradeoffs / Consequences:** +Any automation or scripts referencing old `task-14-tests-*` key should be updated to the new `task-15-tests-*` key. + +## Decision: Route Thread close/back action through ChatView navigation API with safe legacy fallback + +**Date:** 2026-02-27 +**Context:** +Task 14 requires adapting `src/components/Thread/Thread.tsx` to layout-controller-based navigation without changing Thread UI behavior or breaking non-ChatView usage. + +**Decision:** +Update `Thread.tsx` to use `useChatViewNavigation()` and route the close handler through `closeThread()` first, then call `threadInstance.deactivate()` as a compatibility fallback. + +**Reasoning:** +`closeThread()` enables slot-aware navigation semantics (including controller back-stack behavior) when Thread is rendered inside ChatView navigation context, while the explicit `deactivate()` keeps legacy behavior intact for non-ChatView contexts. + +**Alternatives considered:** + +- Replace `deactivate()` entirely with `closeThread()` — rejected because default/no-provider navigation path can be a no-op outside ChatView. +- Keep `deactivate()` only — rejected because it bypasses new layout-controller navigation orchestration. + +**Tradeoffs / Consequences:** +In ChatView contexts, both calls run in sequence; this favors compatibility but may be simplified later once all Thread usage is guaranteed to be navigation-context backed. + +## Decision: Complete Task 15 with focused coverage across controller, navigation hook, ChatView layout, and ChannelHeader back behavior + +**Date:** 2026-02-27 +**Context:** +Task 15 requires test coverage for slot back-stack behavior, unified slot model (`channelList` slot + hide/unhide), `openView`, serialization round-trips, and the high-level navigation DX. + +**Decision:** +Add/extend tests in: + +- `src/components/ChatView/__tests__/layoutController.test.ts` for `openView` activation and serialization/restore behavior, +- `src/components/ChatView/__tests__/ChatView.test.tsx` for `minSlots` fallback rendering and mount-preserving channel-list hide/unhide behavior, +- `src/components/ChatView/__tests__/ChatViewNavigation.test.tsx` (new) for `useChatViewNavigation()` open/close flows and `channelList` hide/unhide semantics, +- `src/components/ChannelHeader/__tests__/ChannelHeader.test.js` for back-action precedence when slot history exists. + +**Reasoning:** +This keeps tests aligned with the new API split: low-level controller semantics in unit tests, high-level consumer behavior in navigation/layout integration tests, and header wiring checks in component tests. + +**Alternatives considered:** + +- Cover all new behavior only through end-to-end ChatView integration tests — rejected because failures would be less localized and harder to diagnose. +- Keep navigation behavior tests inside `ChatView.test.tsx` only — rejected to avoid overloading one suite and to keep hook DX tests explicit. + +**Tradeoffs / Consequences:** +Typecheck and ESLint passed for touched files. Local Jest execution is currently blocked in this worktree environment by missing `@babel/runtime` resolution from linked `stream-chat-js` artifacts, so full runtime regression confirmation remains pending environment fix. + +## Decision: Require Slot to derive hidden state from slot key and layout state + +**Date:** 2026-02-27 +**Context:** +A new requirement was introduced for the Slot primitive: visibility must be intrinsic to Slot behavior and not delegated to parent-provided hidden flags. + +**Decision:** +Add an explicit spec requirement that `Slot` determines hidden/visible state from its `slot` prop and ChatView layout/controller state. + +**Reasoning:** +This centralizes visibility logic, reduces orchestration coupling in `WorkspaceLayout`, and avoids drift where different parents compute slot visibility differently. + +**Alternatives considered:** + +- Keep parent-driven `hidden` prop as source of truth — rejected because it duplicates visibility logic outside Slot. +- Hybrid parent + Slot visibility rules — rejected because conflict resolution becomes ambiguous. + +**Tradeoffs / Consequences:** +`Slot` becomes slightly more state-aware, and parent layouts lose some direct control over visibility heuristics. In return, visibility behavior becomes consistent across all usages. + +## Decision: Implement Slot-owned visibility derivation by slot key + +**Date:** 2026-02-27 +**Context:** +Task 16 required `Slot` to determine hidden/visible state without parent-provided hidden props. + +**Decision:** +`Slot` now derives hidden state from ChatView layout controller state keyed by its own `slot` prop: + +- explicit slot hiding via `hiddenSlots[slot]`, and +- compatibility fallback for the channel list slot when `entityListPaneOpen` is false. + +`WorkspaceLayout` no longer passes `hidden`/`entityListHidden` visibility authority to `Slot`. + +**Reasoning:** +This centralizes visibility behavior in one primitive and prevents parent-specific visibility drift. + +**Alternatives considered:** + +- Keep `hidden` prop as required parent input — rejected because it duplicates logic and violates Task 16. +- Use only `hiddenSlots` and ignore `entityListPaneOpen` — rejected for now to preserve compatibility with existing list-pane controls. + +**Tradeoffs / Consequences:** +`Slot` is now context-aware. Targeted typecheck passes; targeted Jest run is currently blocked in this environment by missing `@babel/runtime/helpers/interopRequireDefault` from linked `stream-chat-js` dist artifacts. + +## Decision: Make LayoutController slot-only and remove ChatView entity-list slot concept + +**Date:** 2026-02-27 +**Context:** +Task 17 required removing entity semantics from low-level layout control while keeping high-level domain actions in `ChatViewNavigationContext`. A follow-up clarification required that ChatView should not have a dedicated `entityListSlot` concept. + +**Decision:** +Refactor `LayoutController` state/contracts to generic slot bindings (`payload`) and drop entity-specific controller methods. Keep `openChannel`/`openThread`/related domain methods only in `ChatViewNavigationContext`, where domain entities are mapped to generic slot bindings. Remove dedicated `entityListSlot` handling from `WorkspaceLayout`/`ChatView`; all slots are rendered through a single slot list. + +**Reasoning:** +This preserves a strict separation of concerns: low-level controller manages slot primitives only, while ChatView/navigation own product-domain semantics. + +**Alternatives considered:** + +- Keep entity-specific methods on `LayoutController` for convenience — rejected because it violates slot-only controller requirements. +- Keep `entityListSlot` as a special ChatView lane — rejected because it preserves an opinionated slot category in ChatView composition. + +**Tradeoffs / Consequences:** +Entity typing now lives in ChatView-level helpers (`createChatViewSlotBinding` / `getChatViewEntityBinding`) rather than controller types. Typecheck passes; runtime Jest verification remains blocked in this environment by missing `@babel/runtime` from linked `stream-chat-js` artifacts. + +## Decision: Plan ChannelStateContext decomposition and SDK store migration as explicit sequential tasks + +**Date:** 2026-02-28 +**Context:** +New requirements were added to decompose `ChannelStateContext` responsibilities and move multiple channel fields into dedicated reactive SDK stores, while preserving backward compatibility and keeping thread pagination state in `ThreadContext`/`Thread` state. + +**Decision:** +Update `plan.md`, `state.json`, and `spec.md` with Tasks 18-27 and explicit sequencing: + +- remove thread pagination fields from `ChannelStateContextValue`, +- create dedicated SDK stores for `members`, `read`, `watcherCount`, `watchers`, and `mutedUsers`, +- move typing ownership to `TextComposer` state, +- move `suppressAutoscroll` to `MessageList`/`VirtualizedMessageList` props, +- add a dedicated integration-compatibility task and a final regression test task. + +`members`, `read`, `watcherCount`, and `watchers` were split into separate tasks as requested, with explicit dependencies because they touch the same SDK file. + +**Reasoning:** +This preserves plan parallelism where possible while respecting make-plans same-file constraints and avoiding conflicting edits in `/src/channel_state.ts`. It also makes compatibility requirements explicit before implementation starts. + +**Alternatives considered:** + +- One large migration task spanning all fields — rejected because it violates requested granularity and makes ownership/testing unclear. +- Parallel tasks for all SDK fields in `channel_state.ts` — rejected due same-file conflict risk and make-plans guidance. + +**Tradeoffs / Consequences:** +Execution is more sequential for SDK tasks, but coordination risk is lower and progress tracking is clearer. `pinnedMessages` stays explicitly out of scope for this iteration. + +## Decision: Plan reactive migration for `channelConfig` and `channelCapabilities` via SDK stores + +**Date:** 2026-02-28 +**Context:** +`channelConfig` and `channelCapabilities` are still sourced through `ChannelStateContext` with non-reactive upstream assumptions (`client.config` and `channel.data.own_capabilities`). + +**Decision:** +Add Tasks 28-31 to plan/spec/state to migrate these values through explicit reactive SDK stores and React subscriptions: + +- Task 28: convert `StreamClient.config` in SDK client to `StateStore` with backward-compatible property access. +- Task 29: convert `channel.data.own_capabilities` in SDK channel state to reactive store with compatibility bridge. +- Task 30: subscribe React SDK (`src/`) context derivation and consumers to these stores. +- Task 31: add compatibility/regression tests. + +**Reasoning:** +This aligns config/capability sourcing with the broader reactive-state migration and removes stale-value risk in capability/config-gated UI logic. + +**Alternatives considered:** + +- Keep deriving from plain properties and refresh via existing events only — rejected due inconsistent reactivity and harder correctness guarantees. +- Migrate React consumers first without SDK store changes — rejected because upstream source would remain non-reactive. + +**Tradeoffs / Consequences:** +Adds SDK work in two same-file hotspots (`client.ts`, `channel_state.ts`) requiring explicit task chaining; however it preserves backward compatibility while enabling reactive subscriptions in React SDK. + +## Decision: Complete Task 18 by removing thread pagination/message fields from channel state contexts and shifting consumers to Thread instance state + +**Date:** 2026-02-28 +**Context:** +Task 18 required removing thread pagination/message fields from `ChannelState` / `ChannelStateContextValue` and keeping thread pagination source-of-truth in `ThreadContext` + `Thread.state`. + +**Decision:** +Implement Task 18 with these constraints: + +- remove thread pagination/message fields from `ChannelStateContextValue`, +- remove thread pagination/message fields and actions from Channel reducer state, +- remove thread pagination controls from `ChannelActionContext` (`closeThread`, `loadMoreThread`), +- migrate thread-aware consumers to `Thread` instance state selectors (`ThreadStart`, `TypingIndicator`, `ScrollToLatestMessageButton`), +- simplify `Window` by removing thread-driven class toggling. + +**Reasoning:** +This enforces a single source-of-truth for thread state (`Thread.state`) and prevents thread pagination ownership split between Channel reducer/context and Thread instance. + +**Alternatives considered:** + +- Keep thread pagination fields in Channel reducer as internal-only state — rejected to avoid maintaining duplicate thread state ownership. +- Keep compatibility wrappers for removed `ChannelActionContext` thread actions — rejected because close/open semantics are now owned by ChatView navigation and thread instance behavior. + +**Tradeoffs / Consequences:** +Typecheck passes after migration. Local Jest runtime verification remains partially blocked in this environment by missing `@babel/runtime` from the linked `stream-chat-js` dist artifacts; `Window` test passes, and thread-related JS test syntax was corrected. + +## Decision: Implement Task 19 using wrapped `members` StateStore shape with compatibility accessor + +**Date:** 2026-02-28 +**Context:** +Task 19 requires a dedicated reactive store for SDK `ChannelState.members` while preserving existing `channel.state.members` access semantics. + +**Decision:** +In `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`: + +- add `membersStore: StateStore<{ members: Record }>` as the dedicated members store, +- keep compatibility by exposing `members` as a getter/setter that maps to `membersStore` (`getLatestValue().members` / `next({ members })`). + +Add targeted tests in `test/unit/channel_state.test.js` to verify: + +- default initialization (`members` + `membersStore`), +- setter compatibility and synchronization with the wrapped store value. + +**Reasoning:** +Using the wrapped shape keeps store structure aligned with project `StateStore` usage expectations while preserving existing property-based API access. + +**Alternatives considered:** + +- Store `members` directly as `StateStore>` — rejected after clarification that store values must use object-property shape. +- Replace `members` property with a differently named API — rejected due backward compatibility requirement. + +**Tradeoffs / Consequences:** +Direct nested mutations on `channel.state.members` remain backward compatible but may not emit store updates until follow-up compatibility integration tasks. Typecheck and targeted `channel_state` unit tests pass for this task. + +## Decision: Implement Task 20 with dedicated wrapped `read` StateStore and compatibility accessor + +**Date:** 2026-02-28 +**Context:** +Task 20 requires introducing a dedicated reactive store for `ChannelState.read` while preserving existing property-based access (`channel.state.read`). + +**Decision:** +In `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`: + +- add `readStore: StateStore<{ read: ChannelReadStatus }>` as a dedicated store instance, +- keep API compatibility through `read` getter/setter that map to `readStore` (`getLatestValue().read` / `next({ read })`). + +Add focused tests in `test/unit/channel_state.test.js` to verify: + +- default `read` store initialization, +- getter/setter compatibility and store synchronization. + +**Reasoning:** +This mirrors Task 19’s wrapped store convention and satisfies the requirement that each migrated field uses `StateStore` object-value shape while preserving current consumer call sites. + +**Alternatives considered:** + +- Keep raw `read` field and defer store migration to Task 26 — rejected because Task 20 explicitly requires the dedicated store now. +- Introduce a renamed API and deprecate `read` property immediately — rejected due backward-compatibility requirement. + +**Tradeoffs / Consequences:** +Nested in-place mutations (e.g., `channel.state.read[userId] = ...`) remain operational but may not emit store-level updates until the planned compatibility integration layer is implemented. Typecheck and targeted `channel_state` tests pass. + +## Decision: Implement Task 21 with dedicated wrapped `watcherCount` StateStore and `watcher_count` compatibility access + +**Date:** 2026-02-28 +**Context:** +Task 21 requires reactive store-backed watcher count while preserving backward-compatible access paths for existing `channel.state.watcher_count` consumers. + +**Decision:** +In `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`: + +- add `watcherCountStore: StateStore<{ watcher_count: number }>` initialized with `{ watcher_count: 0 }`, +- replace direct `watcher_count` field storage with getter/setter that map to `watcherCountStore`. + +Add focused tests in `test/unit/channel_state.test.js` to verify: + +- default watcher count store initialization, +- compatibility getter/setter behavior and store synchronization. + +**Reasoning:** +This follows the wrapped-object `StateStore` convention established for Task 19/20 and keeps all current call sites that read/write `channel.state.watcher_count` unchanged. + +**Alternatives considered:** + +- Rename the public field to `watcherCount` immediately — rejected due compatibility risk. +- Defer store migration until `watchers` migration task — rejected because Task 21 explicitly scopes watcher count store first. + +**Tradeoffs / Consequences:** +Task 21 currently uses a dedicated `watcherCountStore`; Task 22 will align `watchers` with watcher-count store-family requirements and may consolidate store shape. Typecheck and targeted `channel_state` tests pass. + +## Decision: Implement Task 22 by consolidating `watchers` and `watcher_count` into shared watcher store + +**Date:** 2026-02-28 +**Context:** +Task 22 requires `watchers` reactive storage in the same store family as `watcherCount`, while preserving compatibility for existing `channel.state.watchers` and `channel.state.watcher_count` access paths. + +**Decision:** +Refactor `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts` to use: + +- `watcherStore: StateStore<{ watcher_count: number; watchers: Record }>` + +with compatibility accessors: + +- `get/set watchers` mapped through `watcherStore.partialNext({ watchers })`, +- `get/set watcher_count` mapped through `watcherStore.partialNext({ watcher_count })`. + +This replaces the Task 21 standalone `watcherCountStore` so both values are synchronized in one reactive store payload. + +**Reasoning:** +Using one shared store enforces co-location of watcher list + watcher count state and avoids accidental field resets when either side updates. + +**Alternatives considered:** + +- Keep separate stores for `watchers` and `watcher_count` — rejected because Task 22 explicitly requires same store family and synchronization. +- Keep legacy plain `watchers` field and store only `watcher_count` — rejected as it would not satisfy Task 22 acceptance criteria. + +**Tradeoffs / Consequences:** +In-place nested mutation patterns (e.g., `channel.state.watchers[userId] = user`) remain behavior-compatible but do not emit store updates by themselves; this is expected and will be addressed by later compatibility-integration tasks. Typecheck and targeted `channel_state` tests pass. + +## Decision: Implement Task 23 with dedicated wrapped `mutedUsers` StateStore and compatibility access + +**Date:** 2026-02-28 +**Context:** +Task 23 requires migrating `ChannelState.mutedUsers` to dedicated reactive store infrastructure while preserving existing property-level API compatibility. + +**Decision:** +In `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`: + +- add `mutedUsersStore: StateStore<{ mutedUsers: Array }>` initialized as `{ mutedUsers: [] }`, +- replace direct `mutedUsers` field storage with compatibility getter/setter mapped to the store (`getLatestValue().mutedUsers` / `next({ mutedUsers })`). + +Add focused tests in `test/unit/channel_state.test.js` for: + +- default muted users store initialization, +- backward-compatible getter/setter behavior and store synchronization. + +**Reasoning:** +This keeps consistency with previous state migrations (`members`, `read`, watcher store family) and introduces reactive store infrastructure without breaking existing read/write call sites. + +**Alternatives considered:** + +- Keep `mutedUsers` as a plain field until Task 26 — rejected because Task 23 explicitly scopes this migration now. +- Merge `mutedUsers` into watcher store — rejected as unrelated state domain and unnecessary coupling. + +**Tradeoffs / Consequences:** +Like other migrated fields, nested in-place array mutation patterns can bypass store emissions unless reassigned through the setter. Typecheck and targeted `channel_state` tests pass. + +## Decision: Implement Task 24 with TextComposer typing reactive path plus mirrored ChannelState typing store for compatibility + +**Date:** 2026-02-28 +**Context:** +Task 24 requires moving typing reactive ownership to `TextComposer` state while keeping existing React typing consumption and preserving compatibility with `channel.state.typing`. + +**Decision:** +Implement a dual-path compatibility model: + +- `TextComposerState` now includes a documented `typing` map (`user.id -> latest typing event`) in `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/messageComposer/middleware/textComposer/types.ts`. +- `TextComposer` exposes `typing` getter/setter and helpers (`setTypingEvent`, `removeTypingEvent`) in `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/messageComposer/textComposer.ts`. +- `ChannelState` now keeps a dedicated wrapped `typingStore: StateStore<{ typing: Record }>` for backward compatibility and mirrors updates into `TextComposer` typing in `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`. +- Channel event handling writes typing updates through `ChannelState` helpers (`setTypingEvent`/`removeTypingEvent`) in `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel.ts`. +- React `useCreateTypingContext` subscribes to `channel.messageComposer.textComposer.state` typing updates (with fallback support) and Channel reducer-owned typing state was removed from `src/components/Channel/channelState.ts` and `src/components/Channel/Channel.tsx`. + +**Reasoning:** +This moves live reactive typing updates to TextComposer while retaining `ChannelState` compatibility surface needed by existing server/client code paths and incremental migration. + +**Alternatives considered:** + +- Fully remove typing from `ChannelState` now — rejected due backward-compatibility concerns. +- Keep typing only on `ChannelState` and defer TextComposer migration — rejected because Task 24 explicitly requires TextComposer reactive ownership. + +**Tradeoffs / Consequences:** +The mirrored compatibility store introduces temporary duplication by design; Task 26 remains responsible for finalizing cross-layer compatibility contracts. Verification: JS SDK typecheck passed, targeted `channel_state` tests passed, and React typecheck passed; targeted React Jest suites were blocked in this environment due missing `stream-chat` module resolution for that checkout. + +## Decision: Implement Task 25 by removing autoscroll suppression fields from ChannelStateContext and using MessageList props only + +**Date:** 2026-02-28 +**Context:** +Task 25 requires removing `suppressAutoscroll` from `ChannelStateContext` and making autoscroll suppression an explicit `MessageList`/`VirtualizedMessageList` prop concern. + +**Decision:** +Apply the removal and prop-only flow: + +- remove `suppressAutoscroll` and `threadSuppressAutoscroll` from React `ChannelState` / `ChannelStateContextValue` (`src/context/ChannelStateContext.tsx`), +- stop passing `suppressAutoscroll` through `useCreateChannelStateContext` (`src/components/Channel/hooks/useCreateChannelStateContext.ts`), +- add explicit `suppressAutoscroll?: boolean` prop on `MessageListProps` and keep suppression behavior in list components via prop/defaulting logic (`src/components/MessageList/MessageList.tsx`, `src/components/MessageList/VirtualizedMessageList.tsx`), +- remove `threadSuppressAutoscroll` reducer state and Thread component forwarding from channel context (`src/components/Channel/channelState.ts`, `src/components/Thread/Thread.tsx`). + +**Reasoning:** +This enforces the intended API boundary: autoscroll suppression is configured by list props, not carried in channel-state context. + +**Alternatives considered:** + +- Keep `threadSuppressAutoscroll` in context while removing only `suppressAutoscroll` — rejected per requirement to rely on explicit list props only. +- Keep hidden fallback on ChannelStateContext for temporary compatibility — rejected to avoid reintroducing removed context fields. + +**Tradeoffs / Consequences:** +Consumers relying on removed context fields must migrate to explicit list props. React typecheck passes; targeted MessageList Jest suites are blocked in this environment due missing `stream-chat` module resolution in this checkout. + +## Decision: Add Tasks 28-31 for reactive `channelConfig`/`channelCapabilities` migration + +**Date:** 2026-02-28 +**Context:** +After Task 27, `channelConfig` and `channelCapabilities` were still sourced through non-reactive paths consumed by `useChannelStateContext`. New requirements mandate reactive sources in JS SDK and React subscriptions while preserving compatibility for existing public access patterns. + +**Decision:** +Extend plan/spec/state with a four-task sequence: + +- **Task 28:** migrate `StreamClient.config` to a dedicated `StateStore` in JS SDK (`client.ts`) with backward-compatible property access. +- **Task 29:** migrate `channel.data.own_capabilities` to a dedicated reactive store in JS SDK (`channel_state.ts`) with compatibility bridge. +- **Task 30:** wire React SDK subscriptions so `channelConfig` and `channelCapabilities` consumed via `useChannelStateContext` are derived from reactive stores. +- **Task 31:** add SDK + React compatibility/regression tests for reactive updates and legacy access behavior. + +**Reasoning:** +This preserves layering and minimizes risk: + +1. establish stable reactive producers in JS SDK first, +2. migrate React consumers to subscribe to those producers, +3. lock behavior with compatibility/regression tests. + +**Alternatives considered:** + +- Migrate React first with temporary local reactivity adapters — rejected as it duplicates state logic and risks divergence from JS SDK source-of-truth. +- Remove compatibility shims immediately — rejected due explicit backward-compatibility requirement. + +**Tradeoffs / Consequences:** +The plan introduces temporary dual-path maintenance (legacy access + reactive internals), but this is intentional to avoid breaking existing integrations while enabling live updates for config/capability-dependent UI. + +## Decision: `channelConfig` and `channelCapabilities` must be removed from `ChannelStateContextValue` + +**Date:** 2026-02-28 +**Context:** +Follow-up requirement clarifies that these values should not remain exposed via `useChannelStateContext` during the reactive migration. + +**Decision:** +Refine Tasks 30-31 and spec wording so migration outcome is: + +- `channelConfig` removed from `ChannelStateContextValue`, +- `channelCapabilities` removed from `ChannelStateContextValue`, +- consumers subscribe through dedicated reactive hooks/selectors backed by SDK state stores. + +**Reasoning:** +This keeps `ChannelStateContext` lean and avoids reintroducing indirect, non-domain context coupling while still preserving component-level behavior. + +**Supersedes:** +Task 30 wording in the prior decision that kept these values under `useChannelStateContext` compatibility. + +## Decision: Implement Task 28 with `configsStore` (`StateStore<{ configs: Configs }>`), preserving only `configs` accessor compatibility + +**Date:** 2026-03-01 +**Context:** +Task 28 required migrating Stream client config state to reactive store infrastructure. During implementation review, we clarified that `StreamChat` should not introduce a new `config` accessor because it did not exist previously. + +**Decision:** +In `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/client.ts`: + +- add `configsStore: StateStore<{ configs: Configs }>` as the reactive source of truth, +- keep compatibility via `get configs()` / `set configs(...)` backed by `configsStore`, +- update `_addChannelConfig(...)` to write immutably through the `configs` setter so updates flow through the store, +- do not add `config` getter/setter. + +Add unit coverage in `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/client.test.js` to verify: + +- `configsStore` initialization, +- `configs` getter/setter synchronization, +- `_addChannelConfig(...)` updates when cache is enabled, +- no updates when cache is disabled. + +**Reasoning:** +This keeps the reactive migration aligned with existing public API shape (`configs`) and avoids introducing a new surface that downstream SDKs might incorrectly depend on. + +**Alternatives considered:** + +- Introduce `config` getter/setter alias alongside `configs` — rejected after clarification that no `config` property should be introduced. +- Keep direct mutable writes (`this.configs[cid] = ...`) — rejected because it bypasses guaranteed store-driven updates. + +**Tradeoffs / Consequences:** +External direct deep mutation of the object returned by `configs` can still bypass explicit setter invocation; internal SDK writes now consistently go through the reactive store path. + +## Decision: Implement Task 29 with `ownCapabilitiesStore` bridge on `channel.data` + +**Date:** 2026-03-01 +**Context:** +Task 29 required migrating `channel.data.own_capabilities` to a reactive source while preserving backward-compatible access patterns used by existing SDK paths (`channel.data = ...` and `channel.data.own_capabilities = ...`). + +**Decision:** +In `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`: + +- add `ownCapabilitiesStore: StateStore<{ own_capabilities: string[] }>` as a dedicated reactive source, +- install a `ChannelState` bridge that wraps `channel.data` through accessor interception, +- re-apply an accessor bridge for `data.own_capabilities` each time `channel.data` is reassigned, +- keep compatibility for direct `channel.data.own_capabilities` assignments while syncing the store. + +Add unit coverage in `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/channel_state.test.js` to verify: + +- store initialization from initial `channel.data.own_capabilities`, +- synchronization on `channel.data` replacement, +- synchronization on direct `channel.data.own_capabilities` assignment. + +Also make `typing` getter/setter null-safe for `new ChannelState()` test paths (`this._channel?.messageComposer`) to preserve existing test behavior. + +**Reasoning:** +This approach keeps `ChannelState` as the authoritative reactive producer while preserving legacy `channel.data` read/write behavior without introducing breaking API changes. + +**Alternatives considered:** + +- Move bridge logic into `Channel` class — rejected for this phase to keep Task 29 scoped to `channel_state.ts`. +- Rely only on `channel.data` reassignment interception — rejected because existing code and tests also mutate `channel.data.own_capabilities` directly. + +**Tradeoffs / Consequences:** +In-place mutation of the capabilities array itself (for example `push`) is not separately intercepted unless a new array is assigned; this matches existing mutable behavior and keeps the migration non-breaking. + +## Decision: Do not override `Channel.data` property for Task 29 capability bridge + +**Date:** 2026-03-01 +**Context:** +Initial Task 29 implementation intercepted `channel.data` via `Object.defineProperty` to auto-sync `own_capabilities`. This was broader than necessary and risked side effects on the full data property behavior. + +**Decision:** +Revise Task 29 implementation to avoid redefining `channel.data` entirely: + +- keep `ownCapabilitiesStore` in `ChannelState`, +- expose `syncOwnCapabilitiesFromChannelData(...)` in `ChannelState` to bridge only the `own_capabilities` field on the current data object, +- call `state.syncOwnCapabilitiesFromChannelData(this.data)` after internal SDK assignments to `this.data` / `channel.data` in `channel.ts` (query/watch/update/event flows), +- keep direct `channel.data.own_capabilities = ...` backward-compatible by defining accessor on the current data object only. + +**Reasoning:** +This keeps the migration focused on capabilities reactivity, preserves existing `channel.data` property semantics, and still ensures updates from SDK query/watch/events propagate through the reactive store. + +**Tradeoffs / Consequences:** +External direct replacement of `channel.data` by consumers does not auto-sync unless `syncOwnCapabilitiesFromChannelData(...)` is invoked; SDK-managed data update paths now invoke it explicitly. + +## Decision: Implement Task 30 by removing config/capabilities from ChannelStateContext and subscribing directly in consumers + +**Date:** 2026-03-01 +**Context:** +Task 30 required eliminating `channelConfig` and `channelCapabilities` from `ChannelStateContextValue` and migrating selected React consumers to reactive SDK stores. + +**Decision:** +Implement the migration in React SDK by: + +- removing `channelConfig` and `channelCapabilities` from `ChannelStateContextValue` and from `useCreateChannelStateContext`, +- adding `useChannelConfig({ cid })` in `/Users/martincupela/Projects/stream/chat/stream-chat-react-worktrees/chatview-layout-controller/src/components/Channel/hooks/useChannelConfig.ts` backed by `client.configsStore` via `useStateStore`, +- updating `Channel.tsx` and selected consumers (`useUserRole`, `useBaseMessageActionSetFilter`, `AttachmentSelector`, `PollActions`, `PollOptionSelector`) to subscribe directly to: + - `channel.state.ownCapabilitiesStore` for capabilities, + - `client.configsStore` (through `useChannelConfig`) for channel config. + +Capabilities remain arrays and are consumed via `includes(...)` checks; no array-to-object conversion is used. + +**Reasoning:** +This keeps context lean and makes config/capabilities reactive at the component that needs them, matching Task 30 goals and avoiding stale snapshot behavior from context propagation. + +**Tradeoffs / Consequences:** +Some components now hold small local selector declarations for `useStateStore`; Task 31 test updates should lock this behavior and guard against regressions. + +## Decision: Move attachment media/giphy config ownership from Channel to Attachment + +**Date:** 2026-03-02 +**Context:** +Current behavior routes attachment-specific rendering controls (`giphyVersion`, `imageAttachmentSizeHandler`, `shouldGenerateVideoThumbnail`, `videoAttachmentSizeHandler`) through `ChannelProps` and `ChannelStateContextValue`, even though runtime consumers are in the attachment rendering subtree. + +**Decision:** +Adopt attachment-scoped ownership: + +- remove these four values from `ChannelProps`, +- remove them from `ChannelStateContextValue` and channel-state context creation, +- expose them on `AttachmentProps`, +- propagate them inside attachment tree only (attachment-local context/provider or equivalent). + +**Reasoning:** +These values are attachment rendering concerns, not channel-state concerns. Moving them to attachment scope reduces channel context surface area, improves API clarity, and aligns with `WithComponents` overrides where integrators provide custom `Attachment` implementations. + +**Alternatives considered:** + +- Keep existing Channel-level props/context fields for backward compatibility — rejected because it preserves misplaced ownership and context coupling. +- Keep values in Channel props but mirror into Attachment props — rejected because it keeps dual ownership and ambiguous source-of-truth. + +**Tradeoffs / Consequences:** +This is a deliberate API break for `ChannelProps` and `ChannelStateContextValue` consumers. Regression coverage is required to ensure attachment behavior remains unchanged after the ownership move. + +**Plan impact:** +Add Task 32 (Attachment surface + propagation), Task 33 (Channel/context removal), and Task 34 (regression coverage). + +## Decision: Complete remaining React-side Task 31 migration gap in Poll tests + +**Date:** 2026-03-02 +**Context:** +Task 31 requires React tests to validate config/capability gating through reactive stores/hooks rather than `ChannelStateContext`. Most scoped tests were already migrated, but `src/components/Poll/__tests__/Poll.test.js` still seeded `channelCapabilities` directly through `ChannelStateContext`. + +**Decision:** +Update `Poll.test.js` test setup to create a real channel with `channel.data.own_capabilities` and inject only `channel` (plus non-capability context fields) into `ChannelStateProvider`. Capability toggles in tests are now translated into `own_capabilities` on the channel fixture. + +**Reasoning:** +This aligns Poll test setup with Task 30/31 architecture where capabilities are consumed via reactive channel stores (`useChannelCapabilities`) and not via deprecated context fields. + +**Tradeoffs / Consequences:** +Targeted Jest verification in this workspace is currently blocked by a local dependency linkage issue (`Cannot find module '@babel/runtime/helpers/interopRequireDefault'` from linked `stream-chat-js` build). Task 31 remains in progress pending full suite verification. + +## Decision: Stabilize Poll Task 31 tests with local provider/component test harness overrides + +**Date:** 2026-03-02 +**Context:** +After migrating Poll tests away from `ChannelStateContext` capability fields, targeted test execution exposed harness-level failures unrelated to Task 31 assertions: missing `DialogManagerProvider` in Poll test wrappers and a workspace-wide `React is not defined` runtime error in default avatar/icon render paths. + +**Decision:** +For Poll test suites only (`Poll.test.js`, `PollActions.test.js`, `PollOptionList.test.js`): + +- wrap rendered trees with `DialogManagerProvider` where poll actions mount modal/dialog hooks, +- provide a lightweight `AvatarStack` override through `ComponentProvider` to avoid dependence on the broken default avatar runtime path. + +**Reasoning:** +These harness adjustments keep Task 31 capability/config regression assertions focused and executable without broadening scope into unrelated runtime regressions in shared UI components. + +**Tradeoffs / Consequences:** +Poll Task 31 test subset now passes locally. Broader Task 31 path execution still fails in this dirty workspace due unrelated preexisting regressions (notably `React is not defined` in shared components and non-Task-31 behavior changes), so Task 31 remains `in_progress` pending clean-tree verification. + +## Decision: Close Task 31 as implemented and ignore unrelated failing suites per user instruction + +**Date:** 2026-03-02 +**Context:** +After implementing Task 31 test migration updates, broader path test execution in this dirty worktree still reports failures unrelated to Task 31 scope. The user explicitly requested to proceed with task implementation and ignore those failures. + +**Decision:** +Mark Task 31 as done in plan/state based on implemented scope and targeted migration coverage, without requiring clean execution of unrelated failing suites in this workspace. + +**Reasoning:** +This follows direct user instruction and keeps Ralph status aligned with delivered Task 31 changes. + +**Tradeoffs / Consequences:** +Unrelated suite failures remain in the worktree and should be handled separately from Task 31 completion. + +## Decision: Implement Task 32 with attachment-local media config context and Channel fallback + +**Date:** 2026-03-02 +**Context:** +Task 32 requires moving `giphyVersion`, `imageAttachmentSizeHandler`, `shouldGenerateVideoThumbnail`, and `videoAttachmentSizeHandler` to attachment scope and removing attachment descendants' direct dependence on `ChannelStateContext` for these values. + +**Decision:** +Add `AttachmentContext` in `src/components/Attachment/AttachmentContext.tsx` and provide it at the `Attachment` root. Extend `AttachmentProps` with the four values, resolve each value by priority `AttachmentProps -> ChannelStateContext fallback -> SDK defaults`, and switch `AttachmentContainer` (image sizing), `Giphy`, `LinkPreview/Card`, and `VideoAttachment` to consume `useAttachmentContext()`. + +**Reasoning:** +This satisfies attachment-scoped ownership now while preserving backward-compatible behavior for existing call sites that still pass values through Channel wiring. + +**Tradeoffs / Consequences:** +`Attachment` temporarily still reads Channel state for fallback compatibility; Task 33 can safely remove Channel ownership paths after this attachment-level surface is established. + +## Decision: Complete Task 33 by removing Channel ownership and ChannelStateContext plumbing for attachment media config + +**Date:** 2026-03-02 +**Context:** +Task 33 requires removing `giphyVersion`, `imageAttachmentSizeHandler`, `shouldGenerateVideoThumbnail`, and `videoAttachmentSizeHandler` from `ChannelProps`, `ChannelStateContextValue`, and channel context creation. + +**Decision:** +Remove these fields from: + +- `ChannelProps` and `channelStateContextValue` creation in `Channel.tsx`, +- `ChannelStateContextValue` type in `ChannelStateContext.tsx`, +- `useCreateChannelStateContext` plumbing in `useCreateChannelStateContext.ts`. + +Additionally, remove `Attachment`'s fallback reads from `useChannelStateContext` so attachment config ownership is fully attachment-scoped. + +**Reasoning:** +Without removing the `Attachment` fallback, Channel would remain an implicit owner at runtime. Eliminating this fallback completes the ownership move started in Task 32. + +**Tradeoffs / Consequences:** +Integrations that previously configured these values via `Channel` props must now configure them through `Attachment` props/custom attachment components. + +## Decision: Complete Task 34 with focused regression tests for attachment-scoped config and Channel context removal + +**Date:** 2026-03-02 +**Context:** +Task 34 requires regression/compatibility coverage proving attachment media config behavior is preserved under the new attachment-scoped ownership and that Channel context no longer exposes removed fields. + +**Decision:** +Add focused tests: + +- new `src/components/Attachment/__tests__/AttachmentScopedConfig.test.js` covering: + - `giphyVersion` propagation through attachment scope, + - `imageAttachmentSizeHandler` usage from `Attachment` props without Channel context dependency, + - `shouldGenerateVideoThumbnail` + `videoAttachmentSizeHandler` behavior from `Attachment` props. +- update `src/components/Channel/__tests__/Channel.test.js` with a regression assertion that ChannelStateContext does not expose `giphyVersion`, `imageAttachmentSizeHandler`, `shouldGenerateVideoThumbnail`, or `videoAttachmentSizeHandler`. + +**Reasoning:** +These tests directly verify Task 34 acceptance criteria while avoiding unrelated broad-suite instability in the dirty worktree. + +**Tradeoffs / Consequences:** +Coverage is intentionally focused rather than full-suite broad execution. Targeted tests pass and validate the migrated ownership model. + +## Decision: Keep receipt reconciliation internal and move receipt-map emission into MessageReceiptsTracker + +**Date:** 2026-03-02 +**Context:** +Follow-up planning for read/delivery reactivity identified two design choices: + +1. add new `ChannelState` helper methods (for example `updateRead`/`setReadForUser`) vs using `readStore` directly in internals, and +2. recompute read/delivery receipt maps in React hooks vs emitting reactive receipt data from the SDK tracker. + +**Decision:** +For tasks 35-39: + +- do not add new public `ChannelState` APIs for read updates, +- patch read state directly via `channel.state.readStore.next((current) => ...)` in SDK internals, +- extend `MessageReceiptsTracker` to expose a reactive UI-facing receipt signal/snapshot, +- make React hooks consume tracker-emitted reactive data rather than owning receipt-map recomputation. + +**Reasoning:** +This minimizes public API surface changes, keeps receipt computation centralized in the SDK, avoids duplicated logic across React hooks/components, and makes reactivity deterministic for `message.read`, `notification.mark_unread`, and `message.delivered`. + +**Alternatives considered:** + +- Introduce new public `ChannelState` methods for per-user read patching — rejected to avoid premature API growth. +- Keep hook-level recomputation and event subscriptions as the primary source — rejected because it duplicates tracker logic and risks drift between UI surfaces. + +**Tradeoffs / Consequences:** +Tracker internals gain additional reactive responsibilities and test burden, but React receipt hooks become thinner selectors with less local event bookkeeping and fewer implicit rerender dependencies. + +## Decision: Complete Task 35 with immutable readStore patching and merged initialization updates + +**Date:** 2026-03-02 +**Context:** +Task 35 required eliminating in-place receipt map mutations (`state.read[userId] = ...` / nested unread increments) in favor of immutable canonical updates through `channel.state.readStore`, while preserving `channel.state.read` compatibility and avoiding new `ChannelState` public APIs. + +**Decision:** +Implement internal `Channel` helpers to patch and merge read state through `readStore.next((current) => ...)`, then migrate receipt mutation paths in `channel.ts`: + +- `message.read`, `message.delivered`, and `notification.mark_unread` now upsert user read state via immutable `readStore` patches, +- `message.new` unread/read receipt adjustments now rebuild affected user entries immutably, +- `_initializeState` now accumulates bootstrap/query read entries and applies one merge patch (`_mergeReadStates`) instead of in-place field writes. + +Also add focused tests in `test/unit/channel.test.js` for: + +- single-user `message.read` triggering `readStore` subscriptions, +- `_initializeState` read merge behavior preserving existing users while applying incoming read entries. + +**Reasoning:** +This keeps receipt ownership canonical at `readStore`, preserves backward compatibility through `ChannelState.read` accessors, and establishes immutable update semantics needed for subsequent Tasks 36-39. + +**Tradeoffs / Consequences:** +`Channel` now owns small internal read patch helpers, which adds minor internal complexity, but prevents silent mutable updates and keeps the public API unchanged. + +## Decision: Complete Task 36 with unified receipt reconciliation helpers driven by canonical readStore + +**Date:** 2026-03-02 +**Context:** +Task 36 required routing `message.read`, `message.delivered`, and `notification.mark_unread` through a single reconciliation pattern that updates canonical `readStore` first and advances `messageReceiptsTracker` as a derived projection, while keeping query/watch initialization aligned with the same semantics. + +**Decision:** +Introduce shared internal reconciliation helpers in `src/channel.ts`: + +- `_reconcileMessageRead(...)` +- `_reconcileMessageDelivered(...)` +- `_reconcileNotificationMarkUnread(...)` +- supporting helpers `_upsertReadState(...)` and `_toReadResponses(...)` + +and use them in `_handleChannelEvent` for all receipt-relevant events. + +Initialization alignment change: + +- `_initializeState` now merges read entries into canonical store first, then calls `messageReceiptsTracker.ingestInitial(...)` from canonical `this.state.read` (via `_toReadResponses`) instead of raw response payload. + +Ordering/invariant behavior: + +- canonical read updates keep receipt progression monotonic for delivery events (no backward delivery regression on out-of-order events), +- tracker calls now use canonical post-patch read state values (one-way canonical -> derived). + +**Reasoning:** +This removes divergent per-event reconciliation code paths, keeps event/query semantics aligned, and enforces the ownership contract where `readStore` is source-of-truth and tracker is derived. + +**Tradeoffs / Consequences:** +`Channel` gained additional internal helper methods, but receipt logic is now centralized and easier to extend in Task 37 without introducing parallel truth sources. + +## Decision: Complete Task 37 by adding tracker-owned reactive receipt snapshots with revisioned state + +**Date:** 2026-03-02 +**Context:** +Task 37 required exposing a tracker reactive surface for UI selectors, emitting updates only on effective receipt changes, and preserving deterministic rebuild behavior from canonical read data. + +**Decision:** +Extend `MessageReceiptsTracker` with a tracker-owned reactive store: + +- new `snapshotStore: StateStore` where snapshot contains: + - `revision` + - `readersByMessageId` + - `deliveredByMessageId` +- add internal `emitSnapshotIfChanged()` that recomputes grouped maps and increments revision only when effective grouped output changes, +- trigger snapshot emission from `ingestInitial`, `onMessageRead`, `onMessageDelivered`, and `onNotificationMarkUnread` only after effective state mutations, +- add explicit deterministic rebuild API `resyncFromReadResponses(...)` delegating to `ingestInitial(...)`. + +Also add focused tests in `test/unit/messageDelivery/MessageReceiptsTracker.test.ts` to verify: + +- reactive revision emits on effective changes and not on no-op replays, +- operation coverage for `ingestInitial`, `onMessageRead`, `onMessageDelivered`, and `onNotificationMarkUnread`. + +**Reasoning:** +This keeps reactive receipt projection inside the tracker (single derived source), minimizes unnecessary React work via no-op suppression, and gives Task 38 a stable selector-friendly surface. + +**Tradeoffs / Consequences:** +Tracker now computes grouped snapshots after effective updates (extra internal work), but removes duplicated projection logic from future UI layers. + +## Decision: Simplify Channel receipt reconciliation helper surface while keeping canonical ownership + +**Date:** 2026-03-02 +**Context:** +After Task 36 implementation, `Channel` accumulated multiple receipt-specific helpers (`_mergeReadStates`, `_toReadResponses`, `_reconcileMessageRead`, `_reconcileMessageDelivered`, `_reconcileNotificationMarkUnread`) that improved reuse but increased indirection and local complexity. + +**Decision:** +Reduce helper surface in `Channel` to core canonical update primitives: + +- keep `_patchReadState(...)` and `_upsertReadState(...)`, +- inline event-specific reconciliation logic in event handlers and initialization flow, +- preserve behavior and one-way ownership (`readStore` canonical, tracker derived). + +**Reasoning:** +This keeps reconciliation logic close to event context, improves readability, and retains the architectural guarantees introduced in Tasks 35-37. + +**Tradeoffs / Consequences:** +Some event handlers now contain more local logic, but the total mental model is smaller because fewer cross-jumping private helper methods are involved. + +## Decision: Adopt readStore-emission-driven tracker reconciliation with metadata-first delta and fallback + +**Date:** 2026-03-02 +**Context:** +Current flow still invokes tracker reconciliation methods directly from `Channel` event handlers. For larger channels, repeatedly deriving changes by scanning full read maps is avoidable overhead, and direct calls duplicate event-path coupling. + +**Decision:** +For Task 38/39 implementation direction: + +- move tracker reconciliation to a subscription-driven pipeline from canonical `channel.state.readStore` emissions, +- allow readStore emissions to include optional update metadata payload (changed/removed user ids) to avoid full key-diff on every update, +- implement deterministic fallback key-diff reconcile when metadata is absent, +- keep `readStore` as the only canonical source and tracker as derived projection. + +**Reasoning:** +This strengthens one-way ownership, reduces duplicated event-coupled reconciliation logic, and scales reconciliation cost with changed users rather than total channel participants when metadata is present. + +**Tradeoffs / Consequences:** +Requires careful lifecycle management (single subscription + teardown) and explicit compatibility handling for emitters that do not provide metadata; tests must cover metadata and fallback paths. + +## Decision: Complete Task 38 with readStore-subscription-driven tracker reconcile and snapshot-driven React hooks + +**Date:** 2026-03-02 +**Context:** +Task 38 required moving receipt consumers to tracker reactive output and reducing direct event-coupled reconciliation paths. We also agreed on performance-oriented reconciliation with metadata-first deltas and canonical fallback behavior. + +**Decision:** +Implement the following: + +- `Channel` now wires `messageReceiptsTracker.reconcileFromReadStore(...)` to `state.readStore` subscription and no longer calls tracker receipt handlers directly from `message.read`, `message.delivered`, and `notification.mark_unread` handlers. +- Add optional reconcile metadata flow (`changedUserIds` / `removedUserIds`) from channel read patches for metadata-first delta processing. +- Add deterministic fallback in tracker: when reconcile metadata is missing, rebuild from canonical readStore snapshot. +- Add `_disconnect()` teardown for the receipt reconcile subscription. +- Migrate React receipt hooks to tracker `snapshotStore` subscriptions: + - `useLastReadData` + - `useLastDeliveredData` + - `useMessageDeliveryStatus` + removing manual `channel.on('message.delivered'...)` and read/delivered event synchronization in those hooks. + +**Reasoning:** +This enforces one-way canonical ownership (`readStore -> tracker -> React`), removes duplicated event-coupled reconciliation logic, and makes hook reactivity depend on tracker-emitted state instead of ad hoc event listeners. + +**Tradeoffs / Consequences:** +Tracker reconciliation internals are more sophisticated (metadata delta + fallback path), and lifecycle correctness now depends on maintaining the readStore subscription contract and teardown behavior. + +## Decision: Queue `MembersState.memberCount` migration as Task 40 with `channel.data.member_count` compatibility bridge + +**Date:** 2026-03-03 +**Context:** +A follow-up requirement was added to make members state analogous to watcher state by introducing `memberCount` on `MembersState`, while preserving legacy integration reads/writes through `channel.data.member_count`. + +**Decision:** +Capture this as a new pending Task 40 in `plan.md` with dependency on Task 39, and extend `spec.md` to require: + +- `MembersState.memberCount` in `channel_state.ts`, +- a backward-compatible `channel.data.member_count` bridge in `channel.ts`, +- synchronization in SDK-managed channel-data assignment/replacement flows using the same lifecycle pattern already used for own capabilities sync (`syncOwnCapabilitiesFromChannelData` style). + +Update `state.json` with a pending task key for Task 40. + +**Reasoning:** +This keeps the Ralph files aligned and implementation-ready without introducing immediate code changes in the SDK worktree. The dependency on Task 39 avoids same-file overlap in `test/unit/*` when work is executed. + +**Tradeoffs / Consequences:** +The requirement is now explicitly planned but unimplemented; behavior remains unchanged until Task 40 is picked up. + +## Decision: Implement Task 40 with `MembersState.memberCount` canonical store and `channel.data.member_count` accessor bridge + +**Date:** 2026-03-03 +**Context:** +Task 40 required adding `memberCount` to `MembersState` and preserving compatibility for direct `channel.data.member_count` reads/writes, analogous to the own-capabilities bridge pattern. + +**Decision:** +Implement Task 40 in SDK worktree by: + +- extending `MembersState` with `memberCount`, +- adding `ChannelState.member_count` getter/setter and `syncMemberCountFromChannelData(...)`, +- bridging `channel.data.member_count` with an accessor that updates the canonical members store, +- wiring all SDK-managed `channel.data` replacement/update paths in `channel.ts` through `_syncStateFromChannelData(...)` (capabilities + member count), +- preserving `member_count` on `channel.updated` fallback merges and fixing member event math for `0` counts. + +Add focused tests in `test/unit/channel_state.test.js` and `test/unit/channel.test.js` covering initialization, replacement sync, direct assignment sync, and event-driven updates. + +**Reasoning:** +This keeps reactive state canonical in `ChannelState` while maintaining backward-compatible `channel.data.member_count` access semantics used by existing integrations. + +**Tradeoffs / Consequences:** +`channel.data.member_count` is now accessor-backed when synchronized, mirroring the existing own-capabilities bridge model; behavior remains semver-compatible for reads/writes while enabling reactive subscriptions. + +## Decision: Implement Task 41 by removing `messageIsUnread` context field and switching unread-separator detection to tracker APIs + +**Date:** 2026-03-03 +**Context:** +Task 41 required removing `messageIsUnread` from `MessageContextValue` and retrieving unread state through `MessageReceiptsTracker` APIs instead of ad hoc context-level derivation. + +**Decision:** +Implement Task 41 in React SDK by: + +- removing `messageIsUnread` from `MessageContextValue`/provider payload, +- extending `getIsFirstUnreadMessage(...)` with optional tracker-driven `isMessageUnread` callback, +- wiring both non-virtualized (`renderMessages.tsx`) and virtualized (`VirtualizedMessageList.tsx` + `VirtualizedMessageListComponents.tsx`) unread separator checks to `channel.messageReceiptsTracker.hasUserRead(...)`, +- adding focused tests for context field removal and tracker-driven unread utility behavior. + +**Reasoning:** +Unread projection should remain tracker-driven and derived from canonical receipt state rather than duplicated per-message context state. This keeps ownership aligned with Tasks 35-38 receipt architecture. + +**Tradeoffs / Consequences:** +Jest execution in this workspace is currently blocked by missing `@babel/runtime` in linked `stream-chat-js` dist, so test updates were added but could not be executed end-to-end locally. + +## Decision: Remove legacy `MessageProps.openThread` in favor of ChatView navigation context + +**Date:** 2026-03-03 +**Context:** +Thread opening was moved to `ChatViewNavigationContext`, but `src/components/Message/types.ts` still exposed legacy `openThread` on `MessageProps`, leaving an outdated API surface that no longer matches implementation ownership. + +**Decision:** +Add Task 42 and remove `openThread` from `MessageProps`, remove corresponding omit-plumbing in `Message.tsx`, and update spec/plan/state to document `useChatViewNavigation().openThread(...)` as the canonical thread-open path. + +**Reasoning:** +Keeping the legacy prop creates ambiguity and invites dead integrations. Removing it aligns public typing with actual navigation architecture introduced in Task 13. + +**Tradeoffs / Consequences:** +This is a type-level cleanup for a stale prop; consumers still relying on that prop will need to migrate to `useChatViewNavigation()` for thread opening. + +## Decision: Plan removal of `MessageProps.threadList` with local thread-scope inference in leaf components + +**Date:** 2026-03-03 +**Context:** +`threadList` is still carried through `MessageProps` and message context plumbing, even though thread scope can now be derived from `useThreadContext()`. This creates avoidable prop drilling and keeps message leaf components coupled to upstream forwarding. + +**Decision:** +Add Task 43 as a pending follow-up to remove `threadList` from `MessageProps` and move thread-scope branching to leaf components via `useThreadContext()` presence checks (thread instance present => thread scope). Scope includes message-level plumbing cleanup and focused behavior-preservation tests. + +**Reasoning:** +Thread scope is contextual runtime information and should be read at the point of use. Local inference removes stale prop pathways and keeps ownership aligned with current thread architecture. + +**Tradeoffs / Consequences:** +Leaf components will gain direct thread-context dependencies, but message wrappers become simpler and less coupled. Tests must explicitly cover both thread and non-thread rendering paths to guard against behavior drift. + +## Decision: Implement Task 43 by removing `threadList` prop drilling and inferring thread scope from `useThreadContext` + +**Date:** 2026-03-03 +**Context:** +Task 43 required eliminating `MessageProps.threadList` and replacing message-level thread-scope prop drilling with local inference in leaf components. + +**Decision:** +Implement Task 43 in React SDK by: + +- removing `threadList` from `MessageProps` and from `MessageContextValue`, +- removing `threadList` forwarding in `Message.tsx`, `MessageList` shared message props plumbing, and virtualized message renderer props to ``, +- updating leaf message components to infer thread scope via `useThreadContext()`: + - `MessageSimple` reply-count button visibility, + - `MessageStatus` read/delivered/sent status branching, + - `MessageAlsoSentInChannelIndicator` label/action behavior, +- applying the same local thread inference in other leaf consumers that previously depended on `threadList` from message context: + - `Attachment/Audio`, + - `Attachment/LinkPreview/CardAudio`, + - `Attachment/VoiceRecording`, + - `Reactions/ReactionSelectorWithButton`, + - `Thread/ThreadHead` (remove `threadList` prop forwarding to `Message`), +- updating message tests that previously used `threadList: true` to provide thread scope via `ThreadProvider`. + +**Reasoning:** +Thread scope is contextual runtime state and should be read where behavior diverges. This removes stale prop pathways and reduces wrapper-level coupling without changing thread-vs-channel UX semantics. + +**Tradeoffs / Consequences:** +Some leaf components now depend directly on thread context. Typecheck passes; local Jest execution remains blocked in this workspace by missing `@babel/runtime` in linked `stream-chat-js` dist artifacts. + +## Decision: Add Task 44 to remove `LegacyThreadContext` now that Thread renders outside Channel + +**Date:** 2026-03-03 +**Context:** +`LegacyThreadContext` was kept for older thread wiring assumptions, but `Thread.tsx` now renders outside `Channel.tsx`, so the legacy context layer is redundant and increases maintenance surface. + +**Decision:** +Add Task 44 as a pending follow-up to remove `LegacyThreadContext` provider/hook wiring, remove exports from the Thread module, and migrate remaining consumers to current sources (`useThreadContext`, `useChannel`, or explicit props). + +**Reasoning:** +Removing the legacy context aligns thread architecture with current rendering boundaries and reduces duplicate state paths. + +**Tradeoffs / Consequences:** +This requires a focused pass over Thread consumers and tests to preserve behavior while deleting legacy APIs. Migration must avoid introducing new prop drilling. + +## Decision: Complete remaining Task 43 list-level `threadList` removal by inferring thread scope in list/indicator components + +**Date:** 2026-03-03 +**Context:** +After initial Task 43 delivery, residual `threadList` mode props remained in `MessageList`, `VirtualizedMessageList`, `TypingIndicator`, `ScrollToLatestMessageButton`, and `Thread` wiring, with matching test fixtures still passing `threadList`. + +**Decision:** +Finish Task 43 by removing these remaining `threadList` prop paths and inferring thread scope through `useThreadContext()` in list/indicator components. Update affected tests to provide thread scope with `ThreadProvider` instead of `threadList` flags. + +**Reasoning:** +Thread-vs-main behavior should be contextual and not carried via drill props. Completing the list-level cleanup closes the remaining prop-drilling surface and aligns behavior across message and list layers. + +**Tradeoffs / Consequences:** +Tests now model thread scope through context wrappers, which is closer to production wiring but slightly more setup-heavy in fixtures. Typecheck remains green. + +## Decision: Adopt declarative slot topology (`slotNames`) and slot-claimer ownership model + +**Date:** 2026-03-05 +**Context:** +Recent integration feedback showed friction from hard-coded `slot` assumptions and ad hoc list-pane concepts. The desired DX is declarative slot topology with explicit slot claimers (`ChatView.Channels`, `ChannelSlot`, `ThreadSlot`) and policy-driven conflict resolution through controller/navigation APIs. + +**Decision:** +Introduce a declarative slot topology requirement: + +- `ChatView` accepts `slotNames` as canonical ordered topology. +- `minSlots`/`maxSlots` initialization and expansion respect configured slot names. +- `ChatView.Channels`, `ChannelSlot`, and `ThreadSlot` claim/request slots; they do not implement replacement policy. +- conflict outcomes remain controller-owned (`duplicateEntityPolicy`, resolver chain). +- no dedicated `entityListSlot` concept is introduced. + +**Reasoning:** +This keeps slot ownership explicit and predictable for custom JSX layouts, removes naming-coupled behavior from navigation internals, and preserves a single policy authority in controller/resolvers. + +**Alternatives considered:** + +- Keep hard-coded `slot${n}` expansion and rely on documentation only — rejected because behavior remains surprising with custom slot ids. +- Add separate list-pane slot prop (`entityListSlot`) — rejected because it introduces duplicate semantics instead of using generic slot claiming. + +**Tradeoffs / Consequences:** +Integrations gain clearer declarative control but implementation must ensure strict backward compatibility when `slotNames` is omitted. + +## Decision: Implement initial `slotNames` topology in ChatView and navigation expansion + +**Date:** 2026-03-05 +**Context:** +Task 45/46 began to make slot topology declarative and remove hard-coded `slot${n}` expansion assumptions. + +**Decision:** +Implement `slotNames?: string[]` on `ChatView` and initialize internal layout state with: + +- `slotNames` as ordered topology, +- `availableSlots` from the first `minSlots` names, +- `maxSlots`/`minSlots` clamped against topology length when names are provided. + +Also update `useChatViewNavigation()` expansion logic to pick the next slot from ordered topology (`slotNames` first, generated fallback otherwise) instead of constructing `slot${n}` directly. + +**Reasoning:** +This keeps slot ordering declarative and allows named slots (`list`, `main`, `thread`) while preserving backward compatibility for existing numeric slot ids. + +**Alternatives considered:** + +- Introduce a separate topology context only for navigation — rejected because topology belongs in layout state shared by all slot-aware consumers. +- Make `slotNames` mandatory — rejected to avoid breaking existing integrations. + +**Tradeoffs / Consequences:** +Current tests are updated for initialization and named-slot expansion semantics; full Jest verification remains environment-dependent in this workspace. + +## Decision: Add Task 49 for slot-equal navigation and API rename convergence + +**Date:** 2026-03-05 +**Context:** +The spec was updated with final requirements to remove implicit current-slot semantics, separate history/lifecycle/visibility concerns, add forward navigation per slot, and adopt clearer low-level API names (`setSlotBinding`, `openInLayout`). + +**Decision:** +Add Task 49 to `plan.md` and `state.json` as the implementation convergence task for these requirements. + +**Reasoning:** +This keeps Ralph artifacts synchronized and creates a single execution unit for the breaking refactor, test updates, and migration-doc alignment. + +**Alternatives considered:** + +- Split into multiple micro-tasks immediately — rejected for now to keep sequencing simple while requirements are still converging. +- Leave only spec updates without plan/state tasking — rejected because it breaks Ralph tracking discipline. + +**Tradeoffs / Consequences:** +Task 49 is broad and may later be split into implementation subtasks once execution starts, but current plan/state now reliably track this requirement set. + +## Decision: Implement slot-equal controller API with explicit history/visibility separation + +**Date:** 2026-03-05 +**Context:** +Task 49 required removing implicit current-slot behavior, adding forward navigation, and clarifying low-level controller method semantics. + +**Decision:** +Implement the following breaking layout-controller changes: + +- remove `activeSlot` from layout state and resolver/duplicate callback args, +- add per-slot `slotForwardHistory` alongside `slotHistory`, +- rename low-level methods: + - `bind` -> `setSlotBinding`, + - `open` -> `openInLayout`, + - `close` -> `goBack`, + - `setSlotHidden` -> `hide`/`unhide`, +- add `goForward(slot)`, +- keep `clear(slot)` as lifecycle reset separate from history and visibility. + +Also update ChatView navigation and slot/entity hooks to avoid implicit slot fallback and use deterministic slot targeting. + +**Reasoning:** +This enforces slot equality, reduces accidental coupling to a hidden "current pane", and makes controller intent explicit by API name. + +**Alternatives considered:** + +- Keep legacy names as aliases for a transitional period — rejected to keep the breaking contract unambiguous. +- Keep `activeSlot` only as optional hint — rejected because it preserves the same semantic overload problem. + +**Tradeoffs / Consequences:** +Tests and downstream integrations must migrate to renamed methods and explicit slot targeting. In this environment, only typecheck validation was fully runnable. diff --git a/specs/layout-controller/plan.md b/specs/layout-controller/plan.md new file mode 100644 index 0000000000..95866dc3e4 --- /dev/null +++ b/specs/layout-controller/plan.md @@ -0,0 +1,1417 @@ +# ChatView Layout Controller Implementation Plan + +## Worktree + +**Worktree path:** `../stream-chat-react-worktrees/chatview-layout-controller` +**Branch:** `feat/chatview-layout-controller` +**Base branch:** `master` +**Preview branch:** `agent/feat/chatview-layout-controller` + +All work for this plan MUST be done in the worktree directory, NOT in the main repo checkout. + +## Task overview + +Tasks are self-contained and parallelizable where files do not overlap; same-file changes are explicitly chained. + +## Spec reference + +Primary spec for this plan: + +- `src/specs/layout-controller/spec.md` + +## Task 1: Core Types and Controller Engine + +**File(s) to create/modify:** `src/components/ChatView/layoutController/LayoutController.ts`, `src/components/ChatView/layoutController/layoutControllerTypes.ts` + +**Dependencies:** None + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Define `LayoutEntityBinding`, `ChatViewLayoutState`, `ResolveTargetSlotArgs`, `OpenResult`. +- Implement `LayoutController` class with `state: StateStore`. +- Implement commands: `setActiveView`, `setMode`, `bind`, `clear`, `open`, and initial high-level helpers. +- Enforce `occupiedAt` invariant when occupying/clearing slots. +- Implement duplicate entity handling (`duplicateEntityPolicy`, `resolveDuplicateEntity`) and result semantics. + +**Acceptance Criteria:** + +- [x] Controller compiles with strict typing and no `any` leaks. +- [x] `open(...)` returns `opened` / `replaced` / `rejected` consistently. +- [x] `occupiedAt` is set on occupy and removed/reset on clear. + +## Task 2: Resolver Registry and Built-in Strategies + +**File(s) to create/modify:** `src/components/ChatView/layoutSlotResolvers.ts` + +**Dependencies:** Task 1 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add reusable resolvers: `requestedSlotResolver`, `firstFree`, `existingThreadSlotForThread`, `existingThreadSlotForChannel`, `earliestOccupied`, `activeOrLast`, `replaceActive`, `replaceLast`, `rejectWhenFull`. +- Add `composeResolvers`. +- Export `resolveTargetSlotChannelDefault` with documented chain. +- Export central `layoutSlotResolvers` object. + +**Acceptance Criteria:** + +- [x] `layoutSlotResolvers.resolveTargetSlotChannelDefault` matches spec behavior. +- [x] Resolver functions are independently testable and exported. + +## Task 3: ChatView Integration (Context and Props) + +**File(s) to create/modify:** `src/components/ChatView/ChatView.tsx`, `src/components/ChatView/index.tsx` + +**Dependencies:** Task 1, Task 2 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Integrate controller into `ChatView` provider. +- Add new props: `maxSlots`, `resolveTargetSlot`, `duplicateEntityPolicy`, `resolveDuplicateEntity`, optional `entityInferers`, optional external `layoutController`. +- Expose `layoutController` via `useChatViewContext`. +- Keep existing `activeChatView` compatibility path (alias/mapping to `activeView`) or provide migration shim. +- Wire default resolver fallback when `resolveTargetSlot` is absent. + +**Acceptance Criteria:** + +- [x] Existing ChatView usage does not break at runtime. +- [x] New props and context are typed/exported. +- [x] `str-chat__chat-view` behavior remains stable for existing layouts. + +## Task 4: Header Toggle Wiring + +**File(s) to create/modify:** `src/components/ChannelHeader/ChannelHeader.tsx` + +**Dependencies:** Task 3 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Update header toggle button behavior to call ChatView layout actions by default. +- Keep external override behavior (`onSidebarToggle`) intact. +- Ensure collapsed state derives from ChatView layout state when not controlled. + +**Acceptance Criteria:** + +- [x] Header toggle hides/shows list area via ChatView state. +- [x] Override prop still takes precedence when provided. + +## Task 5: Built-in Two-Step DX Layout API + +**File(s) to create/modify:** `src/components/ChatView/ChatView.tsx`, `src/components/ChatView/layout/WorkspaceLayout.tsx` (new) + +**Dependencies:** Task 3 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add optional built-in layout mode (`layout='nav-rail-entity-list-workspace'`). +- Add `slotRenderers` config by `kind` so integrators can avoid custom `DynamicSlotsLayout`/`SlotOutlet`. +- Preserve advanced mode (custom children layout) unchanged. + +**Acceptance Criteria:** + +- [x] Integrator can render multi-slot workspace in two steps (`ChatView` + `slotRenderers`). +- [x] Existing custom-layout usage still works. + +## Task 6: Tests for Controller, Resolvers, and Integration + +**File(s) to create/modify:** `src/components/ChatView/__tests__/layoutController.test.ts`, `src/components/ChatView/__tests__/ChatView.test.tsx`, `src/components/ChannelHeader/__tests__/ChannelHeader.test.js` + +**Dependencies:** Task 2, Task 3, Task 4, Task 5 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add unit tests for resolver chain and duplicate policies. +- Add controller tests for `open` outcomes and `occupiedAt`. +- Add integration tests for switching from threads view to channel via annotation action path. +- Add header toggle tests for list visibility state. + +**Acceptance Criteria:** + +- [x] New tests cover resolver defaults and replacement scenarios. +- [x] Tests verify thread/channel switching and list visibility toggling. +- [ ] No regression in existing ChatView/ChannelHeader tests. + +## Task 7: Docs and Spec Alignment + +**File(s) to create/modify:** `src/specs/layout-controller/spec.md`, `src/specs/layout-controller/plan.md` + +**Dependencies:** Task 5, Task 6 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Align final API names/signatures in spec with implementation details. +- Add migration notes and examples for low-level vs high-level API usage. +- Update plan status/ownership after implementation. + +**Acceptance Criteria:** + +- [x] Spec reflects implemented API exactly. +- [x] Examples compile logically against final exported types. + +## Task 8: Slot Parent Stack and Back Navigation + +**File(s) to create/modify:** `src/components/ChatView/layoutController/LayoutController.ts`, `src/components/ChatView/layoutController/layoutControllerTypes.ts`, `src/components/ChannelHeader/ChannelHeader.tsx` + +**Dependencies:** Task 3 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add per-slot parent stack (`slotHistory`) to support back navigation within a single slot. +- Add low-level controller commands for stack management (`pushParent`, `popParent`) and back-aware close behavior. +- Update header affordance logic to prefer back arrow when current slot has parents. + +**Acceptance Criteria:** + +- [x] One-slot flow `channelList -> channel -> thread` can pop back deterministically. +- [x] Header icon/action switches between back and list-toggle semantics using slot history. + +## Task 9: Unify ChannelList into Slot Model + +**File(s) to create/modify:** `src/components/ChatView/layoutController/layoutControllerTypes.ts`, `src/components/ChatView/ChatView.tsx`, `src/components/ChatView/layout/WorkspaceLayout.tsx`, `src/components/ChannelHeader/ChannelHeader.tsx` + +**Dependencies:** Task 8 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add `channelList` entity kind and treat list panes as regular slots. +- Remove dedicated `entityListPane` state/commands from ChatView layout model. +- Enable replacing `channelList` with alternative entities (e.g. search results) in the same slot. + +**Acceptance Criteria:** + +- [x] Channel list can be opened/closed/replaced via slot binding APIs. +- [x] No layout code path depends on legacy `entityListPaneOpen`. + +## Task 10: Min Slots and Fallback Workspace States + +**File(s) to create/modify:** `src/components/ChatView/ChatView.tsx`, `src/components/ChatView/layout/WorkspaceLayout.tsx`, `src/components/ChatView/layoutController/layoutControllerTypes.ts` + +**Dependencies:** Task 9 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add `minSlots` support in ChatView layout initialization and rendering. +- Add per-slot fallback rendering for unbound slots (e.g. empty channel workspace prompt). +- Keep `maxSlots` behavior for upper bound slot availability. + +**Acceptance Criteria:** + +- [x] `minSlots={2}` can render `channelList + empty workspace` before channel selection. +- [x] Fallback content disappears when slot receives entity binding and reappears when cleared. + +## Task 11: Generic Slot Component with Mount-Preserving Hide/Unhide + +**File(s) to create/modify:** `src/components/ChatView/layout/Slot.tsx` (new), `src/components/ChatView/styling/` (SCSS updates), `src/components/ChatView/layout/WorkspaceLayout.tsx` + +**Dependencies:** Task 10 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Introduce generic `Slot` component that applies hidden/visible classes at root level. +- Hide slots with CSS while keeping subtree mounted. +- Wire slot visibility state into controller (`hiddenSlots` / `setSlotHidden`). + +**Acceptance Criteria:** + +- [x] Hidden slots remain mounted (no pagination re-initialization). +- [x] Slot visibility is controllable via layout state and reflected in CSS class contract. + +## Task 12: Deep-Linking, Serialization, and openView + +**File(s) to create/modify:** `src/components/ChatView/layoutController/LayoutController.ts`, `src/components/ChatView/layoutController/layoutControllerTypes.ts`, `src/components/ChatView/layoutController/serialization.ts` (new) + +**Dependencies:** Task 10 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add `openView` command to controller/navigation flow. +- Define serializable layout snapshot format including active view, slot bindings, hidden slots, and parent stacks. +- Add restore helpers that rebind entities safely and skip unresolved keys. + +**Acceptance Criteria:** + +- [x] View-first deep links (`openView` then entity opens) are supported. +- [x] Layout snapshot round-trip preserves slot stack and visibility semantics. + +## Task 13: High-Level Navigation Hook and Context Split + +**File(s) to create/modify:** `src/components/ChatView/ChatView.tsx`, `src/components/ChatView/ChatViewNavigationContext.tsx` (new), `src/components/ChatView/index.tsx`, `src/components/ChannelHeader/ChannelHeader.tsx` + +**Dependencies:** Task 12 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Create `useChatViewNavigation()` with domain actions (`openChannel`, `closeChannel`, `openThread`, `closeThread`, `hideChannelList`, `unhideChannelList`, `openView`). +- Remove high-level domain methods from `LayoutController` API surface. +- Keep low-level `LayoutController` available for advanced/custom workflows. + +**Acceptance Criteria:** + +- [x] Consumer DX path uses `useChatViewNavigation()` without direct low-level controller usage. +- [x] Existing advanced integrations can still use low-level controller methods (`open`, `bind`, `clear`, etc.). + +## Task 14: Thread Component Layout-Controller Adaptation + +**File(s) to create/modify:** `src/components/Thread/Thread.tsx` + +**Dependencies:** Task 13 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Route `Thread.tsx` interaction handlers through `useChatViewNavigation()` (or equivalent ChatView layout API path) instead of legacy thread-only close assumptions. +- On close/back actions, use slot-aware transitions (`closeThread` + controller back-stack behavior) so one-slot mobile flow is deterministic. +- Keep existing Thread component rendering/UI behavior unchanged; adjust only action wiring and navigation interaction points. +- Preserve safe compatibility when Thread is rendered outside ChatView (no hard failure on missing layout navigation context). + +**Acceptance Criteria:** + +- [x] `Thread.tsx` uses ChatView layout-controller/navigation APIs for thread close/back transitions. +- [x] Thread close/back behavior follows slot-aware controller semantics in one-slot flow. +- [x] Thread UI rendering behavior is unchanged from current behavior. +- [x] Rendering Thread outside ChatView remains safe (no runtime crash/regression). + +## Task 15: Tests for Slot Stack, Unified Slots, and Navigation DX + +**File(s) to create/modify:** `src/components/ChatView/__tests__/layoutController.test.ts`, `src/components/ChatView/__tests__/ChatView.test.tsx`, `src/components/ChannelHeader/__tests__/ChannelHeader.test.js`, `src/components/ChatView/__tests__/ChatViewNavigation.test.tsx` (new) + +**Dependencies:** Task 8, Task 9, Task 10, Task 11, Task 12, Task 13, Task 14 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add tests for per-slot back stack behavior and header icon switching. +- Add tests for `channelList` as slot, min-slot fallback rendering, and mount-preserving hide/unhide. +- Add tests for `openView` and serialization restore flows. +- Add tests for high-level navigation hook behavior and compatibility. + +**Acceptance Criteria:** + +- [x] One-slot back-stack scenarios are covered. +- [x] Deep-link serialization/deserialization and `openView` are covered. +- [ ] No regression in ChatView/ChannelHeader behavior with new slot model. + +## Execution order + +Phase 1 (Parallel): + +- Task 1: Core Types and Controller Engine + +Phase 2 (After Task 1): + +- Task 2: Resolver Registry and Built-in Strategies + +Phase 3 (After Tasks 1, 2): + +- Task 3: ChatView Integration (Context and Props) + +Phase 4 (After Task 3): + +- Task 4: Header Toggle Wiring for Entity List Pane +- Task 5: Built-in Two-Step DX Layout API + +Phase 5 (After Tasks 2, 3, 4, 5): + +- Task 6: Tests for Controller, Resolvers, and Integration + +Phase 6 (After Tasks 5, 6): + +- Task 7: Docs and Spec Alignment + +Phase 7 (After Task 3): + +- Task 8: Slot Parent Stack and Back Navigation + +Phase 8 (After Task 8): + +- Task 9: Unify ChannelList into Slot Model +- Task 10: Min Slots and Fallback Workspace States + +Phase 9 (After Task 10): + +- Task 11: Generic Slot Component with Mount-Preserving Hide/Unhide +- Task 12: Deep-Linking, Serialization, and openView + +Phase 10 (After Task 12): + +- Task 13: High-Level Navigation Hook and Context Split + +Phase 11 (After Task 13): + +- Task 14: Thread Component Layout-Controller Adaptation + +Phase 12 (After Tasks 8-14): + +- Task 15: Tests for Slot Stack, Unified Slots, and Navigation DX + +## File Ownership Summary + +| Task | Creates/Modifies | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `src/components/ChatView/layoutController/LayoutController.ts`, `src/components/ChatView/layoutController/layoutControllerTypes.ts` | +| 2 | `layoutSlotResolvers.ts` | +| 3 | `ChatView.tsx`, `index.tsx` | +| 4 | `ChannelHeader.tsx` | +| 5 | `ChatView.tsx`, `layout/WorkspaceLayout.tsx` | +| 6 | `ChatView/__tests__/layoutController.test.ts`, `ChatView/__tests__/ChatView.test.tsx`, `ChannelHeader/__tests__/ChannelHeader.test.js` | +| 7 | `src/specs/layout-controller/spec.md`, `src/specs/layout-controller/plan.md` | +| 8 | `layoutController/LayoutController.ts`, `layoutController/layoutControllerTypes.ts`, `ChannelHeader.tsx` | +| 9 | `layoutController/layoutControllerTypes.ts`, `ChatView.tsx`, `layout/WorkspaceLayout.tsx`, `ChannelHeader.tsx` | +| 10 | `ChatView.tsx`, `layout/WorkspaceLayout.tsx`, `layoutController/layoutControllerTypes.ts` | +| 11 | `layout/Slot.tsx`, `ChatView/styling/*`, `layout/WorkspaceLayout.tsx` | +| 12 | `layoutController/LayoutController.ts`, `layoutController/layoutControllerTypes.ts`, `layoutController/serialization.ts` | +| 13 | `ChatView.tsx`, `ChatViewNavigationContext.tsx`, `index.tsx`, `ChannelHeader.tsx` | +| 14 | `src/components/Thread/Thread.tsx` | +| 15 | `ChatView/__tests__/layoutController.test.ts`, `ChatView/__tests__/ChatView.test.tsx`, `ChannelHeader/__tests__/ChannelHeader.test.js`, `ChatView/__tests__/ChatViewNavigation.test.tsx` | +| 16 | `src/components/ChatView/layout/Slot.tsx`, `src/components/ChatView/layout/WorkspaceLayout.tsx`, `src/specs/layout-controller/spec.md` | + +## Task 16: Slot Self-Visibility from Slot Prop + +**File(s) to create/modify:** `src/components/ChatView/layout/Slot.tsx`, `src/components/ChatView/layout/WorkspaceLayout.tsx`, `src/specs/layout-controller/spec.md` + +**Dependencies:** Task 11 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Update `Slot` so hidden/visible state is derived internally from the `slot` prop and layout/controller state. +- Remove requirement for parent components to pass explicit hidden state for slot visibility decisions. +- Keep mount-preserving hide/unhide behavior unchanged. + +**Acceptance Criteria:** + +- [x] `Slot` visibility can be computed without a parent-provided hidden prop. +- [x] Visibility behavior remains compatible with existing mount-preserving hide/unhide semantics. + +## Execution order update + +Phase 13 (After Task 11): + +- Task 16: Slot Self-Visibility from Slot Prop + +## Task 17: Remove Entity Semantics from LayoutController (Slot-Only Controller) + +**File(s) to create/modify:** `src/components/ChatView/layoutController/layoutControllerTypes.ts`, `src/components/ChatView/layoutController/LayoutController.ts`, `src/components/ChatView/ChatViewNavigationContext.tsx`, `src/components/ChatView/ChatView.tsx`, `src/specs/layout-controller/spec.md` + +**Dependencies:** Task 16 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Refactor `LayoutController` to model slot primitives only, without entity-binding-aware semantics. +- Move entity/domain interpretation and mapping to ChatView navigation/composition layers. +- Preserve backward compatibility through a migration path (aliases/shims where feasible) while introducing slot-only low-level contracts. + +**Acceptance Criteria:** + +- [x] LayoutController low-level API and state no longer depend on entity kinds/domain entities. +- [x] Entity-specific open/close behavior exists only in higher-level ChatView navigation/composition APIs. +- [x] Existing integration paths have documented migration guidance in spec. + +## Execution order update + +Phase 14 (After Task 16): + +- Task 17: Remove Entity Semantics from LayoutController (Slot-Only Controller) + +## File Ownership Summary Update + +| Task | Creates/Modifies | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 17 | `src/components/ChatView/layoutController/layoutControllerTypes.ts`, `src/components/ChatView/layoutController/LayoutController.ts`, `src/components/ChatView/ChatViewNavigationContext.tsx`, `src/components/ChatView/ChatView.tsx`, `src/specs/layout-controller/spec.md` | + +## Task 18: Remove Thread Pagination Fields from ChannelStateContextValue + +**File(s) to create/modify:** `src/context/ChannelStateContext.tsx`, `src/components/Channel/channelState.ts`, `src/components/Channel/hooks/useCreateChannelStateContext.ts` and other impacted files. + +**Dependencies:** Task 17 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Remove thread-pagination/thread-message fields from `ChannelState` / `ChannelStateContextValue`: + - `thread?: LocalMessage | null` + - `threadHasMore?: boolean` + - `threadLoadingMore?: boolean` + - `threadMessages?: LocalMessage[]` + - `threadSuppressAutoscroll?: boolean` +- Keep thread pagination source-of-truth in `Thread` instance (`ThreadContext` + `Thread.state`), not channel context. +- Preserve compatibility where possible by migrating consumers to thread-instance selectors/hooks. + +**Acceptance Criteria:** + +- [x] `ChannelStateContextValue` no longer exposes thread pagination fields. +- [x] Thread pagination rendering still works through `ThreadContext`-based state. + +## Task 19: Add `members` StateStore to ChannelState (SDK) + +**File(s) to create/modify:** `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` + +**Dependencies:** None + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Introduce dedicated `StateStore` for `members` in SDK `ChannelState`. +- Keep backward compatibility via existing API surface (getters/setters or equivalent adapter path). +- Ensure existing direct `channel.state.members` consumers continue to function during migration. + +**Acceptance Criteria:** + +- [x] `members` has a dedicated reactive store in `channel_state.ts`. +- [x] Backward-compatible access path for `members` is preserved. + +## Task 20: Add `read` StateStore to ChannelState (SDK) + +**File(s) to create/modify:** `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` + +**Dependencies:** Task 19 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Introduce dedicated `StateStore` for `read` in SDK `ChannelState`. +- Keep backward compatibility for existing `read` access. +- Validate that read-dependent consumers (receipts/unread logic) keep behavior. + +**Acceptance Criteria:** + +- [x] `read` has a dedicated reactive store in `channel_state.ts`. +- [x] Backward-compatible access path for `read` is preserved. + +## Task 21: Add `watcherCount` StateStore to ChannelState (SDK) + +**File(s) to create/modify:** `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` + +**Dependencies:** Task 20 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Introduce reactive storage for `watcherCount` as part of a shared watcher store contract. +- Preserve backward-compatible reads/writes for `watcherCount`. +- Ensure watcher count updates remain event-driven and stable. + +**Acceptance Criteria:** + +- [x] `watcherCount` is managed by dedicated reactive store infrastructure. +- [x] Backward-compatible access path for `watcherCount` is preserved. + +## Task 22: Add `watchers` StateStore to ChannelState (SDK) + +**File(s) to create/modify:** `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` + +**Dependencies:** Task 21 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add reactive storage for `watchers` in the same store family as `watcherCount`. +- Keep `watchers` backward compatibility via adapter/getter path. +- Confirm watcher list updates stay in sync with watcher count updates. + +**Acceptance Criteria:** + +- [x] `watchers` is managed by dedicated reactive store infrastructure. +- [x] `watchers` + `watcherCount` updates stay synchronized. + +## Task 23: Convert `mutedUsers` to Dedicated StateStore (SDK) + +**File(s) to create/modify:** `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` + +**Dependencies:** Task 22 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Convert `mutedUsers` in SDK `ChannelState` to dedicated `StateStore`. +- Preserve backward-compatible property behavior. +- Keep existing mute-dependent UI hooks/components functioning unchanged. + +**Acceptance Criteria:** + +- [x] `mutedUsers` is backed by dedicated reactive store. +- [x] Existing mute access APIs continue working. + +## Task 24: Move `typing` Reactive State to TextComposer StateStore (SDK) + +**File(s) to create/modify:** `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/messageComposer/textComposer.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `src/context/TypingContext.tsx`, `src/components/Channel/hooks/useCreateTypingContext.ts` + +**Dependencies:** Task 18 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Relocate typing reactive source-of-truth to existing `TextComposer` state store. +- Keep compatibility with current React TypingContext consumption. +- Remove duplicated typing ownership from channel-context-centric paths. +- Keep mirrored typing state on `ChannelState` side (`typingStore`) for backward compatibility, synchronized with TextComposer typing updates. +- Remove TypingContext.tsx from stream-chat-react + +**Acceptance Criteria:** + +- [x] `typing` source-of-truth is `TextComposer` reactive state. +- [x] Existing typing indicators/context consumers continue to work. + +## Task 25: Remove `suppressAutoscroll` from ChannelStateContext and Make It MessageList Props + +**File(s) to create/modify:** `src/context/ChannelStateContext.tsx`, `src/components/MessageList/MessageList.tsx`, `src/components/MessageList/VirtualizedMessageList.tsx`, `src/components/Channel/hooks/useCreateChannelStateContext.ts`, `src/components/Channel/Channel.tsx` + +**Dependencies:** Task 18 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Remove `suppressAutoscroll` from `ChannelStateContextValue`. +- Treat `suppressAutoscroll` as explicit prop input for `MessageList` and `VirtualizedMessageList`. +- Keep channel-level behavior backward compatible through prop defaulting/migration bridge. +- Remove `threadSuppressAutoscroll` from `ChannelStateContextValue`; thread suppression relies on explicit `suppressAutoscroll` props only. + +**Acceptance Criteria:** + +- [x] `ChannelStateContextValue` no longer includes `suppressAutoscroll` (and `threadSuppressAutoscroll`). +- [x] `MessageList` and `VirtualizedMessageList` support `suppressAutoscroll` via props without regressions. + +## Task 26: Integration Layer for Backward Compatibility of New Stores + +**File(s) to create/modify:** `src/components/Channel/hooks/useCreateChannelStateContext.ts`, `src/context/ChannelStateContext.tsx`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/client.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/messageComposer/textComposer.ts` + +**Dependencies:** Task 19, Task 20, Task 21, Task 22, Task 23, Task 24, Task 25 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add compatibility bridge layer so migrated stores can be consumed through existing SDK/React interfaces during transition. +- Ensure each moved value (`members`, `read`, `watcherCount`, `watchers`, `mutedUsers`, `typing`) has a stable fallback path. +- Keep `mutedUsers` reactivity on `StreamChat` (`client.mutedUsersStore`) and subscribe directly in React consumers instead of `ChatContext`. +- Keep `pinnedMessages` explicitly out of scope. + +**Acceptance Criteria:** + +- [x] Compatibility bridge documented and implemented for all moved values. +- [x] No breaking public API removals outside approved scope. + +## Task 27: Tests for ChannelStateContext Decomposition and Store Migration + +**File(s) to create/modify:** `src/components/MessageList/__tests__/MessageList.test.js`, `src/components/MessageInput/__tests__/*`, `src/components/TypingIndicator/__tests__/*`, `src/components/Channel/__tests__/Channel.test.js`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` + +**Dependencies:** Task 26 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add coverage for: + - channel resolution through `useChannel`, + - removed `ChannelStateContext` fields, + - new reactive stores (`members`, `read`, `watcherCount`, `watchers`, `mutedUsers`, `typing`), + - `suppressAutoscroll` prop behavior in MessageList variants. +- Include compatibility-focused regression checks. + +**Acceptance Criteria:** + +- [x] React and SDK tests cover all new store migration requirements. +- [x] No regression on thread pagination, unread/read, watchers, typing, mute, and autoscroll behavior. + +## Execution order update + +Phase 15 (After Task 17): + +- Task 18: Remove Thread Pagination Fields from ChannelStateContextValue + +Phase 16 (Sequential, same-file SDK `channel_state.ts` work): + +- Task 19: Add `members` StateStore to ChannelState (SDK) +- Task 20: Add `read` StateStore to ChannelState (SDK) +- Task 21: Add `watcherCount` StateStore to ChannelState (SDK) +- Task 22: Add `watchers` StateStore to ChannelState (SDK) +- Task 23: Convert `mutedUsers` to Dedicated StateStore (SDK) + +Phase 17 (After Task 18, parallelizable with Phases 16 where files do not overlap): + +- Task 24: Move `typing` Reactive State to TextComposer StateStore (SDK) +- Task 25: Remove `suppressAutoscroll` from ChannelStateContext and Make It MessageList Props + +Phase 18 (After Tasks 19-25): + +- Task 26: Integration Layer for Backward Compatibility of New Stores + +Phase 19 (After Task 26): + +- Task 27: Tests for ChannelStateContext Decomposition and Store Migration + +## File Ownership Summary Update + +| Task | Creates/Modifies | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 18 | `src/context/ChannelStateContext.tsx`, `src/components/Channel/channelState.ts`, `src/components/Channel/hooks/useCreateChannelStateContext.ts` | +| 19 | `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` | +| 20 | `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` | +| 21 | `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` | +| 22 | `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` | +| 23 | `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` | +| 24 | `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/messageComposer/textComposer.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `src/context/TypingContext.tsx`, `src/components/Channel/hooks/useCreateTypingContext.ts` | +| 25 | `src/context/ChannelStateContext.tsx`, `src/components/MessageList/MessageList.tsx`, `src/components/MessageList/VirtualizedMessageList.tsx`, `src/components/Channel/hooks/useCreateChannelStateContext.ts`, `src/components/Channel/Channel.tsx` | +| 26 | `src/components/Channel/hooks/useCreateChannelStateContext.ts`, `src/context/ChannelStateContext.tsx`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/messageComposer/textComposer.ts` | +| 27 | `src/components/MessageList/__tests__/MessageList.test.js`, `src/components/MessageInput/__tests__/*`, `src/components/TypingIndicator/__tests__/*`, `src/components/Channel/__tests__/Channel.test.js`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` | + +## Task 28: Convert `StreamClient.configs` to Reactive StateStore (SDK) + +**File(s) to create/modify:** `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/client.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` + +**Dependencies:** Task 27 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Introduce a dedicated `StateStore<{ configs: Configs }>` for `StreamClient.configs` in SDK client. +- Keep backward-compatible property access (`client.configs`) through getter/setter backed by the store. +- Ensure all config writes route through the reactive store path. + +**Acceptance Criteria:** + +- [x] `StreamClient.configs` is backed by `StateStore<{ configs: Configs }>`. +- [x] Legacy `client.configs` access remains backward compatible. + +## Task 29: Convert `channel.data.own_capabilities` to Reactive StateStore (SDK) + +**File(s) to create/modify:** `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` + +**Dependencies:** Task 28 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add reactive store for `channel.data.own_capabilities`. +- Keep `channel.data.own_capabilities` compatibility via non-breaking accessor bridge. +- Ensure updates from query/watch/events propagate to this store. + +**Acceptance Criteria:** + +- [x] `own_capabilities` has a dedicated reactive store path. +- [x] Existing capability reads remain backward compatible. + +## Task 30: Remove `channelConfig`/`channelCapabilities` from ChannelStateContext and Subscribe React SDK Stores + +**File(s) to create/modify:** `src/components/Channel/hooks/useCreateChannelStateContext.ts`, `src/context/ChannelStateContext.tsx`, `src/components/Message/hooks/useUserRole.ts`, `src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts`, `src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx`, `src/components/Poll/PollActions/PollActions.tsx`, `src/components/Poll/PollOptionSelector.tsx` + +**Dependencies:** Task 29 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Subscribe React SDK to new reactive stores for: + - client config (`channelConfig` source), + - own capabilities (`channelCapabilities` source). +- Remove static assumptions so components react to live store updates. +- Remove `channelConfig` and `channelCapabilities` from `ChannelStateContextValue` and migrate consumers to dedicated reactive hooks/selectors. + +**Acceptance Criteria:** + +- [x] `ChannelStateContextValue` no longer exposes `channelConfig` and `channelCapabilities`. +- [x] React SDK consumers derive config/capabilities from reactive stores via dedicated hooks/selectors. +- [x] Components relying on capabilities/config re-render on store updates. + +## Task 31: Compatibility and Regression Tests for Reactive Config/Capabilities + +**File(s) to create/modify:** `src/components/Channel/__tests__/Channel.test.js`, `src/components/MessageActions/__tests__/MessageActions.test.js`, `src/components/MessageInput/__tests__/*`, `src/components/Poll/__tests__/*`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` + +**Dependencies:** Task 30 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add SDK tests for reactive `client.config` and `own_capabilities`. +- Add React tests for: + - `channelConfig`-driven behavior via reactive hooks/selectors (not `ChannelStateContext`), + - `channelCapabilities`-driven behavior via reactive hooks/selectors (not `ChannelStateContext`), + - absence of `channelConfig`/`channelCapabilities` in `ChannelStateContextValue`. +- Verify no regression in gating logic for actions, attachments, and polls. + +**Acceptance Criteria:** + +- [x] SDK and React suites cover reactive config/capabilities migration paths. +- [x] Backward-compatible access patterns are verified. +- [x] No regression in config/capability feature gating. + +## Execution order update + +Phase 20 (After Task 27): + +- Task 28: Convert `StreamClient.config` to Reactive StateStore (SDK) + +Phase 21 (After Task 28): + +- Task 29: Convert `channel.data.own_capabilities` to Reactive StateStore (SDK) + +Phase 22 (After Task 29): + +- Task 30: Remove `channelConfig`/`channelCapabilities` from ChannelStateContext and Subscribe React SDK Stores + +Phase 23 (After Task 30): + +- Task 31: Compatibility and Regression Tests for Reactive Config/Capabilities + +## File Ownership Summary Update + +| Task | Creates/Modifies | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 28 | `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/client.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` | +| 29 | `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` | +| 30 | `src/components/Channel/hooks/useCreateChannelStateContext.ts`, `src/context/ChannelStateContext.tsx`, `src/components/Message/hooks/useUserRole.ts`, `src/components/MessageActions/hooks/useBaseMessageActionSetFilter.ts`, `src/components/MessageInput/AttachmentSelector/AttachmentSelector.tsx`, `src/components/Poll/PollActions/PollActions.tsx`, `src/components/Poll/PollOptionSelector.tsx` | +| 31 | `src/components/Channel/__tests__/Channel.test.js`, `src/components/MessageActions/__tests__/MessageActions.test.js`, `src/components/MessageInput/__tests__/*`, `src/components/Poll/__tests__/*`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` | + +## Task 32: Attachment-Scoped Media Config Surface + +**File(s) to create/modify:** `src/components/Attachment/Attachment.tsx`, `src/components/Attachment/AttachmentContainer.tsx`, `src/components/Attachment/Giphy.tsx`, `src/components/Attachment/LinkPreview/Card.tsx`, `src/components/Attachment/VideoAttachment.tsx`, `src/components/Attachment/*AttachmentContext*` (new) + +**Dependencies:** Task 31 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add `giphyVersion`, `imageAttachmentSizeHandler`, `shouldGenerateVideoThumbnail`, and `videoAttachmentSizeHandler` to `AttachmentProps`. +- Add attachment-local propagation (context/provider or equivalent) so attachment descendants read these values from attachment scope. +- Remove attachment descendants' direct reliance on `useChannelStateContext()` for these four values. + +**Acceptance Criteria:** + +- [x] All four values are provided by `AttachmentProps` and consumed in attachment scope. +- [x] Attachment subtree no longer requires `ChannelStateContext` for these values. +- [x] Existing attachment behavior remains unchanged with default setup. + +## Task 33: Remove Channel Ownership for Attachment Media Config + +**File(s) to create/modify:** `src/components/Channel/Channel.tsx`, `src/context/ChannelStateContext.tsx`, `src/components/Channel/hooks/useCreateChannelStateContext.ts` + +**Dependencies:** Task 32 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Remove `giphyVersion`, `imageAttachmentSizeHandler`, `shouldGenerateVideoThumbnail`, and `videoAttachmentSizeHandler` from `ChannelProps`. +- Remove those fields from `ChannelStateContextValue`. +- Remove creation/plumbing of these fields in channel context factories. + +**Acceptance Criteria:** + +- [x] `ChannelProps` no longer expose the four attachment media config values. +- [x] `ChannelStateContextValue` no longer includes these fields. +- [x] Typecheck passes with attachment-scoped ownership. + +## Task 34: Regression and Compatibility Coverage for Attachment-Scoped Config + +**File(s) to create/modify:** `src/components/Attachment/__tests__/*`, `src/components/Message/__tests__/*`, `src/components/Channel/__tests__/Channel.test.js` + +**Dependencies:** Task 32, Task 33 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add/adjust tests proving attachment rendering still supports giphy version selection, image/video sizing handlers, and video thumbnail generation behavior. +- Add regression coverage that these behaviors work without relying on `ChannelStateContext` fields. +- Validate that `Channel` no longer accepts these props. + +**Acceptance Criteria:** + +- [x] Test coverage verifies attachment-scoped config behavior. +- [x] Test coverage verifies removed `ChannelProps`/context fields. +- [x] No regressions in attachment rendering behavior. + +## Execution order update + +Phase 24 (After Task 31): + +- Task 32: Attachment-Scoped Media Config Surface + +Phase 25 (After Task 32): + +- Task 33: Remove Channel Ownership for Attachment Media Config + +Phase 26 (After Task 32 and Task 33): + +- Task 34: Regression and Compatibility Coverage for Attachment-Scoped Config + +## File Ownership Summary Update + +| Task | Creates/Modifies | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 32 | `src/components/Attachment/Attachment.tsx`, `src/components/Attachment/AttachmentContainer.tsx`, `src/components/Attachment/Giphy.tsx`, `src/components/Attachment/LinkPreview/Card.tsx`, `src/components/Attachment/VideoAttachment.tsx`, `src/components/Attachment/*AttachmentContext*` | +| 33 | `src/components/Channel/Channel.tsx`, `src/context/ChannelStateContext.tsx`, `src/components/Channel/hooks/useCreateChannelStateContext.ts` | +| 34 | `src/components/Attachment/__tests__/*`, `src/components/Message/__tests__/*`, `src/components/Channel/__tests__/Channel.test.js` | + +## Receipt Reactivity Ownership Contract (Tasks 35-39) + +Tasks 35-39 must implement and preserve this ownership model: + +1. Canonical store: `channel.state.readStore` +2. Derived store: `channel.messageReceiptsTracker` reactive output +3. Consumers only: React receipt hooks/components + +Allowed direction only: + +- event/query ingestion -> `readStore` patch -> tracker reconcile/emit -> React subscription render + +Conflict policy: + +- if tracker output diverges from `readStore`, reconciliation must prefer `readStore` and repair tracker state. + +## Task 35: Immutable `readStore` Patching for Receipt Updates (SDK Internals) + +**File(s) to create/modify:** `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` + +**Dependencies:** Task 34 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Replace internal in-place mutation paths for receipt state (`state.read[userId] = ...`) with immutable `readStore.next((current) => ...)` patching. +- Keep `ChannelState.read` compatibility access intact while routing event/query update paths through direct `readStore` patching. +- Ensure bootstrap/query `state.read` ingestion applies incremental immutable merges without unnecessary whole-map overwrite churn. + +**Acceptance Criteria:** + +- [x] Receipt updates for a single user trigger `readStore` subscriptions. +- [x] Existing `channel.state.read` compatibility behavior is preserved. +- [x] No new public `ChannelState` methods are introduced. +- [x] Receipt updates are canonicalized in `readStore` before any tracker/UI projection updates. + +## Task 36: Unify Event-to-Receipt Reconciliation on `readStore` + Tracker (SDK) + +**File(s) to create/modify:** `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` + +**Dependencies:** Task 35 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Route `message.read`, `notification.mark_unread`, and `message.delivered` through one reconciliation pattern: + - patch `readStore` immutably for affected user(s), + - advance `messageReceiptsTracker` in lockstep. +- Keep query/watch initialization aligned with the same reconciliation semantics. + +**Acceptance Criteria:** + +- [x] All receipt-relevant events update `readStore` and `messageReceiptsTracker` consistently. +- [x] Event ordering does not regress delivered/read invariants. +- [x] One-way sync is enforced: `readStore` (canonical) -> tracker (derived), not the reverse. + +## Task 37: Emit Reactive UI Receipt Snapshots from `MessageReceiptsTracker` (SDK) + +**File(s) to create/modify:** `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/messageDelivery/MessageReceiptsTracker.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/messageDelivery/index.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` + +**Dependencies:** Task 36 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add tracker-owned reactive state for UI consumers (for example `StateStore` with `revision` and/or cached `readersByMessageId` + `deliveredByMessageId` snapshot). +- Emit updates only on effective receipt changes to avoid redundant React work. +- Keep existing query methods (`readersForMessage`, `deliveredForMessage`, etc.) backward compatible. + +**Acceptance Criteria:** + +- [x] Tracker exposes a reactive surface suitable for React selectors. +- [x] Snapshot/revision updates happen for `ingestInitial`, `onMessageRead`, `onMessageDelivered`, and `onNotificationMarkUnread` when state effectively changes. +- [x] Tracker has a deterministic resync/rebuild path from canonical `readStore`. + +## Task 38: Migrate Receipt Hooks to Tracker-Emitted Reactive Data (React) + +**File(s) to create/modify:** `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/messageDelivery/MessageReceiptsTracker.ts`, `src/components/MessageList/hooks/useLastReadData.ts`, `src/components/MessageList/hooks/useLastDeliveredData.ts`, `src/store/hooks/useStateStore.ts` (if selector-shape support adjustment is needed), `src/components/ChannelPreview/hooks/useMessageDeliveryStatus.ts` + +**Dependencies:** Task 37 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Route tracker reconciliation from canonical `channel.state.readStore` emissions (subscription-driven), not direct per-event tracker method calls in `Channel` event handlers. +- Add metadata-first delta reconciliation contract for read store emissions (changed/removed user ids), with key-diff fallback when metadata is unavailable. +- Refactor hooks to subscribe to `channel.messageReceiptsTracker` reactive state instead of relying on component tree rerenders or ad hoc event listeners. +- Avoid recomputing full receipt maps in React when tracker can provide emitted/cached receipt snapshots. +- Preserve current behavior for `returnAllReadData` vs last-own-message-only paths. + +**Acceptance Criteria:** + +- [x] Tracker reconciliation is driven by canonical `readStore` emissions and keeps one-way `readStore -> tracker` ownership. +- [x] Metadata-first delta reconciliation is supported for read updates, with deterministic fallback when metadata is absent. +- [x] `useLastReadData` and `useLastDeliveredData` update reactively on receipt events through tracker state. +- [x] Manual `channel.on('message.delivered', ...)` hook-level synchronization is removed where superseded by tracker store. +- [x] Hook outputs remain API-compatible. +- [x] Hooks do not implement independent receipt truth; they only select from tracker reactive output. + +## Task 39: Regression Matrix for Read/Delivery Reactivity + +**File(s) to create/modify:** `src/components/MessageList/__tests__/*`, `src/components/Message/__tests__/*`, `src/components/ChannelPreview/__tests__/*`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` + +**Dependencies:** Task 38 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add SDK tests for immutable `readStore` patch semantics and tracker reactive snapshot emission. +- Add SDK tests for metadata-driven delta reconciliation and key-diff fallback behavior when metadata is absent. +- Add SDK tests for tracker subscription lifecycle (single subscription, teardown, no post-teardown emissions). +- Add React tests validating updates after `message.read`, `notification.mark_unread`, and `message.delivered`. +- Verify no regressions in message receipt UI paths (message list + preview surfaces). + +**Acceptance Criteria:** + +- [ ] Event matrix is covered end-to-end across SDK and React layers, including metadata-driven and fallback reconciliation paths. +- [ ] Tracker subscription lifecycle behavior is covered and stable. +- [ ] Read/delivery receipt UI remains functionally consistent with expected behavior. + +## Task 40: Add `memberCount` to MembersState with `channel.data.member_count` Compatibility Bridge + +**File(s) to create/modify:** `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/channel_state.test.js`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/channel.test.js` + +**Dependencies:** Task 39 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Extend `MembersState` in `channel_state.ts` with `memberCount` so members state shape mirrors watcher state semantics. +- Add/adjust backward-compatible members store accessors so `memberCount` is available through reactive state and legacy access paths. +- Bridge `channel.data.member_count` compatibility in `channel.ts` using the same sync pattern used for own capabilities (`syncOwnCapabilitiesFromChannelData`-style lifecycle sync). +- Ensure SDK-managed channel data replacement paths keep `memberCount` and `channel.data.member_count` synchronized. +- Keep direct `channel.data.member_count` read/write behavior compatible while treating reactive `memberCount` as canonical. +- Add SDK unit coverage for initialization, channel data replacement, direct assignment compatibility, and reactive subscriber updates. + +**Acceptance Criteria:** + +- [x] `MembersState` includes `memberCount` and exposes it through the intended reactive/compatibility paths. +- [x] `channel.data.member_count` reads remain backward compatible after migration. +- [x] SDK-managed `channel.data` replacement and direct `member_count` assignment both synchronize with canonical `memberCount` state. +- [x] Focused unit tests validate synchronization and backward-compatibility behavior. + +## Task 41: Remove `messageIsUnread` from `MessageContextValue` and Resolve Unread via `MessageReceiptsTracker` + +**File(s) to create/modify:** `src/context/MessageContext.tsx`, `src/components/Message/Message.tsx`, `src/components/MessageList/utils.ts`, `src/components/Message/__tests__/*`, `src/components/MessageList/__tests__/*`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/messageDelivery/MessageReceiptsTracker.ts` (API consumption verification only; no SDK behavior changes expected) + +**Dependencies:** Task 39 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Remove `messageIsUnread` from `MessageContextValue` so message unread status is no longer carried as ad hoc derived context state. +- Refactor message unread checks in React SDK paths to use `channel.messageReceiptsTracker` APIs from `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/messageDelivery/MessageReceiptsTracker.ts`. +- Keep unread-separator behavior and first-unread detection semantics stable in list rendering. +- Ensure no extra receipt source-of-truth is introduced in React; unread state remains tracker-driven/canonical-store-derived. + +**Acceptance Criteria:** + +- [x] `MessageContextValue` no longer defines or provides `messageIsUnread`. +- [x] Message unread/delivery UI paths resolve unread state via `MessageReceiptsTracker` API calls/selectors. +- [x] Message list unread separator behavior remains unchanged for end users. +- [x] Updated tests cover unread-state behavior after the context-field removal. + +## Task 42: Remove Legacy `MessageProps.openThread` Prop + +**File(s) to create/modify:** `src/components/Message/types.ts`, `src/components/Message/Message.tsx`, `src/specs/layout-controller/spec.md`, `src/specs/layout-controller/plan.md`, `src/specs/layout-controller/state.json`, `src/specs/layout-controller/decisions.md` + +**Dependencies:** Task 13 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Remove leftover `openThread` prop from `MessageProps` because thread-open behavior now belongs to `ChatViewNavigationContext`. +- Remove type plumbing that omits `openThread` in `Message.tsx` now that the prop no longer exists. +- Update spec/plan/state/decisions to capture this cleanup and the canonical navigation contract. + +**Acceptance Criteria:** + +- [x] `MessageProps` no longer includes `openThread`. +- [x] `Message.tsx` no longer references `openThread` in `MessagePropsToOmit`. +- [x] Spec/plan/state/decisions explicitly record that `useChatViewNavigation()` is the source of truth for thread opening. + +## Task 43: Remove `MessageProps.threadList` and Infer Thread Scope in Leaf Components + +**File(s) to create/modify:** `src/components/Message/types.ts`, `src/components/Message/Message.tsx`, `src/context/MessageContext.tsx`, `src/components/Message/MessageSimple.tsx`, `src/components/Message/MessageStatus.tsx`, `src/components/Message/MessageAlsoSentInChannelIndicator.tsx`, `src/components/Message/utils.tsx`, `src/components/Message/__tests__/*`, `src/components/MessageList/hooks/MessageList/useMessageListElements.tsx`, `src/components/MessageList/MessageList.tsx`, `src/components/MessageList/VirtualizedMessageList.tsx`, `src/components/MessageList/VirtualizedMessageListComponents.tsx`, `src/components/MessageList/ScrollToLatestMessageButton.tsx`, `src/components/TypingIndicator/TypingIndicator.tsx`, `src/components/Attachment/Audio.tsx`, `src/components/Attachment/LinkPreview/CardAudio.tsx`, `src/components/Attachment/VoiceRecording.tsx`, `src/components/Reactions/ReactionSelectorWithButton.tsx`, `src/components/Thread/Thread.tsx`, `src/components/Thread/ThreadHead.tsx`, `src/components/Attachment/__tests__/Audio.test.js`, `src/components/Attachment/__tests__/Card.test.js`, `src/components/Attachment/__tests__/VoiceRecording.test.js`, `src/components/MessageActions/__tests__/MessageActions.test.js`, `src/components/MessageList/__tests__/MessageList.test.js`, `src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.js`, `src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js`, `src/components/TypingIndicator/__tests__/TypingIndicator.test.js`, `src/components/Thread/__tests__/Thread.test.js`, `src/specs/layout-controller/spec.md`, `src/specs/layout-controller/plan.md`, `src/specs/layout-controller/state.json`, `src/specs/layout-controller/decisions.md` + +**Dependencies:** Task 42 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Remove leftover `threadList` from `MessageProps` and related pass-through plumbing in message-level wrappers. +- Update message leaf components that currently branch on `threadList` to infer thread scope directly via `useThreadContext()` instead of context/prop forwarding. +- Keep behavior parity for thread-specific UX branches (reply-button visibility, status rendering, “also sent in channel” behavior/text), but sourced from local thread-instance presence. +- Add/adjust focused tests for leaf behavior in both thread and channel scope without relying on `threadList` prop drilling. + +**Acceptance Criteria:** + +- [x] `MessageProps` no longer includes `threadList`. +- [x] Message leaf components that require thread awareness infer it via `useThreadContext()` and do not depend on drilled `threadList` props. +- [x] `MessageContextValue` no longer carries `threadList` only for downstream branching. +- [x] Existing thread-vs-channel behavior remains functionally equivalent in updated tests. + +## Task 44: Remove `LegacyThreadContext` and Legacy Thread Context Wiring + +**File(s) to create/modify:** `src/components/Thread/LegacyThreadContext.ts`, `src/components/Thread/Thread.tsx`, `src/components/Thread/index.ts`, `src/components/Thread/*` consumers still using `useLegacyThreadContext`, `src/specs/layout-controller/spec.md`, `src/specs/layout-controller/plan.md`, `src/specs/layout-controller/state.json`, `src/specs/layout-controller/decisions.md` + +**Dependencies:** Task 14 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Remove `LegacyThreadContext` provider and hook usage from thread rendering/wiring. +- Remove legacy context exports from `src/components/Thread/index.ts`. +- Update any remaining consumers to use current thread/channel data sources (`useThreadContext`, `useChannel`, or explicit props) without re-introducing context prop drilling. +- Keep runtime behavior equivalent for thread open/close/navigation flows after removing the legacy context layer. + +**Acceptance Criteria:** + +- [ ] `LegacyThreadContext` is no longer used in thread rendering paths. +- [ ] Thread module no longer exports legacy thread context APIs. +- [ ] TypeScript compiles with no references to `useLegacyThreadContext` in active code. +- [ ] Thread behavior remains stable in updated tests. + +## Task 45: Declarative Slot Topology (`slotNames`) in ChatView + +**File(s) to create/modify:** `src/components/ChatView/ChatView.tsx`, `src/components/ChatView/layoutController/layoutControllerTypes.ts`, `src/components/ChatView/__tests__/ChatView.test.tsx`, `specs/layout-controller/spec.md`, `specs/layout-controller/plan.md`, `specs/layout-controller/state.json`, `specs/layout-controller/decisions.md` + +**Dependencies:** Task 13 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add `slotNames?: string[]` to `ChatView` props as the canonical ordered topology for available slots. +- Keep backward compatibility by generating `slot1..slotN` when `slotNames` is omitted. +- Initialize available slots from `slotNames` + `minSlots`/`maxSlots` clamping rules. +- Add focused tests for initialization with explicit slot names and fallback generation behavior. + +**Acceptance Criteria:** + +- [ ] `ChatView` supports custom slot ids without relying on hard-coded `slot` naming. +- [ ] Default behavior remains unchanged when `slotNames` is not provided. +- [ ] TypeScript compiles and new tests assert slot initialization semantics. + +## Task 46: Navigation Expansion Must Respect Configured Slot Names + +**File(s) to create/modify:** `src/components/ChatView/ChatViewNavigationContext.tsx`, `src/components/ChatView/__tests__/ChatViewNavigation.test.tsx` + +**Dependencies:** Task 45 + +**Status:** in-progress + +**Owner:** codex + +**Scope:** + +- Replace hard-coded expansion (`slot${n}`) in navigation open flows with topology-aware expansion from configured slots. +- Ensure `openThread` (and related expansion path(s)) uses the next available configured slot id. +- Preserve existing duplicate-policy and resolver behavior (move/replace/reject semantics remain controller-owned). + +**Acceptance Criteria:** + +- [ ] Navigation expansion uses configured slot names when present. +- [ ] Existing `slot` behavior remains unchanged when `slotNames` is absent. + +## Task 47: Slot Claimer Components (`ChatView.Channels`, `ChannelSlot`, `ThreadSlot`) Contract Tightening + +**File(s) to create/modify:** `src/components/ChatView/ChatView.tsx`, `src/components/Channel/ChannelSlot.tsx`, `src/components/Thread/ThreadSlot.tsx`, `src/components/ChatView/__tests__/useSlotEntity.test.tsx`, `src/components/ChatView/__tests__/ChatView.test.tsx` + +**Dependencies:** Task 46 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Keep slot claiming declarative via slot components and `useChatViewNavigation()` APIs only. +- Verify `ChatView.Channels` slot-claim behavior and `ChannelSlot`/`ThreadSlot` claim-request flows operate with arbitrary slot ids from topology. +- Ensure conflict behavior remains policy-driven (controller decides replacement/move/reject). + +**Acceptance Criteria:** + +- [ ] Slot-claimers work with named slots (not only `slot` ids). +- [ ] Tests cover explicit-claim behavior and policy-driven outcomes. + +## Task 48: Documentation and Migration Notes for Declarative Slot Topology + +**File(s) to create/modify:** `specs/layout-controller/spec.md`, `specs/layout-controller/plan.md`, `specs/layout-controller/state.json`, `specs/layout-controller/decisions.md` + +**Dependencies:** Task 45, Task 46, Task 47 + +**Status:** pending + +**Owner:** unassigned + +**Scope:** + +- Update spec migration guidance to describe declarative slot topology and slot-claiming patterns. +- Document compatibility defaults and examples (`slotNames`, `ChatView.Channels slot`, `hideChannelList/unhideChannelList` with named slots). +- Sync plan/state/decision logs with implemented outcomes. + +**Acceptance Criteria:** + +- [ ] Spec and plan clearly describe the declarative slot model and migration path. +- [ ] Ralph files reflect final implementation state consistently. + +## Execution order update + +Phase 27 (After Task 34): + +- Task 35: Immutable `readStore` Patching for Receipt Updates (SDK Internals) + +Phase 28 (After Task 35): + +- Task 36: Unify Event-to-Receipt Reconciliation on `readStore` + Tracker (SDK) + +Phase 29 (After Task 36): + +- Task 37: Emit Reactive UI Receipt Snapshots from `MessageReceiptsTracker` (SDK) + +Phase 30 (After Task 37): + +- Task 38: Migrate Receipt Hooks to Tracker-Emitted Reactive Data (React) + +Phase 31 (After Task 38): + +- Task 39: Regression Matrix for Read/Delivery Reactivity + +Phase 32 (After Task 39): + +- Task 40: Add `memberCount` to MembersState with `channel.data.member_count` Compatibility Bridge + +Phase 33 (After Task 39): + +- Task 41: Remove `messageIsUnread` from `MessageContextValue` and Resolve Unread via `MessageReceiptsTracker` + +Phase 34 (After Task 13): + +- Task 42: Remove Legacy `MessageProps.openThread` Prop + +Phase 35 (After Task 42): + +- Task 43: Remove `MessageProps.threadList` and Infer Thread Scope in Leaf Components + +Phase 36 (After Task 14): + +- Task 44: Remove `LegacyThreadContext` and Legacy Thread Context Wiring + +Phase 37 (After Task 13): + +- Task 45: Declarative Slot Topology (`slotNames`) in ChatView + +Phase 38 (After Task 45): + +- Task 46: Navigation Expansion Must Respect Configured Slot Names + +Phase 39 (After Task 46): + +- Task 47: Slot Claimer Components Contract Tightening + +Phase 40 (After Tasks 45-47): + +- Task 48: Documentation and Migration Notes for Declarative Slot Topology + +## File Ownership Summary Update + +| Task | Creates/Modifies | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 35 | `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` | +| 36 | `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` | +| 37 | `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/messageDelivery/MessageReceiptsTracker.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/messageDelivery/index.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` | +| 38 | `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/messageDelivery/MessageReceiptsTracker.ts`, `src/components/MessageList/hooks/useLastReadData.ts`, `src/components/MessageList/hooks/useLastDeliveredData.ts`, `src/store/hooks/useStateStore.ts`, `src/components/ChannelPreview/hooks/useMessageDeliveryStatus.ts` | +| 39 | `src/components/MessageList/__tests__/*`, `src/components/Message/__tests__/*`, `src/components/ChannelPreview/__tests__/*`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/*` | +| 40 | `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel_state.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/channel.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/channel_state.test.js`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/test/unit/channel.test.js` | +| 41 | `src/context/MessageContext.tsx`, `src/components/Message/Message.tsx`, `src/components/MessageList/utils.ts`, `src/components/Message/__tests__/*`, `src/components/MessageList/__tests__/*`, `/Users/martincupela/Projects/stream/chat/stream-chat-js-worktrees/thread-constructor-minimal-init/src/messageDelivery/MessageReceiptsTracker.ts` (API consumption verification only) | +| 42 | `src/components/Message/types.ts`, `src/components/Message/Message.tsx`, `src/specs/layout-controller/spec.md`, `src/specs/layout-controller/plan.md`, `src/specs/layout-controller/state.json`, `src/specs/layout-controller/decisions.md` | +| 43 | `src/components/Message/types.ts`, `src/components/Message/Message.tsx`, `src/context/MessageContext.tsx`, `src/components/Message/MessageSimple.tsx`, `src/components/Message/MessageStatus.tsx`, `src/components/Message/MessageAlsoSentInChannelIndicator.tsx`, `src/components/Message/utils.tsx`, `src/components/Message/__tests__/*`, `src/specs/layout-controller/spec.md`, `src/specs/layout-controller/plan.md`, `src/specs/layout-controller/state.json`, `src/specs/layout-controller/decisions.md` | +| 44 | `src/components/Thread/LegacyThreadContext.ts`, `src/components/Thread/Thread.tsx`, `src/components/Thread/index.ts`, `src/components/Thread/*` consumers still using `useLegacyThreadContext`, `src/specs/layout-controller/spec.md`, `src/specs/layout-controller/plan.md`, `src/specs/layout-controller/state.json`, `src/specs/layout-controller/decisions.md` | +| 45 | `src/components/ChatView/ChatView.tsx`, `src/components/ChatView/layoutController/layoutControllerTypes.ts`, `src/components/ChatView/__tests__/ChatView.test.tsx`, `specs/layout-controller/spec.md`, `specs/layout-controller/plan.md`, `specs/layout-controller/state.json`, `specs/layout-controller/decisions.md` | +| 46 | `src/components/ChatView/ChatViewNavigationContext.tsx`, `src/components/ChatView/__tests__/ChatViewNavigation.test.tsx` | +| 47 | `src/components/ChatView/ChatView.tsx`, `src/components/Channel/ChannelSlot.tsx`, `src/components/Thread/ThreadSlot.tsx`, `src/components/ChatView/__tests__/useSlotEntity.test.tsx`, `src/components/ChatView/__tests__/ChatView.test.tsx` | +| 48 | `specs/layout-controller/spec.md`, `specs/layout-controller/plan.md`, `specs/layout-controller/state.json`, `specs/layout-controller/decisions.md` | + +## Task 49: Slot-Equal Navigation Refactor and Controller API Renames + +**File(s) to create/modify:** `src/components/ChatView/layoutController/layoutControllerTypes.ts`, `src/components/ChatView/layoutController/LayoutController.ts`, `src/components/ChatView/layoutController/serialization.ts`, `src/components/ChatView/ChatViewNavigationContext.tsx`, `src/components/ChatView/hooks/useSlotEntity.ts`, `src/components/ChannelHeader/ChannelHeader.tsx`, `src/components/ChannelList/ChannelList.tsx`, `src/components/ChatView/__tests__/layoutController.test.ts`, `src/components/ChatView/__tests__/ChatViewNavigation.test.tsx`, `src/components/ChannelHeader/__tests__/ChannelHeader.test.js`, `src/components/ChannelList/__tests__/ChannelList.test.js`, `specs/layout-controller/spec.md`, `specs/layout-controller/plan.md`, `specs/layout-controller/state.json`, `specs/layout-controller/decisions.md` + +**Dependencies:** Task 48 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Remove implicit "current/focused slot" fallback semantics; treat all slots as equal for navigation targeting. +- Replace overloaded close semantics with explicit history/navigation lifecycle APIs: + - `goBack(slot)` for back navigation, + - `goForward(slot)` for forward navigation, + - `clear(slot)` for binding reset, + - `hide/unhide(slot)` for visibility only. +- Add per-slot forward history tracking and enforce forward-stack invalidation after new writes post-back. +- Rename low-level controller methods for clarity: + - `bind` -> `setSlotBinding`, + - `open` -> `openInLayout`. +- Align `useChatViewNavigation()` targeting behavior with deterministic slot resolution (explicit slot or unambiguous candidate; ambiguous requests reject/no-op). +- Update tests and Ralph docs to reflect the final API contract and migration path. + +**Acceptance Criteria:** + +- [x] No layout-controller API path depends on `activeSlot`/focused-slot assumptions. +- [x] Back/forward history works independently per slot and does not leak between slots. +- [x] `setSlotBinding` and `openInLayout` are the only low-level slot-write API names. +- [x] Visibility (`hide/unhide`) and lifecycle (`clear`) remain independent from history navigation (`goBack/goForward`). +- [ ] TypeScript compiles and targeted navigation/controller tests cover ambiguity, back/forward, and renamed APIs. + +## Execution order update + +Phase 41 (After Task 48): + +- Task 49: Slot-Equal Navigation Refactor and Controller API Renames + +## File Ownership Summary Update + +| Task | Creates/Modifies | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 49 | `src/components/ChatView/layoutController/layoutControllerTypes.ts`, `src/components/ChatView/layoutController/LayoutController.ts`, `src/components/ChatView/layoutController/serialization.ts`, `src/components/ChatView/ChatViewNavigationContext.tsx`, `src/components/ChatView/hooks/useSlotEntity.ts`, `src/components/ChannelHeader/ChannelHeader.tsx`, `src/components/ChannelList/ChannelList.tsx`, `src/components/ChatView/__tests__/layoutController.test.ts`, `src/components/ChatView/__tests__/ChatViewNavigation.test.tsx`, `src/components/ChannelHeader/__tests__/ChannelHeader.test.js`, `src/components/ChannelList/__tests__/ChannelList.test.js`, `specs/layout-controller/spec.md`, `specs/layout-controller/plan.md`, `specs/layout-controller/state.json`, `specs/layout-controller/decisions.md` | diff --git a/specs/layout-controller/spec.md b/specs/layout-controller/spec.md new file mode 100644 index 0000000000..1030b7a49e --- /dev/null +++ b/specs/layout-controller/spec.md @@ -0,0 +1,209 @@ +# ChatView Layout Controller Spec + +This spec describes the current implementation in `src/components/ChatView`. + +## API Surface + +`src/components/ChatView/index.tsx` exports: + +- `ChatView` components/hooks/types +- `ChatViewNavigationContext` API +- layout controller types + serialization helpers +- slot resolver helpers/registry + +Note: + +- `LayoutController` class implementation exists at + `src/components/ChatView/layoutController/LayoutController.ts`. +- It is intentionally not re-exported from `src/components/ChatView/index.tsx`. + +## Core State Model + +`ChatViewLayoutState` is view-scoped. Slot state is stored only under `*ByView` maps. + +```ts +type ChatViewLayoutState = { + activeView: ChatView; + maxSlots?: number; + minSlots?: number; + + listSlotByView?: Partial>; + + availableSlotsByView?: Partial>; + slotNamesByView?: Partial>; + + hiddenSlotsByView?: Partial>>; + slotBindingsByView?: Partial< + Record> + >; + slotHistoryByView?: Partial< + Record> + >; + slotForwardHistoryByView?: Partial< + Record> + >; + slotMetaByView?: Partial< + Record> + >; +}; +``` + +`getLayoutViewState(state, view?)` derives active-view slot state used by ChatView consumers. + +## LayoutController Contract + +Implemented by `new LayoutController(...)`. + +```ts +type LayoutController = { + setSlotBinding(slot: SlotLayout, binding?: LayoutSlotBinding): void; + setAvailableSlots(slots: SlotLayout[]): void; + setSlotNames(slots?: SlotLayout[]): void; + + clear(slot: SlotLayout): void; + goBack(slot: SlotLayout): void; + goForward(slot: SlotLayout): void; + + hide(slot: SlotLayout): void; + unhide(slot: SlotLayout): void; + + openInLayout( + binding: LayoutSlotBinding, + options?: { targetSlot?: SlotLayout }, + ): OpenResult; + + openView(view: ChatView, options?: { slot?: SlotLayout }): void; + setActiveView(next: ChatView): void; + + state: StateStore; +}; +``` + +Behavior: + +1. All slot operations (`setSlotBinding`, `hide`, `goBack`, `goForward`, etc.) apply to the **active view slice** only. +2. `setActiveView`/`openView` switch only `activeView`; each view keeps its own slot state in `*ByView` stores. +3. `openInLayout` slot selection order: + +- explicit `targetSlot` if valid in active view +- existing slot occupied by same `binding.payload.kind` +- first free active-view slot +- `resolveTargetSlot(...)` fallback + +4. Duplicate handling is identity-key based (`binding.key`) with `allow | move | reject`. +5. Slot history and forward history are tracked **per slot, per view**. + +Resolver arguments include both global state and active-view slice: + +```ts +type ResolveTargetSlotArgs = { + state: ChatViewLayoutState; + activeViewState: ChatViewLayoutViewState; + binding: LayoutSlotBinding; + requestedSlot?: SlotLayout; +}; +``` + +## ChatView Integration + +`ChatView` supports: + +- `maxSlots`, `minSlots`, `slotNames` +- `layoutController` override +- `resolveTargetSlot`, `duplicateEntityPolicy`, `resolveDuplicateEntity` +- `layout='nav-rail-entity-list-workspace'` +- optional fallback/renderer customization + +Initialization: + +- When `layoutController` is not provided, ChatView creates one. +- Initial topology is written to `channels` view slice (`availableSlotsByView.channels`, `slotNamesByView.channels`). + +`ChatView.Channels` / `ChatView.Threads`: + +- Accept `slots?: SlotLayout[]`. +- When the view is active, they call controller APIs to set per-view topology: + - `setSlotNames(slots)` + - `setAvailableSlots(slots)` +- `slots` order is the render/claim order for that view. + +View switch behavior: + +- `setActiveView(next)` (via `useChatViewContext`) clears `channel` and `thread` bindings in the view being left before switching. + +## Navigation API (`useChatViewNavigation`) + +Current API: + +- `openView(view, { slot? })` +- `openChannel(channel, { slot? })` +- `closeChannel({ slot? })` +- `openThread(threadOrTarget, { slot? })` +- `closeThread({ slot? })` +- `hideChannelList({ slot? })` +- `unhideChannelList({ slot? })` + +Current behavior: + +1. `openChannel(...)` + +- closes thread slots in active view (`closeThread()`) +- switches to `channels` view +- binds channel via `openInLayout` + +2. `openThread(...)` + +- opens in requested slot when provided +- otherwise opens by controller resolution +- if no free slot and topology allows expansion, appends next configured slot name + +3. `closeThread({ slot? })` + +- with explicit `slot`: clears only that slot +- without slot: clears deterministically resolved thread slots in active view + +4. List toggle behavior is view-aware: + +- channels view list kind: `channelList` +- threads view list kind: `threadList` +- list-slot hint uses `listSlotByView[activeView]` + +## Slot Components and Claiming + +- `ChannelListSlot`, `ThreadListSlot`, `ChannelSlot`, and `ThreadSlot` are explicit slot claimers. +- Slot visibility is internal to `Slot` (via state lookup by slot key), not parent-prop driven. +- `ThreadSlot` no longer auto-claims from another thread slot. + +## Thread Interaction Behavior + +- `ThreadListItemUI` opens via navigation API (`openThread`). +- Normal click targets `main-thread`. +- `Ctrl/Cmd + Click` targets `optional-thread`. +- `Thread` close button (`str-chat__close-thread-button`) closes only its owning slot: +- `ThreadSlot` provides slot context +- `Thread` calls `closeThread({ slot })` + +## Built-in Layout Mode + +For `layout='nav-rail-entity-list-workspace'`, ChatView renders: + +1. selector rail (`ChatView.Selector`) +2. ordered workspace slots from active-view `availableSlots` +3. each slot content from current entity binding (`channelList`, `threadList`, `channel`, `thread`, etc.) +4. slot fallback when unbound + +There is no dedicated internal `entityListSlot` concept. + +## Serialization + +`serializeLayoutState` / `restoreLayoutState` persist and restore view-scoped collections: + +- `availableSlotsByView` +- `slotNamesByView` +- `hiddenSlotsByView` +- `slotBindingsByView` +- `slotHistoryByView` +- `slotForwardHistoryByView` +- `slotMetaByView` +- `listSlotByView` +- `activeView` diff --git a/specs/layout-controller/state.json b/specs/layout-controller/state.json new file mode 100644 index 0000000000..804fc59799 --- /dev/null +++ b/specs/layout-controller/state.json @@ -0,0 +1,62 @@ +{ + "tasks": { + "task-1-core-types-and-controller-engine": "done", + "task-2-resolver-registry-and-built-in-strategies": "done", + "task-3-chatview-integration-context-and-props": "done", + "task-4-header-toggle-wiring-for-entity-list-pane": "done", + "task-5-built-in-two-step-dx-layout-api": "done", + "task-6-tests-for-controller-resolvers-and-integration": "done", + "task-7-docs-and-spec-alignment": "done", + "task-8-slot-parent-stack-and-back-navigation": "done", + "task-9-unify-channellist-into-slot-model": "done", + "task-10-min-slots-and-fallback-workspace-states": "done", + "task-11-generic-slot-component-with-mount-preserving-hide-unhide": "done", + "task-12-deep-linking-serialization-and-openview": "done", + "task-13-high-level-navigation-hook-and-context-split": "done", + "task-14-thread-component-layout-controller-adaptation": "done", + "task-15-tests-for-slot-stack-unified-slots-and-navigation-dx": "done", + "task-16-slot-self-visibility-from-slot-prop": "done", + "task-17-remove-entity-semantics-from-layoutcontroller-slot-only-controller": "done", + "task-18-remove-thread-pagination-fields-from-channelstatecontextvalue": "done", + "task-19-add-members-statestore-to-channelstate-sdk": "done", + "task-20-add-read-statestore-to-channelstate-sdk": "done", + "task-21-add-watchercount-statestore-to-channelstate-sdk": "done", + "task-22-add-watchers-statestore-to-channelstate-sdk": "done", + "task-23-convert-mutedusers-to-dedicated-statestore-sdk": "done", + "task-24-move-typing-reactive-state-to-textcomposer-statestore-sdk": "done", + "task-25-remove-suppressautoscroll-from-channelstatecontext-and-make-it-messagelist-prop": "done", + "task-26-integration-layer-for-backward-compatibility-of-new-stores": "done", + "task-27-tests-for-channelstatecontext-decomposition-and-store-migration": "done", + "task-28-convert-streamclient-config-to-reactive-statestore-sdk": "done", + "task-29-convert-channel-data-own-capabilities-to-reactive-statestore-sdk": "done", + "task-30-subscribe-react-sdk-src-to-config-and-capability-stores": "done", + "task-31-compatibility-and-regression-tests-for-reactive-config-capabilities": "done", + "task-32-attachment-scoped-media-config-surface": "done", + "task-33-remove-channel-ownership-for-attachment-media-config": "done", + "task-34-regression-and-compatibility-coverage-for-attachment-scoped-config": "done", + "task-35-immutable-readstore-patching-for-receipt-updates-sdk-internals": "done", + "task-36-unify-event-to-receipt-reconciliation-on-readstore-plus-tracker-sdk": "done", + "task-37-emit-reactive-ui-receipt-snapshots-from-messagereceiptstracker-sdk": "done", + "task-38-migrate-receipt-hooks-to-tracker-emitted-reactive-data-react": "done", + "task-39-regression-matrix-for-read-delivery-reactivity": "pending", + "task-40-add-membercount-to-membersstate-with-channel-data-member-count-compatibility-bridge": "done", + "task-41-remove-messageisunread-from-messagecontextvalue-and-use-messagereceiptstracker-api": "done", + "task-42-remove-openthread-from-messageprops-and-align-navigation-contract": "done", + "task-43-remove-threadlist-from-messageprops-and-infer-thread-scope-locally": "done", + "task-44-remove-legacythreadcontext-and-legacy-thread-context-wiring": "pending", + "task-45-declarative-slot-topology-slotnames-in-chatview": "done", + "task-46-navigation-expansion-must-respect-configured-slot-names": "in-progress", + "task-47-slot-claimer-components-contract-tightening": "pending", + "task-48-documentation-and-migration-notes-for-declarative-slot-topology": "pending", + "task-49-slot-equal-navigation-refactor-and-controller-api-renames": "done" + }, + "flags": { + "blocked": false, + "needs-review": false + }, + "meta": { + "last_updated": "2026-03-05", + "worktree": "../stream-chat-react-worktrees/chatview-layout-controller", + "branch": "feat/chatview-layout-controller" + } +} diff --git a/specs/message-pagination/decisions.md b/specs/message-pagination/decisions.md new file mode 100644 index 0000000000..b2df061362 --- /dev/null +++ b/specs/message-pagination/decisions.md @@ -0,0 +1,793 @@ +# Message Pagination Decisions + +## Decision: Use instance-driven pagination as the canonical replacement contract + +**Date:** 2026-03-04 +**Context:** +The migration goal is to make `Channel` and `Thread` operate on the same pagination abstraction and eliminate legacy context-thread pagination controls. + +**Decision:** +Adopt `(thread ?? channel).messagePaginator` as the canonical replacement for legacy `jumpTo*` / `loadMore*` action-context APIs, and `useChatViewNavigation` for thread open/close navigation. + +**Reasoning:** +This keeps data ownership on SDK instances and allows sibling rendering (`Channel` + `Thread`) without requiring nested Channel contexts for thread lifecycle control. + +**Alternatives considered:** + +- Keep ChannelActionContext as a permanent shim for thread pagination: rejected because it preserves structural coupling. +- Introduce separate React-only thread pagination controller: rejected because it duplicates SDK ownership already available in `MessagePaginator`. + +**Tradeoffs / Consequences:** +Thread paginator behavior in `stream-chat-js` must be fully thread-replies aware before React can rely on it everywhere. + +## Decision: Track migration as done vs missing by runtime behavior, not by commented code removal + +**Date:** 2026-03-04 +**Context:** +Several files still contain commented legacy APIs, but many runtime flows already use paginator/navigation instances. + +**Decision:** +Mark work as "done" only when runtime behavior is instance-driven; treat commented legacy remnants as cleanup follow-up. + +**Reasoning:** +Behavioral independence is the core objective; cosmetic cleanup should not be conflated with functional completion. + +**Alternatives considered:** + +- Define completion by deleting all commented legacy code first: rejected because that may hide unresolved runtime coupling. + +**Tradeoffs / Consequences:** +Plan includes an explicit final cleanup task after core migration and tests. + +## Decision: Prioritize thread paginator correctness before broad React context decoupling + +**Date:** 2026-03-04 +**Context:** +Current `Thread` paginator construction in `stream-chat-js` still indicates thread-specific query adaptation is incomplete. + +**Decision:** +Sequence remaining work so `stream-chat-js` thread paginator correctness is completed before final React list/context decoupling. + +**Reasoning:** +React-side migrations (`MessageList`, hooks, actions) cannot be validated safely if the underlying thread paginator does not yet guarantee thread-replies semantics. + +**Alternatives considered:** + +- Complete React decoupling first and patch JS paginator later: rejected because it risks shipping incorrect pagination behavior in thread views. + +**Tradeoffs / Consequences:** +Cross-repo sequencing is required; React branch depends on upstream JS behavior finalization. + +## Decision: Keep `MessagePaginator` as shared paginator type by making it thread-aware via optional parent id + +**Date:** 2026-03-04 +**Context:** +`Thread` still instantiated `MessagePaginator` with channel-only query behavior, causing thread pagination to read channel message dataset instead of thread replies. + +**Decision:** +Extend `MessagePaginator` with optional `parentMessageId`: + +- when absent, query channel messages (`channel.query({ messages: ... })`) as before; +- when present, query thread replies (`channel.getReplies(parentMessageId, ...)`); +- include `parent_id` in client-side filters only for thread mode. + +`Thread` now constructs `MessagePaginator` with `parentMessageId: thread.id`. + +**Reasoning:** +This preserves the target architecture (single paginator abstraction for channel and thread) while fixing dataset correctness for thread pagination. + +**Alternatives considered:** + +- Use `MessageReplyPaginator` in `Thread`: rejected for this migration because it introduces dual paginator abstractions instead of converging on one. +- Keep channel-only query path and adapt in React layer: rejected because data ownership/correctness must be fixed at SDK source. + +**Tradeoffs / Consequences:** +`MessagePaginator` now has a mode switch (channel vs thread replies), so tests must cover both query paths. Added coverage in `MessagePaginator.test.ts`. + +## Decision: Active display routing is owned by ChatView layout, not ChatContext + +**Date:** 2026-03-04 +**Context:** +`ChatContext` currently still exposes `setActiveChannel` and `channel` display routing, while ChatView already has slot-based entity ownership (`LayoutController` + `ChatViewNavigation`). + +**Decision:** +Treat `LayoutController.state.slotBindings` as the sole active display source for Channel/Thread entities, and remove `setActiveChannel` routing from `ChatContext` and `Chat.tsx`. + +**Reasoning:** +Keeping both routing models creates conflicting sources of truth and blocks full sibling `Channel`/`Thread` architecture. + +**Alternatives considered:** + +- Keep `setActiveChannel` as a long-term compatibility path: rejected because it perpetuates dual routing ownership. +- Mirror layout state back into ChatContext channel field: rejected because it reintroduces coupling instead of removing it. + +**Tradeoffs / Consequences:** +Some integrations/tests that currently drive active display via `setActiveChannel` will need migration to `useChatViewNavigation().openChannel(...)` or equivalent layout-controller binding calls. + +## Decision: Migrate channel selection entry points to ChatViewNavigation in the same task as `setActiveChannel` removal + +**Date:** 2026-03-04 +**Context:** +Removing `setActiveChannel` from `ChatContextValue` breaks selection entry points that still call it (`ChannelList`, `ChannelSearch`, and experimental search result items), as well as tests/mocks asserting the old contract. + +**Decision:** +In Task 3, migrate these entry points to `useChatViewNavigation().openChannel(...)` and update directly affected tests/mocks in the same change set. + +**Reasoning:** +This keeps runtime behavior coherent immediately after context API removal and prevents introducing a partially-migrated broken state. + +**Alternatives considered:** + +- Keep temporary optional `setActiveChannel` in context: rejected because it preserves dual routing ownership. +- Defer selection-entry migration to later task: rejected because TypeScript/runtime breakages would be immediate. + +**Tradeoffs / Consequences:** +Task 3 scope expands beyond ChatContext/Chat wrapper wiring and includes ChannelList/Search selection touchpoints plus related test updates. + +## Decision: Remove `channel` from ChatContext after deprecating context-owned active routing + +**Date:** 2026-03-04 +**Context:** +`ChatContext.channel` previously reflected state owned by `setActiveChannel`, which is now removed in favor of layout-controller-owned entity routing. + +**Decision:** +Remove `channel` from `ChatContextValue` and update consumers to resolve channel instance from local/runtime ownership (`Channel` prop, `useChannel()`, or layout bindings). + +**Reasoning:** +Keeping `channel` in chat context would preserve an obsolete pseudo-source-of-truth and invite accidental coupling back to pre-layout routing. + +**Alternatives considered:** + +- Keep `channel` as always-undefined compatibility field: rejected because it adds noise and misleading API surface. +- Mirror active slot channel back into ChatContext: rejected because it duplicates layout state ownership. + +**Tradeoffs / Consequences:** +Consumers that previously read active channel from chat context had to be migrated (e.g., channel selection flow, geolocation actions, edit-message handler, and unread counter helper paths). + +## Decision: Introduce `ChannelSlot` as channel counterpart to `ThreadSlot` + +**Date:** 2026-03-04 +**Context:** +After removing ChatContext-owned active channel routing, `Channel` must receive a concrete channel instance from layout-owned bindings in ChatView compositions. + +**Decision:** +Add `ChannelSlot` that resolves a channel entity from ChatView slot bindings and renders `...`, mirroring `ThreadSlot` behavior for threads. + +**Reasoning:** +This gives a consistent slot-adapter pattern for both channel and thread rendering and removes the need for example/integration code to source channel instance from deprecated ChatContext fields. + +**Alternatives considered:** + +- Require all integrators to pass `channel` to `` manually from custom layout state: rejected because it reimplements slot resolution logic in each integration. +- Fold channel rendering into `ChatView` only via `slotRenderers`: rejected because existing composition patterns benefit from a reusable adapter component. + +**Tradeoffs / Consequences:** +Examples and integrations should migrate to `ChannelSlot` in ChatView flows; direct `` usage remains valid when a concrete `channel` prop is provided. + +## Decision: Standardize slot binding lookup behind `useSlotEntity` (and typed wrappers) + +**Date:** 2026-03-04 +**Context:** +Slot consumers (`ChannelSlot`, `ThreadSlot`, search/navigation components) repeatedly implement local logic to find active entities from layout bindings, which creates duplicated narrowing code and inconsistent fallback behavior. + +**Decision:** +Introduce a shared `useSlotEntity({ kind, slot? })` hook in ChatView scope, with optional typed wrappers (`useSlotChannel`, `useSlotThread`) for common cases. Migrate slot adapters and selection consumers to this shared API. + +**Reasoning:** +A single lookup contract reduces type errors (for example channel/thread union narrowing pitfalls), avoids drift in fallback rules, and clarifies how entity resolution works across single-slot vs multi-slot render scenarios. + +**Alternatives considered:** + +- Keep inline per-component slot lookup logic: rejected because it scales duplication and repeats union-type bugs. +- Move all lookup into `ChannelSlot`/`ThreadSlot` only: rejected because non-slot components (search/navigation consumers) still need shared entity resolution semantics. + +**Tradeoffs / Consequences:** +`ChannelSlot` remains a single-slot adapter; multi-pane channel rendering requires multiple `ChannelSlot` instances with explicit slot ids. Omitted-slot fallback remains first-match convenience, not deterministic multi-pane placement. + +## Decision: ChannelList auto-hide is driven by layout slot capacity + +**Date:** 2026-03-04 +**Context:** +Channel selection previously relied on `ChatContext.closeMobileNav`, which is not aligned with ChatView layout ownership of visible panes/slots. + +**Decision:** +ChannelList channel selection flow should call `useChatViewNavigation().hideChannelList(...)` only when channel open replaces the existing `channelList` slot binding (that is, no spare available slot capacity to keep list and opened channel simultaneously). + +Also remove `closeMobileNav` from `ChatContext` contract. + +**Reasoning:** +Pane visibility belongs to layout/navigation state (`entityListPaneOpen` + slot hidden flags), not chat context state. This keeps one routing/visibility authority and avoids dual navigation control paths. + +**Alternatives considered:** + +- Keep `closeMobileNav` as a compatibility shim in `ChatContext`: rejected because it preserves duplicate visibility state ownership. +- Always hide channel list after channel select: rejected because desktop and multi-slot layouts should keep list visible. + +**Tradeoffs / Consequences:** +Auto-hide now follows layout-capacity outcomes directly; behavior is independent of device/user-agent heuristics. + +## Decision: Thread-subtree message handlers use optional channel-context with direct instance fallback + +**Date:** 2026-03-04 +**Context:** +Several message/message-input/message-list hooks still assumed `ChannelActionContext` / `ChannelStateContext` presence, which is not guaranteed when rendering `Thread` as a sibling without Channel providers. + +**Decision:** +Introduce optional channel-action context access for these paths and fallback to direct `channel`/`thread` instance operations when context is absent. + +Applied in: + +- `MessageBounceContext` +- `useSendMessageFn`, `useUpdateMessageFn`, `useRetryHandler` +- `useMarkRead` +- `Message` and message action hooks (`useActionHandler`, `useDeleteHandler`, `useMentionsHandler`, `usePinHandler`, `useReactionHandler`) + +**Reasoning:** +This removes hard runtime coupling to Channel providers for thread flows while preserving existing context-driven behavior where providers are present. + +**Alternatives considered:** + +- Keep hard Channel context requirement and enforce wrapper nesting: rejected because it conflicts with sibling `Channel`/`Thread` architecture. +- Rebuild a separate React-only action context for threads: rejected due duplication with available channel/thread instance APIs. + +**Tradeoffs / Consequences:** +When outside Channel providers, custom Channel-level action overrides (for example custom delete/update wrappers) are bypassed in favor of direct SDK instance behavior. + +## Decision: Retarget Task 5 to complete ChannelActionContext removal in message flows + +**Date:** 2026-03-04 +**Context:** +Task 5 was marked complete under a partial decoupling approach (optional context + direct fallback), but migration intent is stricter: remove `ChannelActionContext` dependency entirely from message interaction flows. + +**Decision:** +Re-open Task 5 and enforce the following final contract: + +- notifications via `client.notifications` (`StreamChat.notifications` / `NotificationManager`); +- optimistic message reconciliation via `messagePaginator` APIs (`ingestItem`, `removeItem`); +- no runtime dependence on `ChannelActionContext` for message interaction handlers. + +**Reasoning:** +Optional-context fallback still preserves dual interaction models and leaves migration ambiguous. A single, instance-driven contract is required for predictable sibling Channel/Thread composition. + +**Alternatives considered:** + +- Keep optional `ChannelActionContext` fallback as end-state: rejected because it preserves legacy coupling and override pathways. +- Defer message optimistic-state migration to cleanup task only: rejected because this is a core runtime contract, not cosmetic cleanup. + +**Tradeoffs / Consequences:** +Some existing Channel-level custom action override hooks will require replacement or new extension points aligned with paginator/client-instance APIs. + +## Decision: Keep `Channel.test.js` focused on React integration, not paginator algorithms + +**Date:** 2026-03-05 +**Context:** +During migration, `Channel.test.js` accumulated compatibility/emulation logic for paginator internals (pagination query choreography, unread jump resolution) that belongs to SDK entity behavior. + +**Decision:** +Constrain `stream-chat-react` `Channel.test.js` to integration ownership: + +- context/render wiring; +- registration/delegation of request handlers; +- component-level event bridging and UI-facing behavior. + +Skip or remove legacy tests that re-assert `MessagePaginator` and entity algorithms already covered in `stream-chat-js`. + +**Reasoning:** +This avoids duplicate logic in React tests, reduces brittle migration shims, and keeps ownership boundaries clear between UI integration and SDK data/algorithm behavior. + +**Alternatives considered:** + +- Keep full parity assertions in `Channel.test.js`: rejected because it duplicates `stream-chat-js` coverage and encourages test-only emulation code. +- Port all deep algorithm assertions to React layer with heavy mocks: rejected due high maintenance cost and weak signal. + +**Tradeoffs / Consequences:** +React suite has fewer deep unread/pagination algorithm assertions; those invariants must remain strong in `stream-chat-js` unit tests. + +## Decision: Split Thread/Channel ownership for reply updates (`message.new` vs `message.updated`) + +**Date:** 2026-03-05 +**Context:** +`Thread.subscribeNewReplies` was updating `Thread.state.replies` only (`upsertReplyLocally`) and did not guarantee `thread.messagePaginator` updates. +At the same time, parent-message metadata (`reply_count`, `thread_participants`) belongs to channel message items and should be synchronized by `Channel` event handling. + +**Decision:** +Use split ownership: + +- ingest reply into `thread.messagePaginator`; +- keep legacy `Thread.state.replies` mirrored for backward compatibility; +- keep thread-local `replyCount`/`parentMessage.reply_count` in `Thread.state` for thread consumers; +- update channel message-item metadata via `Channel` handling of `message.updated` / `message.undeleted` events by ingesting updated parent messages into `channel.messagePaginator`. + +**Reasoning:** +This keeps thread reply-list state owned by thread, and channel message metadata owned by channel, while still supporting paginator-first UI consumers. + +**Alternatives considered:** + +- Keep only `Thread.state.replies` updates: rejected because paginator-driven thread consumers would miss events. +- Update channel parent metadata from `Thread` on `message.new`: rejected because it crosses ownership boundaries and can diverge from authoritative backend counts. + +**Tradeoffs / Consequences:** +Channel metadata updates rely on authoritative server events (`message.updated`/`message.undeleted`) and `channel.messagePaginator.ingestItem(...)`; duplicate local `message.new` echoes no longer risk parent count over-increment on channel side. + +## Decision: Use commented `Channel.tsx` legacy actions as parity checklist + +## Decision: Resolve mark-read custom handlers in `MessageDeliveryReporter` by collection type + +**Date:** 2026-03-04 +**Context:** +Mark-read customization previously routed through `Channel.markReadRequest`, which mixed channel/thread concerns and kept an unnecessary channel-level indirection for thread reads. + +**Decision:** +Resolve mark-read custom handlers directly in `MessageDeliveryReporter.markRead(...)`: + +- channel collections use `channel.configState.requestHandlers.markReadRequest`; +- thread collections use `thread.configState.requestHandlers.markReadRequest`; +- default fallback for both uses `channel.markAsReadRequest(...)`, with `thread_id` enrichment for thread collections. + +Also expose `Thread.markRead(...)` as the primary API and keep `Thread.markAsRead(...)` as deprecated alias. + +**Reasoning:** +This keeps handler ownership instance-scoped, avoids cross-collection routing, and keeps read transport semantics centralized in one place. + +**Tradeoffs / Consequences:** +Thread-specific custom mark-read handlers must now be registered on thread instance config (React `useThreadRequestHandlers` updated accordingly). + +**Date:** 2026-03-04 +**Context:** +Large commented legacy blocks in `Channel.tsx` encode previous interaction behavior and can be missed when migration tasks are loosely defined. + +**Decision:** +Treat those commented action blocks as explicit parity checklist in spec coverage: + +- `loadMore*`, `jumpTo*`, unread UI state handling, +- notification routing, +- optimistic update/remove/send/retry/edit paths. + +**Reasoning:** +This prevents partial migration claims and forces concrete behavior-by-behavior replacement tracking. + +**Alternatives considered:** + +- Keep only high-level task wording: rejected because it leaves too much interpretation room and misses edge behavior. + +**Tradeoffs / Consequences:** +Migration progress must be tracked at finer granularity; task completion criteria are stricter. + +## Decision: Message interaction optimistic reconcile is paginator-first and MessageOperations-backed + +**Date:** 2026-03-04 +**Context:** +To finish Task 5, message interaction handlers in React needed a concrete contract that matches `stream-chat-js` optimistic operation semantics (`src/messageOperations/MessageOperations.ts`) and avoids Channel action-context coupling. + +**Decision:** +Standardize message interaction behavior on: + +- notifications via `client.notifications` (`addSuccess`/`addError`); +- optimistic local reconcile via `messagePaginator.ingestItem` / `messagePaginator.removeItem`; +- send/retry/update via instance APIs that delegate to `messageOperations` (`sendMessageWithLocalUpdate`, `retrySendMessageWithLocalUpdate`, `updateMessageWithLocalUpdate`). + +Applied in current pass: + +- migrated `Message` notification dispatch away from `ChannelActionContext` wrappers; +- migrated `useActionHandler`, `useDeleteHandler`, `usePinHandler`, `useReactionHandler`, and error-delete action in `MessageActions` to paginator reconcile; +- switched `UnreadMessagesNotification` / `UnreadMessagesSeparator` read actions to instance methods (`thread.markAsRead` / `channel.markRead`); +- switched deprecated `useAudioController` notifications to `client.notifications`; +- updated `Channel.tsx` action-context wrapper implementations (`updateMessage` / `removeMessage`) to call paginator APIs. + +**Reasoning:** +This removes dual state mutation paths (`channel.state.*` vs context wrappers) and aligns React behavior with SDK instance ownership. + +**Alternatives considered:** + +- Keep optional ChannelActionContext fallback for update/remove operations: rejected because it keeps dual semantics and context coupling. +- Continue writing to `channel.state` directly in handlers: rejected because optimistic lifecycle should be centralized on `MessagePaginator` and MessageOperations. + +**Tradeoffs / Consequences:** +At the time of this decision, `ChannelActionContext` still had remaining legacy customization surfaces (for example mention handlers). Those mention surfaces were removed later when Task 10 was completed. + +## Decision: `stream-chat-react` must not call `Thread.upsertReplyLocally` / `deleteReplyLocally` + +**Date:** 2026-03-04 +**Context:** +`Thread` in `stream-chat-js` still exposes `upsertReplyLocally` and `deleteReplyLocally` for backward compatibility. During migration, several `stream-chat-react` hooks still invoked these methods after paginator updates. + +**Decision:** +For `stream-chat-react`, message reconcile in thread flows must be done only via `thread.messagePaginator` APIs (`ingestItem`, `removeItem`, and paginator-driven state subscriptions). +`Thread.upsertReplyLocally` / `Thread.deleteReplyLocally` remain in `stream-chat-js` as compatibility APIs, but are treated as legacy and not used by React SDK runtime paths. + +**Reasoning:** +Using both paginator reconcile and thread local-reply mutators creates dual state mutation paths and reintroduces coupling to `Thread.state.replies`, which conflicts with the instance-era paginator-first architecture. + +**Alternatives considered:** + +- Continue using `upsertReplyLocally` in React as a bridge: rejected because it keeps dual state ownership and makes migration completion ambiguous. +- Remove methods from `stream-chat-js`: rejected for backward compatibility. + +**Tradeoffs / Consequences:** +Consumers still using `Thread.state.replies`-based derived UI in React internals must be migrated to paginator state where message data is needed. Compatibility methods remain available for external integrations on `stream-chat-js`. + +## Decision: Mention handlers are Message-level API, not Channel-level/context API + +**Date:** 2026-03-04 +**Context:** +`onMentionsClick` / `onMentionsHover` were historically accepted on `Channel` and propagated through `ChannelActionContext`. In the paginator/layout migration this keeps unnecessary cross-context coupling for behavior that belongs to message rendering. + +**Decision:** +Move mention handlers to `Message` props as the primary contract and remove mention handler fields from `ChannelActionContext` and `Channel` props. + +**Reasoning:** +Mention interactions are tied to message text rendering and should be configured where message components are configured. This also reduces remaining reliance on `ChannelActionContext`. + +**Alternatives considered:** + +- Keep Channel-level mention props and forward into Message handlers: rejected because it preserves legacy context coupling and ambiguous ownership. +- Keep both Channel-level and Message-level APIs long term: rejected because duplicate config paths create precedence ambiguity. + +**Tradeoffs / Consequences:** +Integrations passing mention handlers on `` must migrate to message-level configuration (`Message` props / message list integration points). Mention-related tests move from Channel-context assertions to Message-level assertions. + +## Decision: Add instance-level delete wrappers in JS SDK and migrate React delete flow to them + +**Date:** 2026-03-04 +**Context:** +`stream-chat-js` currently provides instance-owned optimistic wrappers for `send/retry/update` (`*WithLocalUpdate`) but delete flow is still handled ad-hoc in React (currently direct `client.deleteMessage(...)` + paginator ingest). Also, custom delete request injection is available in React `Channel` props (`doDeleteMessageRequest`) but not in JS instance config handlers. + +**Decision:** +Introduce `deleteMessageWithLocalUpdate` wrappers on `Channel` and `Thread` in `stream-chat-js`, and add handler injection support (`deleteMessageRequest`) in instance config/per-call APIs. +Then migrate `stream-chat-react` delete flow to call these instance wrappers instead of direct `client.deleteMessage(...)` and context-era delete wrappers. + +Naming convention requirement: + +- keep operation method naming parallel to existing patterns (`send/retry/update/delete` in `MessageOperations`); +- keep instance wrapper naming parallel (`*WithLocalUpdate`). + +**Reasoning:** +Delete behavior should follow the same ownership model as send/retry/update: + +- one instance-level API surface for optimistic/state reconcile semantics; +- one extension point for integrator request customization; +- no React-only fallback logic divergence. + +**Alternatives considered:** + +- Keep React-level delete wrapper only (`doDeleteMessageRequest` in `Channel.tsx`): rejected because it keeps logic split across layers and blocks consistent instance contract. +- Add delete only on `Channel` and not `Thread`: rejected because thread/message operations should remain symmetric where possible. + +**Tradeoffs / Consequences:** +`MessageOperations` contract likely needs an additional operation kind (`delete`) or a dedicated small operation path with equivalent state policy. +Migration needs coordinated JS+React updates and tests, but keeps backward compatibility by adding APIs rather than removing existing ones. + +## Decision: Mark-read migration scope includes both Channel and Thread flows + +**Date:** 2026-03-04 +**Context:** +Initial Task 12 framing scoped mark-read migration to channel message lists only, while `stream-chat-js` already supports thread read reporting through `Thread.markAsRead()` delegated to `MessageDeliveryReporter`. + +**Decision:** +Expand mark-read migration scope to cover both channel and thread message-list flows: + +- channel flows use `channel.markRead(...)`; +- thread flows use `thread.markAsRead(...)`; +- both delegate to `client.messageDeliveryReporter.markRead(...)`. + +`ChannelActionContext.markRead` is no longer the target runtime contract for either flow. + +**Reasoning:** +Keeping thread out of scope would leave an unnecessary split contract even though the instance API is already available and aligned (`MessageDeliveryReporter` handles channel vs thread request shape, including `thread_id`). + +**Alternatives considered:** + +- Keep thread mark-read deferred to a follow-up task: rejected because it preserves dual behavior without technical need. +- Call `channel.markRead` from thread UI paths: rejected because `thread.markAsRead` is the explicit thread-level API and preserves intent. + +**Tradeoffs / Consequences:** +Tests must cover both channel and thread mark-read trigger paths (auto-mark + explicit unread UI actions) and ensure unread snapshot/UI parity during migration away from context-markRead wiring. + +## Decision: Custom mark-read overrides are instance-scoped via request handlers + +**Date:** 2026-03-04 +**Context:** +Mark-read customization is needed for both channel and thread surfaces. A proposal to mutate `client.messageDeliveryReporter` from React component lifecycle (`useEffect`) would introduce client-global side effects across slots/channels/threads. + +**Decision:** +Do not mutate `client.messageDeliveryReporter` from React runtime. +Instead: + +- keep `ChannelProps.doMarkReadRequest(channel, options?)`; +- add `ThreadProps.doMarkReadRequest({ thread, options? })`; +- wire both to `channel.configState.requestHandlers.markReadRequest`; +- resolve that handler from `MessageDeliveryReporter.markRead(...)` for both channel and thread calls. + +**Reasoning:** +`messageDeliveryReporter` is client-global state. Per-instance request handlers preserve isolation and avoid race/collision risks in multi-slot layouts while still supporting custom request behavior. + +**Alternatives considered:** + +- Configure/override reporter methods in React `useEffect`: rejected because it is global mutable state and can leak across unrelated collections. +- Keep channel-only handler signature and infer thread from `options.thread_id`: rejected because thread-level customization should receive explicit thread context. + +**Tradeoffs / Consequences:** +Thread-level customization requires small adapter wiring in `Thread.tsx` to attach/detach scoped handler behavior and preserve previous handler chain on cleanup. + +## Decision: Remove `ChannelActionContext` from runtime/public API surface + +**Date:** 2026-03-04 +**Context:** +After migrating message actions, notifications, navigation, and mark-read flows to instance APIs, `ChannelActionContext` no longer provides required runtime behavior. + +**Decision:** +Delete `src/context/ChannelActionContext.tsx`, remove it from context barrel exports, and stop wrapping `Channel` children with `ChannelActionProvider`. + +**Reasoning:** +Keeping an empty/legacy action context invites accidental coupling and stale integrations. Instance APIs (`channel`/`thread` + `messagePaginator`, `useChatViewNavigation`, `client.notifications`) are now the authoritative contract. + +**Alternatives considered:** + +- Keep an empty compatibility provider indefinitely: rejected because it preserves dead API surface. +- Keep context only for tests/examples: rejected because tests/examples should validate shipped runtime contracts. + +**Tradeoffs / Consequences:** +Legacy tests/stories that imported `ChannelActionProvider`/`useChannelActionContext` must be migrated to current APIs. + +## Decision: `suppressAutoscroll` is message-list behavior, not Channel reducer state + +**Date:** 2026-03-04 +**Context:** +`suppressAutoscroll` was historically toggled in `channelState.ts` during legacy load-more flows. That reducer is no longer the runtime source of truth in the paginator-first architecture. + +**Decision:** +Treat `suppressAutoscroll` as list-local behavior driven by paginator/list lifecycle (`toTail`/loading transitions), and remove dependency on `channelState.ts` semantics. + +**Reasoning:** +Autoscroll suppression is a scroll policy concern in message-list rendering, not channel-global state. Keeping it in legacy reducer logic blocks final Channel-state-context cleanup and creates hidden coupling. + +**Alternatives considered:** + +- Keep reducer-driven suppression and pass it through Channel context: rejected because it preserves legacy ownership and context coupling. +- Drop suppression behavior entirely: rejected because it risks regressions while paginating older messages. + +**Tradeoffs / Consequences:** +`MessageList` and `VirtualizedMessageList` must implement/verify equivalent suppression behavior from modern state paths, with regression tests for pagination while scrolled up. + +## Decision: Decompose `ChannelStateContext` into instance provider + list-local data inputs + +**Date:** 2026-03-04 +**Context:** +`ChannelStateContext` currently mixes channel instance access and list-level data transport (`notifications`), while paginator-first flows now derive message/read state from instance stores. + +**Decision:** +Remove `ChannelStateContext` in staged steps: + +- keep channel instance access through a dedicated minimal channel-instance provider (or equivalent source), +- move list-specific inputs (`notifications`) to explicit list contracts, +- migrate list/message runtime consumers and test/story wrappers accordingly. + +**Reasoning:** +This avoids replacing one oversized context with another while keeping `useChannel()` and list components operational during migration. + +**Alternatives considered:** + +- Keep `ChannelStateContext` permanently as a thin wrapper: rejected because it preserves legacy API surface and naming mismatch. +- Remove context in one shot without replacement provider: rejected because `useChannel()` and many consumers need a stable channel source. + +**Tradeoffs / Consequences:** +Migration requires coordinated runtime + test/story updates, but yields clearer ownership boundaries (instance source vs list rendering state). + +## Decision: `channelConfig` and `channelCapabilities` are channel-owned, not ChannelStateContext-owned + +**Date:** 2026-03-04 +**Context:** +Some tests were still injecting `channelConfig` / `channelCapabilities` through `ChannelStateProvider`, while runtime behavior already resolves these values from channel-owned stores/hooks. + +**Decision:** +Do not expose or consume `channelConfig` / `channelCapabilities` through `ChannelStateContext`. + +- Capabilities are resolved from `channel.state.ownCapabilitiesStore` (via `useChannelCapabilities`). +- Config is resolved from channel config state (`useChannelConfig` / client config store). + +**Reasoning:** +This keeps one source of truth aligned with instance-driven architecture and avoids preserving deprecated context shape. + +**Alternatives considered:** + +- Keep context copies for test convenience: rejected because it encourages runtime drift and blocks full context removal. + +**Tradeoffs / Consequences:** +Tests must configure capabilities/config on channel instances (or client config store) instead of passing them via `ChannelStateProvider`. + +## Decision: Complete Task 18 by deleting `ChannelStateContext` and `channelState.ts` + +**Date:** 2026-03-04 +**Context:** +After runtime migration and test/story migration to instance-based channel sourcing, `ChannelStateContext.tsx` and `src/components/Channel/channelState.ts` had no remaining runtime ownership role. + +**Decision:** +Delete `ChannelStateContext.tsx` and `src/components/Channel/channelState.ts`, remove context export from `src/context/index.ts`, and migrate remaining tests/examples to `ChannelInstanceProvider` / `useChannel`. + +**Reasoning:** +Keeping dead legacy state/context files increases API ambiguity and invites accidental coupling back to pre-paginator architecture. + +**Alternatives considered:** + +- Keep a deprecated no-op `ChannelStateContext` shim: rejected because it preserves obsolete surface area without providing functional value. + +**Tradeoffs / Consequences:** +Any external code importing `ChannelStateProvider`/`useChannelStateContext` must migrate to instance-based APIs (`useChannel`, `ChannelInstanceProvider`). + +## Decision: Close remaining JS SDK parity gaps from commented `Channel.tsx` logic + +**Date:** 2026-03-05 +**Context:** +The commented legacy blocks in `src/components/Channel/Channel.tsx` were re-audited against `stream-chat-js` ownership (`MessagePaginator`, `MessageOperations`, `MessageDeliveryReporter`). + +Most behavior is already covered. One concrete SDK-level parity gap was identified: + +- `jumpToTheFirstUnreadMessage` lacks the legacy timestamp fallback when unread ids are unavailable. + +**Decision:** +Track a dedicated follow-up (`Task 19`) scoped to `stream-chat-js` for unread jump parity only: + +- implement timestamp-based unread jump fallback + inferred snapshot hydration; +- keep pre-send failed-message cleanup (`filterErrorMessages` legacy behavior) out of scope by default. +- do not reintroduce `beforeSend` or equivalent hook only for this parity task. + +**Reasoning:** +Unread jump fallback is a paginator data-ownership concern and should be solved in JS SDK, not React adapters. +Failed-message cleanup is a product-policy concern and can delete actionable retry items; without explicit product requirements, preserving retry visibility is safer. + +**Alternatives considered:** + +- Also port `filterErrorMessages`-style cleanup into MessageOperations: rejected as unnecessary and risky without explicit product requirement. +- Re-implement unread fallback in React only: rejected because it duplicates SDK responsibilities and risks divergence across SDK consumers. + +**Tradeoffs / Consequences:** +Unread fallback logic becomes more complex and requires explicit tests for edge cases (unknown ids, partially loaded windows, whole-channel-unread scenarios). +Pre-send cleanup remains intentionally unported; any future cleanup behavior must be introduced as an explicit, configurable policy. + +**Implementation Update (2026-03-05):** +Task 19 was implemented in `stream-chat-js`: + +- `MessagePaginator.jumpToTheFirstUnreadMessage` now performs `created_at_around` fallback when unread ids are missing but last-read timestamp exists; +- inferred unread boundaries hydrate `unreadStateSnapshot` after successful fallback jump; +- targeted tests were added in: + - `test/unit/pagination/paginators/MessagePaginator.test.ts` + - `test/unit/channel.test.js` (snapshot sync assertions for `channel.truncated` and `notification.mark_unread`). + +## Decision: Separate Channel bootstrap loading/error from message-list pagination errors + +**Date:** 2026-03-05 +**Context:** +`Channel.tsx` still needs to handle the initial bootstrap request for externally provided/slot-provided channel instances (`initializeOnMount` path when `channel.initialized === false`). +At the same time, pagination loading and failures after bootstrap belong to `MessageList` / `VirtualizedMessageList` runtime. + +**Decision:** +Define `Channel.tsx` loading/error UI ownership as initial bootstrap only: + +- show `LoadingIndicator` for first-page bootstrap request; +- show `LoadingErrorIndicator` for bootstrap failure; +- do not reuse this state for subsequent paginator/page loading failures. + +**Reasoning:** +This preserves clear ownership boundaries: + +- `Channel.tsx` handles channel instance readiness; +- list components handle pagination lifecycle and errors. + +It also avoids ambiguous behavior in slot-based layouts where global query flags may not map to the actively rendered instance. + +**Alternatives considered:** + +- Keep only `channelsQueryState` global loading/error for all channel readiness and pagination: rejected because instance-scoped bootstrap and list-level pagination can diverge. +- Put all loading/error behavior into lists: rejected because channel bootstrap may fail before list runtime is ready. + +**Tradeoffs / Consequences:** +`Channel.tsx` will carry a small local bootstrap request state. Tests must verify bootstrap-only rendering and ensure no regression where list pagination failures incorrectly render channel-level bootstrap indicators. + +**Implementation Update (2026-03-05):** +Task 20 implementation is complete: + +- `Channel.tsx` now tracks instance-scoped bootstrap state (`isBootstrapping`, `bootstrapError`) for initial `initializeOnMount` query only. +- bootstrap indicators are rendered from component context (`LoadingIndicator` / `LoadingErrorIndicator`) and are not reused for paginator/page failures. +- targeted regression tests were validated in `src/components/Channel/__tests__/Channel.test.js`: + - loading indicator during unresolved bootstrap; + - error indicator on bootstrap watch failure; + - no channel-bootstrap error UI for post-bootstrap pagination failure. + +## Decision: Rebase Channel message-mutation tests to instance APIs + +**Date:** 2026-03-05 +**Context:** +`Channel` message mutation tests still depended on context-era callback timing and legacy `channel.state` assumptions, which produced flaky failures after the instance/bootstrap migration. + +**Decision:** +Rebase the `Sending/removing/updating messages` test block in `Channel.test.js` to test instance-owned contracts directly via: + +- `channel.sendMessageWithLocalUpdate(...)` +- `channel.retrySendMessageWithLocalUpdate(...)` +- `channel.updateMessageWithLocalUpdate(...)` +- `channel.deleteMessageWithLocalUpdate(...)` +- `channel.messagePaginator.removeItem(...)` + +with assertions focused on paginator ingestion and request-handler invocation. + +**Reasoning:** +The migration goal is instance ownership. Test coverage should validate those APIs directly instead of preserving brittle context-wiring behavior. + +**Alternatives considered:** + +- Keep callback-context driven tests and add more waits: rejected because it keeps testing a deprecated integration path. + +**Tradeoffs / Consequences:** +Some assertions now validate `messagePaginator.ingestItem` calls (optimistic/received/failed transitions) rather than only DOM text outcomes, which better matches mutation ownership in `stream-chat-js`. + +## Decision: Temporarily skip legacy Channel integration jump/mark-read cases + +**Date:** 2026-03-05 +**Context:** +`Channel.test.js` still contained integration tests asserting reducer-era `jumpToFirstUnread` and mount-time mark-read internals that no longer map 1:1 to the instance-driven paginator/message-delivery runtime. + +**Decision:** +Keep migrated instance-contract assertions active, and temporarily skip legacy integration cases whose detailed behavior is now owned and unit-tested in `stream-chat-js` (`MessagePaginator` / `MessageDeliveryReporter`). + +**Reasoning:** +This avoids brittle React-level coupling to internal SDK flow while preserving coverage at the correct ownership layer. + +## Decision: Thread mount flow must be ThreadManager-aware + +**Date:** 2026-03-05 +**Context:** +`Thread.tsx` was calling `thread.reload()` on every mount. This caused redundant network fetches for thread instances that were already tracked by `client.threads` and/or already had first-page paginator data. +Also, `ChatViewNavigation.openThread({ channel, message })` always created a fresh `Thread` instance, even when `ThreadManager` already had one for the same parent message id. + +**Decision:** + +- Reuse existing thread instance from `client.threads.threadsById[message.id]` when opening a thread from `{ channel, message }`. +- In `Thread.tsx`, call `thread.reload()` on mount only for unmanaged threads whose `messagePaginator.state.items` is still `undefined` and not loading. +- Register unmanaged thread instances into `ThreadManager` only after first paginator page load succeeds (items loaded and no `lastQueryError`). + +**Reasoning:** +This keeps `ThreadManager` as the canonical instance registry, avoids duplicate thread instances, and removes unnecessary mount-time reloads while still bootstrapping brand-new ad-hoc thread instances. + +**Tradeoffs / Consequences:** + +- Initial ad-hoc thread registration is deferred until first successful paginator load. +- Thread list order can include newly opened unmanaged threads once registered; this is acceptable because they now represent active thread instances with loaded data. + +## Decision: First loaded message batch should trigger MessageList autoscroll + +**Date:** 2026-03-05 +**Context:** +When opening a thread, `Thread.tsx` can mount before replies are loaded. `MessageList` then transitions from empty to populated, but initial scroll behavior was tied to mount-only/layout triggers and could miss this data-availability transition. + +**Decision:** +Handle initial autoscroll in `useScrollLocationLogic` by scrolling once when messages become available (`messages.length > 0`) with a one-time guard per empty->non-empty cycle, so it still works if the scroll container ref resolves after data. + +**Reasoning:** +This keeps initial-scroll ownership in the top-level list scroll logic and avoids ad-hoc thread-specific effects. It directly models the required state transition: data becomes available for the first time. + +## Decision: `MessageAlsoSentInChannelIndicator` uses instance-owned paginators for both directions + +**Date:** 2026-03-06 +**Context:** +`MessageAlsoSentInChannelIndicator` still used legacy `queryParent` search flow and used ambient paginator selection for channel jumps, which could resolve to thread paginator in thread context. + +**Decision:** + +- `jumpToReplyInChannelMessages` must use `channel.messagePaginator`. +- For channel jumps while `activeView === 'threads'`, query the target channel before paginator jump. +- `jumpToReplyInThread` must resolve thread instance from `client.threads.threadsById[parentId]`; if absent, fetch via `client.getThread(parentId, { watch: true })`, then call `thread.messagePaginator.jumpToMessage(replyId)`. + +**Notes:** +Add TODO in component to replace unconditional pre-query with `ChannelListOrchestrator` loaded-instance check before querying channel. + +## Decision: Unread separator boundary is based on unread snapshot, not live read events + +**Date:** 2026-03-06 +**Context:** +`getIsFirstUnreadMessage` used live `channel.messageReceiptsTracker` read state, so `message.read` events could remove `UnreadMessagesSeparator` during the same mounted list session. + +**Decision:** +Use `messagePaginator.unreadStateSnapshot` fields (`unreadCount`, `firstUnreadMessageId`, `lastReadAt`, `lastReadMessageId`) as the unread boundary source for separator rendering and do not consult live read tracker state in `getIsFirstUnreadMessage`. + +**Reasoning:** +This keeps the separator stable while snapshot unread count remains > 0, showing users where the unread section starts. On remount, snapshot can start with unread count 0, so separator is not rendered. diff --git a/specs/message-pagination/plan.md b/specs/message-pagination/plan.md new file mode 100644 index 0000000000..7c4ed625a5 --- /dev/null +++ b/specs/message-pagination/plan.md @@ -0,0 +1,539 @@ +# Message Pagination Independence Plan + +## Worktree + +**Worktree path:** `/Users/martincupela/Projects/stream/chat/stream-chat-react` +**Branch:** `feat/message-paginator` +**Base branch:** `master` + +## Task overview + +Tasks are self-contained where possible; same-file tasks are chained explicitly. + +## Task 1: Audit Current Migration State Across Both Repos + +**File(s) to create/modify:** `specs/message-pagination/spec.md`, `specs/message-pagination/decisions.md`, `specs/message-pagination/state.json` + +**Dependencies:** None + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Verify current `stream-chat-react` and `stream-chat-js` pagination/thread coupling points. +- Record done vs missing milestones. +- Capture legacy-to-new API mapping contract. + +**Acceptance Criteria:** + +- [x] Done/missing summary is documented. +- [x] Replacement mapping table exists. + +## Task 2: Make `Thread.messagePaginator` Thread-Replies Aware (JS SDK) + +**File(s) to create/modify:** `/Users/martincupela/Projects/stream/chat/stream-chat-js/src/thread.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js/src/pagination/paginators/MessagePaginator.ts` (or companion adapters) + +**Dependencies:** Task 1 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Ensure thread paginator query path uses thread replies semantics rather than channel messages query. +- Keep `jumpTo*`, `toHead`, `toTail`, and state semantics coherent for thread datasets. + +**Acceptance Criteria:** + +- [x] Thread pagination never queries channel main-message dataset by mistake. +- [x] Thread paginator state (`items`, `hasMore*`, cursor) reflects replies. + +## Task 3: Remove `setActiveChannel` Routing from Chat Context/Chat Wrapper + +**File(s) to create/modify:** `src/context/ChatContext.tsx`, `src/components/Chat/Chat.tsx`, `src/components/Chat/hooks/useCreateChatContext.ts`, `src/components/Chat/hooks/useChat.ts`, `src/components/Channel/ChannelSlot.tsx`, `src/components/Channel/index.ts`, `src/components/ChannelList/ChannelList.tsx`, `src/components/ChannelPreview/ChannelPreview.tsx`, `src/components/ChannelPreview/ChannelPreviewMessenger.tsx`, `src/components/ChannelSearch/hooks/useChannelSearch.ts`, `src/experimental/Search/SearchResults/SearchResultItem.tsx`, `examples/vite/src/App.tsx`, `src/components/Chat/__tests__/Chat.test.js`, `src/components/ChatView/__tests__/ChatView.test.tsx`, `src/components/ChatView/__tests__/ChatViewNavigation.test.tsx`, `src/components/ChannelPreview/__tests__/ChannelPreviewMessenger.test.js`, `src/experimental/Search/__tests__/SearchResultItem.test.js`, `src/components/ChatView/ChatView.tsx` (integration checks) + +**Dependencies:** Task 1 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Remove active-display channel ownership from `ChatContext` (`setActiveChannel`, display `channel` routing path). +- Remove corresponding wiring from `Chat.tsx`. +- Wire channel selection in `ChannelList`/`ChannelPreview` to layout navigation (`openChannel`) instead of `setActiveChannel`. +- Wire channel selection in `ChannelSearch` and experimental Search result items to layout navigation (`openChannel`). +- Ensure active channel/thread display is driven by ChatView layout entities (`LayoutController` / `ChatViewNavigation`). +- Add `ChannelSlot` and migrate example app channel rendering to slot-based channel instance resolution. +- Update directly affected tests/mocks to the no-`setActiveChannel` contract. + +**Acceptance Criteria:** + +- [x] `ChatContextValue` no longer exposes `setActiveChannel`. +- [x] `ChatContextValue` no longer exposes `channel` as active-display state. +- [x] `Chat.tsx` no longer passes `setActiveChannel` into chat context creation. +- [x] Channel preview click/select in `ChannelList` opens via layout navigation API. +- [x] Channel display flow works from slot-bound entities rather than chat-context active channel state. +- [x] ChannelSearch and SearchResultItem channel selection use layout navigation API. +- [x] `ChannelSlot` exists and resolves channel instance from layout slot bindings. +- [x] Vite example app uses `ChannelSlot` instead of direct `Channel` component wiring. +- [x] TypeScript compiles after removing `setActiveChannel` from `ChatContextValue`. + +## Task 4: Remove Hardcoded `channel.messagePaginator` Usage from React Lists + +**File(s) to create/modify:** `src/components/MessageList/MessageList.tsx`, `src/components/MessageList/VirtualizedMessageList.tsx` + +**Dependencies:** Task 2, Task 3 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Route list pagination controls through `useMessagePaginator()`. +- Ensure thread lists and channel lists use the correct paginator instance in both list variants. + +**Acceptance Criteria:** + +- [x] No direct paginator direction calls (`toHead`/`toTail`) use `channel.messagePaginator` inside shared list components. +- [x] Thread list pagination behavior matches thread dataset. + +## Task 5: Remove Remaining Thread-Flow Dependence on Channel Context Actions/State + +**File(s) to create/modify:** `src/components/Message/Message.tsx`, `src/components/Message/hooks/*.ts`, `src/components/MessageActions/defaults.tsx`, `src/components/MessageInput/hooks/useSendMessageFn.ts`, `src/components/MessageInput/hooks/useUpdateMessageFn.ts`, `src/components/MessageList/hooks/useMarkRead.ts`, `src/components/MessageList/UnreadMessagesNotification.tsx`, `src/components/MessageList/UnreadMessagesSeparator.tsx`, `src/components/Attachment/hooks/useAudioController.ts`, `src/context/MessageBounceContext.tsx` + +**Dependencies:** Task 4 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Remove `ChannelActionContext` from message interaction runtime paths (thread and channel where migrated). +- Route notification writes through `client.notifications` APIs (`addSuccess`/`addError`) instead of context notification wrappers. +- Route optimistic message state updates through `messagePaginator` APIs (`ingestItem`, `removeItem`) instead of context `updateMessage`/`removeMessage`. +- Close parity gaps for legacy commented `Channel.tsx` action blocks (`jumpTo*`, unread UI snapshot handling, send/retry/edit optimistic reconciliation). +- Keep behavior parity for mark-read flow and message mutation pathways. +- Align React-layer send/retry/update pathways with `MessageOperations` ownership in `stream-chat-js` (`src/messageOperations/MessageOperations.ts`). +- Remove `thread.upsertReplyLocally` / `thread.deleteReplyLocally` calls from React runtime paths; keep those methods only as JS SDK compatibility surface. + +**Acceptance Criteria:** + +- [x] Thread subtree interactions work without requiring `ChannelActionProvider` or `ChannelStateProvider` in migrated flows. +- [x] Message interaction notifications use `client.notifications` in migrated flows. +- [x] Optimistic message update/remove paths use `messagePaginator` reconciliation APIs. +- [x] React runtime does not call `thread.upsertReplyLocally` / `thread.deleteReplyLocally`. +- [x] Coverage matrix items derived from commented legacy `Channel.tsx` actions are fully addressed or explicitly deprecated. +- [x] No runtime warnings from context hooks in sibling-render thread flow. + +## Task 6: Update and Rebase Tests to Instance-Driven Contract + +**File(s) to create/modify:** `src/components/Thread/__tests__/Thread.test.js`, `src/components/Channel/__tests__/Channel.test.js`, `src/components/MessageList/__tests__/MessageList.test.js`, `/Users/martincupela/Projects/stream/chat/stream-chat-js/test/unit/threads.test.ts` (as needed) + +**Dependencies:** Task 5 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Remove legacy assertions for context thread pagination controls. +- Add assertions for paginator-driven channel/thread list behavior. +- Add regression coverage for sibling `Channel` + `Thread` composition and no-`setActiveChannel` routing. +- Keep React tests at integration boundary; avoid emulating paginator internals already tested in `stream-chat-js`. + +**Acceptance Criteria:** + +- [x] Tests assert instance-driven pagination contract. +- [x] Legacy context-thread assumptions are removed or explicitly deprecated. +- [x] Tests no longer assume `ChatContext.setActiveChannel` for active display routing. + +## Task 7: Final Cleanup and Deprecation Notes + +**File(s) to create/modify:** `src/context/ChannelActionContext.tsx`, `src/context/ChannelStateContext.tsx`, `specs/message-pagination/spec.md`, `specs/message-pagination/decisions.md` + +**Dependencies:** Task 6 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Remove dead/commented legacy pagination API remnants. +- Document final migration/deprecation state. + +**Acceptance Criteria:** + +- [x] Channel context files no longer carry stale commented pagination/thread contracts. +- [x] Spec and decisions reflect final shipped behavior. + +## Task 8: Introduce Shared Slot-Entity Resolution Hook(s) + +**File(s) to create/modify:** `src/components/ChatView/hooks/useSlotEntity.ts`, `src/components/Channel/ChannelSlot.tsx`, `src/components/Thread/ThreadSlot.tsx`, `src/experimental/Search/SearchResults/SearchResultItem.tsx`, `src/components/ChatView/index.ts` (exports), `src/components/ChatView/ChatView.tsx` (if hook wiring requires) + +**Dependencies:** Task 3 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add `useSlotEntity({ kind, slot? })` in ChatView scope to centralize slot binding resolution. +- Add typed wrappers (`useSlotChannel`, `useSlotThread`) if this improves call-site safety. +- Migrate `ChannelSlot`/`ThreadSlot` and at least one current call site with duplicated narrowing (`SearchResultItem`) to the shared hook. +- Document single-entity-per-kind behavior and expectations for multiple rendered slots. + +**Acceptance Criteria:** + +- [x] Slot entity resolution is implemented once and reused by slot consumers. +- [x] `SearchResultItem` (and similar navigation consumers) no longer implement ad-hoc slot/channel narrowing inline. +- [x] Hook contract documents how first-match resolution works when multiple slots are visible. +- [x] TypeScript compiles with the shared hook API. + +## Task 9: Move ChannelList Hide Flow to Layout-Capacity Navigation + +**File(s) to create/modify:** `src/components/ChannelList/ChannelList.tsx`, `src/context/ChatContext.tsx`, `src/components/Chat/hooks/useCreateChatContext.ts`, `src/components/Chat/hooks/useChat.ts`, `src/components/Chat/Chat.tsx`, `src/components/ChannelList/__tests__/ChannelList.test.js`, `src/components/Chat/__tests__/Chat.test.js` + +**Dependencies:** Task 3 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Remove `closeMobileNav` from `ChatContext` contract and context creation wiring. +- On channel selection in `ChannelList`, call `hideChannelList` from `ChatViewNavigation` instead of context mobile-nav APIs. +- Gate channel-list auto-hide by layout capacity (hide only when opening channel replaces `channelList` slot binding). +- Update affected tests. + +**Acceptance Criteria:** + +- [x] `ChatContextValue` no longer exposes `closeMobileNav`. +- [x] ChannelList auto-hide is driven by layout-capacity signal (replacement of `channelList` slot binding), not user-agent checks. +- [x] ChannelList remains visible when channel opens without consuming the `channelList` slot. +- [x] TypeScript compiles after context and ChannelList changes. + +## Task 10: Move Mention Handlers from Channel-Level API to Message Props + +**File(s) to create/modify:** `src/components/Channel/Channel.tsx`, `src/context/ChannelActionContext.tsx`, `src/components/Message/types.ts`, `src/components/Message/hooks/useMentionsHandler.ts`, `src/components/Message/__tests__/Message.test.js`, `src/components/Message/hooks/__tests__/useMentionsHandler.test.js`, `src/components/Channel/__tests__/Channel.test.js`, `specs/message-pagination/spec.md`, `specs/message-pagination/decisions.md` + +**Dependencies:** Task 5 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Stop accepting `onMentionsClick` / `onMentionsHover` on `Channel` API surface. +- Remove mention handler fields from `ChannelActionContext` runtime contract. +- Treat mention handlers as `Message`-level behavior configured via `MessageProps`. +- Update mention handler hook/tests to avoid Channel action-context fallback. + +**Acceptance Criteria:** + +- [x] `ChannelProps` no longer define mention handler props. +- [x] `ChannelActionContextValue` no longer carries mention handlers. +- [x] `MessageProps` mention handlers are typed independently of `ChannelActionContext`. +- [x] Mention handling works from `Message` props only. +- [x] Tests covering mention behavior are migrated to Message-level contract. + +## Task 11: Add JS Instance-Level Delete Wrappers and Migrate React Delete Flow + +**File(s) to create/modify:** `/Users/martincupela/Projects/stream/chat/stream-chat-js/src/messageOperations/types.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js/src/messageOperations/MessageOperations.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js/src/channel.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js/src/thread.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js/test/unit/pagination/paginators/MessagePaginator.test.ts` (or dedicated messageOperations tests), `src/components/Message/hooks/useDeleteHandler.ts`, `src/components/Channel/Channel.tsx`, `specs/message-pagination/spec.md`, `specs/message-pagination/decisions.md` + +**Dependencies:** Task 5 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Extend JS instance operation contract with delete local-update wrapper on `Channel` and `Thread` (`deleteMessageWithLocalUpdate`). +- Add optional custom request handler support (`deleteMessageRequest`) in `ChannelInstanceConfig.requestHandlers` and per-call overrides. +- Define delete optimistic policy semantics (default behavior: execute request, then reconcile via paginator ingest/remove path based on response shape). +- Migrate React delete flow (`useDeleteHandler`) to instance wrappers instead of direct `client.deleteMessage(...)`. +- Remove `Channel` prop/context-era delete wrapper reliance once instance wrappers are used (`doDeleteMessageRequest` migration/deprecation path). + +**Acceptance Criteria:** + +- [x] `Channel` and `Thread` expose `deleteMessageWithLocalUpdate`. +- [x] Integrators can inject custom delete logic through channel config handler (`deleteMessageRequest`) and per-call override. +- [x] React message delete flow uses `(thread ?? channel).deleteMessageWithLocalUpdate(...)`. +- [x] Delete reconciliation is paginator-based and consistent with existing send/retry/update ownership model. +- [x] JS + React tests cover default delete path and custom request handler path. + +## Task 12: Port `markRead` out of `ChannelActionContext` (Channel + Thread Lists) + +**File(s) to create/modify:** `src/context/ChannelActionContext.tsx`, `src/components/Channel/Channel.tsx`, `src/components/MessageList/hooks/useMarkRead.ts`, `src/components/MessageList/__tests__/MessageList.test.js`, `src/components/MessageList/hooks/__tests__/useMarkRead.test.js`, `src/components/Channel/__tests__/Channel.test.js`, `specs/message-pagination/spec.md`, `specs/message-pagination/decisions.md` + +**Dependencies:** Task 5 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Remove `markRead` as a required runtime function exposed via `ChannelActionContext`. +- Standardize Channel list mark-read calls on `channel.markRead(...)` (delegating to `client.messageDeliveryReporter.markRead(channel, ...)`). +- Standardize Thread list mark-read calls on `thread.markAsRead(...)` (delegating to `client.messageDeliveryReporter.markRead(thread, ...)`). +- Preserve unread snapshot reconciliation semantics in React where required (for example `markReadOnMount` and UI unread indicators), but without Channel-action context coupling. +- Ensure parity for thread unread indicator actions and auto-mark-read hooks where thread list semantics apply. +- Define migration/deprecation path for `Channel` prop override `doMarkReadRequest` so customization aligns with instance-driven ownership. +- Add thread-level custom mark-read prop (`ThreadProps.doMarkReadRequest`) with thread-first arguments and wire it to instance request handlers. +- Keep customization parity between `Channel` and `Thread` for `doDeleteMessageRequest`, `doSendMessageRequest`, `doUpdateMessageRequest`, and `doMarkReadRequest`. + +**Acceptance Criteria:** + +- [x] Runtime Channel list mark-read flow no longer relies on `useChannelActionContext().markRead`. +- [x] Runtime Thread list mark-read flow no longer relies on `useChannelActionContext().markRead`. +- [x] `ChannelActionContextValue` does not require `markRead` for core message-list/read behavior. +- [x] Read reporting in migrated paths goes through `channel.markRead` / `thread.markRead` via `client.messageDeliveryReporter`. +- [x] Existing mark-read behavior parity is preserved (`markReadOnMount`, visibility/bottom-scroll conditions, unread count/UI updates). +- [x] Specs document channel/thread mark-read contract and thread request shape (`thread_id`) through reporter. +- [x] Custom mark-read overrides (`ChannelProps` + `ThreadProps`) are routed through instance `requestHandlers.markReadRequest`, not client-global reporter mutation. +- [x] `ThreadProps` offers custom request overrides matching Channel parity (`doDeleteMessageRequest`, `doSendMessageRequest`, `doUpdateMessageRequest`, `doMarkReadRequest`) and they are instance-scoped. + +## Task 13: Migrate `suppressAutoscroll` off Legacy Channel Reducer Semantics + +**File(s) to create/modify:** `src/components/MessageList/MessageList.tsx`, `src/components/MessageList/VirtualizedMessageList.tsx`, `src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx`, `src/components/MessageList/hooks/MessageList/useMessageListScrollManager.ts`, `src/components/MessageList/__tests__/MessageList.test.js`, `src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js`, `src/components/Channel/channelState.ts`, `specs/message-pagination/spec.md`, `specs/message-pagination/decisions.md` + +**Dependencies:** Task 4, Task 7 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Preserve current UX parity for pagination/autoscroll while removing reliance on `channelState.ts` `suppressAutoscroll` reducer behavior. +- Define list-local suppression signal tied to paginator lifecycle (`toTail`/loading older messages) and apply consistently to MessageList + VirtualizedMessageList. +- Remove stale references/comments that imply reducer/context ownership of suppression. +- Add regression tests that cover: +- loading older messages while scrolled up does not force scroll-to-bottom; +- normal auto-follow still happens for own/newest messages when suppression is inactive. + +**Acceptance Criteria:** + +- [x] `suppressAutoscroll` behavior is implemented from list/paginator lifecycle, not Channel reducer state. +- [x] MessageList and VirtualizedMessageList have parity for suppression behavior. +- [x] No runtime behavior depends on `channelState.ts` for autoscroll suppression. +- [x] Tests verify suppression and normal follow behavior in both list variants. + +## Task 14: Replace `useChannel()` Dependency on `ChannelStateContext` + +**File(s) to create/modify:** `src/context/useChannel.ts`, `src/context/ChannelStateContext.tsx` (transitional), `src/components/Channel/Channel.tsx`, `src/components/Channel/ChannelSlot.tsx`, `src/context/index.ts`, `specs/message-pagination/spec.md`, `specs/message-pagination/decisions.md` + +**Dependencies:** Task 7 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Introduce/standardize a dedicated channel-instance provider path for runtime channel resolution. +- Update `useChannel()` to resolve from thread first, then new channel-instance provider (not `ChannelStateContext`). +- Keep migration additive and backward compatible while downstream consumers are moved. + +**Acceptance Criteria:** + +- [x] `useChannel()` no longer reads from `ChannelStateContext`. +- [x] Channel runtime still resolves channel instance correctly in Channel and Thread compositions. +- [x] Transitional compatibility is documented. + +## Task 15: Migrate Message Lists Off `ChannelStateContext` + +**File(s) to create/modify:** `src/components/MessageList/MessageList.tsx`, `src/components/MessageList/VirtualizedMessageList.tsx`, `src/components/MessageList/UnreadMessagesNotification.tsx`, `src/components/MessageList/MessageListNotifications.tsx`, `src/components/Channel/Channel.tsx`, `specs/message-pagination/spec.md` + +**Dependencies:** Task 14 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Remove `useChannelStateContext(...)` consumption from list runtime components. +- Source channel instance through `useChannel()` and source list notification data via explicit props or client notification store. +- Keep MessageList and VirtualizedMessageList behavior parity. + +**Acceptance Criteria:** + +- [x] No runtime list component imports/uses `useChannelStateContext`. +- [x] Channel/unread actions still work in channel and thread list contexts. +- [x] MessageList notifications rendering has a non-ChannelStateContext source. + +## Task 16: Remove `ChannelStateProvider` Wiring From Channel Runtime + +**File(s) to create/modify:** `src/components/Channel/Channel.tsx`, `src/components/Channel/hooks/useCreateChannelStateContext.ts`, `src/context/ChannelStateContext.tsx`, `src/context/index.ts` + +**Dependencies:** Task 15 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Remove `ChannelStateProvider` wrapper from `Channel.tsx`. +- Remove obsolete context creation helper (`useCreateChannelStateContext`). +- Remove runtime exports of `ChannelStateContext` once no runtime consumers remain. + +**Acceptance Criteria:** + +- [x] `Channel.tsx` no longer renders `ChannelStateProvider`. +- [x] `useCreateChannelStateContext` is removed. +- [x] Runtime build/typecheck pass without `ChannelStateContext` in normal render path. + +## Task 17: Migrate Stories and Tests Off `ChannelStateProvider` + +**File(s) to create/modify:** `src/stories/*.stories.tsx` (affected), `src/components/**/__tests__/*` (affected wrappers), `specs/message-pagination/plan.md`, `specs/message-pagination/state.json` + +**Dependencies:** Task 16 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Replace `ChannelStateProvider` wrappers in stories/tests with current runtime provider composition. +- Update assertions/mocks to instance-driven channel sourcing. + +**Acceptance Criteria:** + +- [x] No active story/test scaffolding depends on `ChannelStateProvider`. +- [x] Updated tests verify instance-driven behavior. + +## Task 18: Delete `ChannelStateContext` and Legacy Channel Reducer + +**File(s) to create/modify:** `src/context/ChannelStateContext.tsx`, `src/components/Channel/channelState.ts`, `src/context/index.ts`, `specs/message-pagination/spec.md`, `specs/message-pagination/decisions.md` + +**Dependencies:** Task 13, Task 17 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Remove `ChannelStateContext.tsx` and final references. +- Remove `channelState.ts` once `suppressAutoscroll`/legacy reducer semantics are fully migrated. +- Finalize deprecation notes. + +**Acceptance Criteria:** + +- [x] `ChannelStateContext.tsx` is deleted. +- [x] `channelState.ts` is deleted. +- [x] Specs reflect final ChannelStateContext-free architecture. + +## Task 19: Port Remaining Commented `Channel.tsx` Legacy Semantics to JS SDK + +**File(s) to create/modify:** `/Users/martincupela/Projects/stream/chat/stream-chat-js/src/pagination/paginators/MessagePaginator.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js/test/unit/pagination/paginators/MessagePaginator.test.ts`, `specs/message-pagination/spec.md`, `specs/message-pagination/decisions.md`, `specs/message-pagination/state.json` + +**Dependencies:** Task 11, Task 12 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add legacy-equivalent fallback logic to `MessagePaginator.jumpToTheFirstUnreadMessage(...)` when unread ids are unavailable: + - query around `lastReadAt`/read-state timestamp (`created_at_around` descriptor); + - derive first unread candidate from returned page boundaries; + - persist inferred ids back into `unreadStateSnapshot`. +- Keep current unresolved-target contract (`boolean` return) and avoid introducing new notification side effects in paginator APIs. +- Explicitly do **not** port legacy `filterErrorMessages()` behavior; `beforeSend` was removed and no replacement hook is required for this migration. +- Add tests that pin unread-fallback parity outcomes. + +**Acceptance Criteria:** + +- [x] `jumpToTheFirstUnreadMessage` works even when both unread ids are missing but `last_read` timestamp exists. +- [x] Successful fallback jump hydrates `unreadStateSnapshot` inferred ids when previously unknown. +- [x] Specs/decisions explicitly document that pre-send failed-message cleanup is not ported as parity requirement. +- [x] JS SDK unit tests cover new unread-fallback semantics. + +## Task 20: Restore Instance-Scoped Initial Channel Bootstrap Loading/Error UI + +**File(s) to create/modify:** `src/components/Channel/Channel.tsx`, `src/components/Channel/__tests__/Channel.test.js`, `specs/message-pagination/spec.md`, `specs/message-pagination/decisions.md`, `specs/message-pagination/state.json` + +**Dependencies:** Task 3, Task 18 + +**Status:** done + +**Owner:** codex + +**Scope:** + +- Add explicit bootstrap request state in `Channel.tsx` for `initializeOnMount` flow of a provided channel instance. +- Render `LoadingIndicator` only during initial bootstrap load when `channel.initialized === false`. +- Render `LoadingErrorIndicator` only when the initial bootstrap request fails. +- Keep pagination/loading errors for subsequent pages out of `Channel.tsx`; those remain message-list responsibilities. +- Add tests that verify bootstrap loading/error rendering and no takeover of paginator/page loading failures. + +**Acceptance Criteria:** + +- [x] Uninitialized channel instance (`initializeOnMount=true`) shows `LoadingIndicator` until initial load resolves. +- [x] Initial load failure shows `LoadingErrorIndicator`. +- [x] After successful bootstrap, `Channel.tsx` renders children and no longer owns page-level loading/error states. +- [x] Message-list pagination failures are not surfaced through `Channel.tsx` bootstrap indicators. + +## Execution Order + +1. Phase 1 (completed): Task 1 +2. Phase 2 (parallel): Task 2 and Task 3 +3. Phase 3 (sequential): Task 4 -> Task 5 +4. Phase 4: Task 6 +5. Phase 5: Task 7 +6. Phase 6: Task 8 +7. Phase 7: Task 9 +8. Phase 8: Task 10 +9. Phase 9: Task 11 +10. Phase 10: Task 12 +11. Phase 11: Task 13 +12. Phase 12: Task 14 +13. Phase 13: Task 15 +14. Phase 14: Task 16 +15. Phase 15: Task 17 +16. Phase 16: Task 18 +17. Phase 17: Task 19 +18. Phase 18: Task 20 + +## File ownership summary + +| Task | Creates/Modifies | +| ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Task 1 | `specs/message-pagination/spec.md`, `specs/message-pagination/decisions.md`, `specs/message-pagination/state.json` | +| Task 2 | `/Users/martincupela/Projects/stream/chat/stream-chat-js/src/thread.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js/src/pagination/paginators/MessagePaginator.ts` | +| Task 3 | `src/context/ChatContext.tsx`, `src/components/Chat/Chat.tsx`, `src/components/Chat/hooks/useCreateChatContext.ts`, `src/components/Chat/hooks/useChat.ts`, `src/components/Channel/ChannelSlot.tsx`, `src/components/Channel/index.ts`, `src/components/ChannelList/ChannelList.tsx`, `src/components/ChannelPreview/ChannelPreview.tsx`, `src/components/ChannelPreview/ChannelPreviewMessenger.tsx`, `src/components/ChannelSearch/hooks/useChannelSearch.ts`, `src/experimental/Search/SearchResults/SearchResultItem.tsx`, `examples/vite/src/App.tsx`, `src/components/Chat/__tests__/Chat.test.js`, `src/components/ChatView/__tests__/ChatView.test.tsx`, `src/components/ChatView/__tests__/ChatViewNavigation.test.tsx`, `src/components/ChannelPreview/__tests__/ChannelPreviewMessenger.test.js`, `src/experimental/Search/__tests__/SearchResultItem.test.js`, `src/components/ChatView/ChatView.tsx` (integration checks) | +| Task 4 | `src/components/MessageList/MessageList.tsx`, `src/components/MessageList/VirtualizedMessageList.tsx` | +| Task 5 | `src/components/Message/Message.tsx`, `src/components/Message/hooks/*.ts`, `src/components/MessageInput/hooks/useSendMessageFn.ts`, `src/components/MessageInput/hooks/useUpdateMessageFn.ts`, `src/components/MessageList/hooks/useMarkRead.ts`, `src/context/MessageBounceContext.tsx` | +| Task 6 | `src/components/Thread/__tests__/Thread.test.js`, `src/components/Channel/__tests__/Channel.test.js`, `src/components/MessageList/__tests__/MessageList.test.js`, `/Users/martincupela/Projects/stream/chat/stream-chat-js/test/unit/threads.test.ts` | +| Task 7 | `src/context/ChannelActionContext.tsx`, `src/context/ChannelStateContext.tsx`, `specs/message-pagination/spec.md`, `specs/message-pagination/decisions.md` | +| Task 8 | `src/components/ChatView/hooks/useSlotEntity.ts`, `src/components/Channel/ChannelSlot.tsx`, `src/components/Thread/ThreadSlot.tsx`, `src/experimental/Search/SearchResults/SearchResultItem.tsx`, `src/components/ChatView/index.ts`, `src/components/ChatView/ChatView.tsx` | +| Task 9 | `src/components/ChannelList/ChannelList.tsx`, `src/context/ChatContext.tsx`, `src/components/Chat/hooks/useCreateChatContext.ts`, `src/components/Chat/hooks/useChat.ts`, `src/components/Chat/Chat.tsx`, `src/components/ChannelList/__tests__/ChannelList.test.js`, `src/components/Chat/__tests__/Chat.test.js` | +| Task 10 | `src/components/Channel/Channel.tsx`, `src/context/ChannelActionContext.tsx`, `src/components/Message/types.ts`, `src/components/Message/hooks/useMentionsHandler.ts`, `src/components/Message/__tests__/Message.test.js`, `src/components/Message/hooks/__tests__/useMentionsHandler.test.js`, `src/components/Channel/__tests__/Channel.test.js`, `specs/message-pagination/spec.md`, `specs/message-pagination/decisions.md` | +| Task 11 | `/Users/martincupela/Projects/stream/chat/stream-chat-js/src/messageOperations/types.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js/src/messageOperations/MessageOperations.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js/src/channel.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js/src/thread.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js/test/unit/*`, `src/components/Message/hooks/useDeleteHandler.ts`, `src/components/Channel/Channel.tsx`, `specs/message-pagination/spec.md`, `specs/message-pagination/decisions.md` | +| Task 12 | `src/context/ChannelActionContext.tsx`, `src/components/Channel/Channel.tsx`, `src/components/Channel/hooks/useChannelRequestHandlers.ts`, `src/components/Channel/hooks/__tests__/useChannelRequestHandlers.test.ts`, `src/components/Thread/Thread.tsx`, `src/components/Thread/hooks/useThreadRequestHandlers.ts`, `src/components/Thread/hooks/__tests__/useThreadRequestHandlers.test.ts`, `src/components/MessageList/hooks/useMarkRead.ts`, `src/components/MessageList/__tests__/MessageList.test.js`, `src/components/MessageList/hooks/__tests__/useMarkRead.test.js`, `src/components/Channel/__tests__/Channel.test.js`, `specs/message-pagination/spec.md`, `specs/message-pagination/decisions.md` | +| Task 13 | `src/components/MessageList/MessageList.tsx`, `src/components/MessageList/VirtualizedMessageList.tsx`, `src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx`, `src/components/MessageList/hooks/MessageList/useMessageListScrollManager.ts`, `src/components/MessageList/__tests__/MessageList.test.js`, `src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js`, `src/components/Channel/channelState.ts`, `specs/message-pagination/spec.md`, `specs/message-pagination/decisions.md` | +| Task 14 | `src/context/useChannel.ts`, `src/context/ChannelStateContext.tsx` (transitional), `src/components/Channel/Channel.tsx`, `src/components/Channel/ChannelSlot.tsx`, `src/context/index.ts`, `specs/message-pagination/spec.md`, `specs/message-pagination/decisions.md` | +| Task 15 | `src/components/MessageList/MessageList.tsx`, `src/components/MessageList/VirtualizedMessageList.tsx`, `src/components/MessageList/UnreadMessagesNotification.tsx`, `src/components/MessageList/MessageListNotifications.tsx`, `src/components/Channel/Channel.tsx`, `specs/message-pagination/spec.md` | +| Task 16 | `src/components/Channel/Channel.tsx`, `src/components/Channel/hooks/useCreateChannelStateContext.ts`, `src/context/ChannelStateContext.tsx`, `src/context/index.ts` | +| Task 17 | `src/stories/*.stories.tsx` (affected), `src/components/**/__tests__/*` (affected wrappers), `specs/message-pagination/plan.md`, `specs/message-pagination/state.json` | +| Task 18 | `src/context/ChannelStateContext.tsx`, `src/components/Channel/channelState.ts`, `src/context/index.ts`, `specs/message-pagination/spec.md`, `specs/message-pagination/decisions.md` | +| Task 19 | `/Users/martincupela/Projects/stream/chat/stream-chat-js/src/pagination/paginators/MessagePaginator.ts`, `/Users/martincupela/Projects/stream/chat/stream-chat-js/test/unit/pagination/paginators/MessagePaginator.test.ts`, `specs/message-pagination/spec.md`, `specs/message-pagination/decisions.md`, `specs/message-pagination/state.json` | +| Task 20 | `src/components/Channel/Channel.tsx`, `src/components/Channel/__tests__/Channel.test.js`, `specs/message-pagination/spec.md`, `specs/message-pagination/decisions.md`, `specs/message-pagination/state.json` | diff --git a/specs/message-pagination/spec.md b/specs/message-pagination/spec.md new file mode 100644 index 0000000000..d826e866af --- /dev/null +++ b/specs/message-pagination/spec.md @@ -0,0 +1,314 @@ +# Message Pagination Independence Spec + +## Problem Statement + +`stream-chat-react` is mid-migration from Channel-context-driven pagination/thread controls to instance-driven APIs backed by `MessagePaginator`. + +The target architecture is: + +- `stream-chat-js` `Channel` and `Thread` instances both expose `messagePaginator` as the pagination source of truth. +- `Channel.tsx` and `Thread.tsx` can be rendered as siblings. +- Thread/message UI behavior no longer depends on `ChannelActionContext` / `ChannelStateContext` pagination-thread fields. +- Active `Channel` / `Thread` display instances are sourced from ChatView layout bindings (via `LayoutController` and `ChatViewNavigation`), not from `ChatContext.setActiveChannel`. + +## Goal + +Define a concrete replacement contract from legacy context APIs to `Channel` / `Thread` instance APIs (especially `MessagePaginator`), and document what is already completed versus what is still missing. + +Explicitly for message interactions: + +- remove `ChannelActionContext` as a required runtime API surface; +- send user-facing notifications via `client.notifications` (`NotificationManager`, `StreamChat.notifications`); +- perform optimistic message state reconciliation (`upsert` / `remove`) through `MessagePaginator` API, not via ChannelActionContext wrappers. + +## Non-Goals + +- Immediate removal of all Channel contexts from the SDK in one step. +- Introducing new backend endpoints. + +## Current State Summary + +### Done + +- `stream-chat-js`: +- `Channel` exposes `messagePaginator`. +- `Thread` exposes `messagePaginator`. +- `Channel.messageOperations` / `Thread.messageOperations` use `MessagePaginator` ingest/get for optimistic `send/retry/update`. +- `Thread` supports minimal construction (`client + channel + parentMessage`) and reload/hydration flow. +- `Thread.messagePaginator` is thread-aware (`parentMessageId`) and queries replies dataset. +- `Thread` `message.new` subscription now ingests replies into `thread.messagePaginator` and updates thread-local `replyCount` only (channel metadata ownership stays with `Channel`). +- `Channel` `message.updated` / `message.undeleted` now ingest into `channel.messagePaginator`, keeping parent-message metadata (`reply_count`, `thread_participants`) synchronized for channel-list UI. +- `ChatViewNavigation.openThread({ channel, message })` reuses `client.threads.threadsById[message.id]` when available instead of always constructing a new thread instance. +- `Thread.tsx` no longer forces `thread.reload()` on every mount; unmanaged threads reload only when their paginator has no first-page data yet. +- `Thread.tsx` registers unmanaged thread instances into `ThreadManager` only after the first paginator page has loaded successfully (`messagePaginator.state.items !== undefined` and no `lastQueryError`). + +- `stream-chat-react`: +- `ChannelActionContext` already removed legacy pagination/thread methods (`jumpTo*`, `loadMore*`, `openThread/closeThread`, `loadMoreThread`) from its public value type. +- `ChannelActionContext` is removed from runtime/public exports and `Channel` no longer wraps children with `ChannelActionProvider`. +- `ThreadProvider` is thread-only (no implicit `` wrapper). +- `Thread.tsx` is thread-instance driven and closes via `useChatViewNavigation().closeThread()` (+ `thread.deactivate()` fallback). +- `MessageActions` and `MessageSimple` already use `useChatViewNavigation().openThread(...)`. +- `QuotedMessage`, `MessageAlsoSentInChannelIndicator`, `MessageList`, and `VirtualizedMessageList` already use paginator APIs in at least part of the flow. +- Message mutation handlers (`action/delete/pin/reaction`, error-message delete action, and Channel wrappers) reconcile optimistic updates/removals through `messagePaginator`. +- Message notification writes in migrated paths use `client.notifications`. +- `stream-chat-react` message runtime handlers no longer call `thread.upsertReplyLocally` / `thread.deleteReplyLocally`. +- Mention handling migration is complete: mention handlers are configured at `Message` level and no longer exposed via `Channel`/`ChannelActionContext`. +- Channel message-list read actions already call instance APIs directly (`channel.markRead()` / `thread.markRead()` in notification/separator components). +- Channel message-mutation tests (`send/retry/update/delete/remove` block) were migrated to call instance APIs (`*WithLocalUpdate`, `messagePaginator.removeItem`) instead of legacy Channel action-context callbacks. +- `Channel.test.js` coverage is intentionally scoped to React integration concerns; legacy deep pagination/unread algorithm scenarios are skipped there and owned by `stream-chat-js` paginator/message-delivery unit tests. + +### Missing + +- Channel test coverage was re-scoped to current component ownership (bootstrap/render/event wiring), while paginator/unread algorithms remain covered in `stream-chat-js` unit tests. +- Mention-handler migration is in progress; remaining docs/tests need full Message-level contract alignment. +- `suppressAutoscroll` behavior is still effectively specified by legacy `channelState.ts` reducer semantics and is not yet expressed as an instance-owned list contract. +- Channel bootstrap UI contract is implemented and covered by targeted Channel bootstrap tests (Task 20). +- `stream-chat-js` does not yet expose `deleteMessageWithLocalUpdate` wrappers on `Channel` / `Thread` equivalent to existing `send/retry/update` local-update APIs. +- `stream-chat-js` `ChannelInstanceConfig.requestHandlers` does not include `deleteMessageRequest`, so integrators cannot inject custom delete logic through instance configuration. +- `stream-chat-react` still carries `doDeleteMessageRequest` in `Channel` prop/context-era flow; this should migrate to instance-level delete wrapper usage. +- Story and docs references still need full pass to remove legacy context terminology from comments/examples where not yet migrated. + +### Completed ChannelStateContext Removal + +- `ChannelStateContext.tsx` is deleted. +- `channelState.ts` is deleted. +- `useChannel()` resolves via `Thread` or `ChannelInstanceContext`. +- `MessageList` and `VirtualizedMessageList` consume notification/unread state from paginator/client stores, not channel-state context. +- stories/tests were migrated off `ChannelStateProvider`. + +## Instance Ownership Contract (Layout First) + +- `LayoutController.state.slotBindings` is the source of truth for which `Channel`/`Thread` instance is displayed in each slot. +- `ChatViewNavigation` is the public imperative API to bind/open/close Channel and Thread entities. +- `Channel`/`Thread` renderers consume instances from layout bindings (for example through slot renderers / slot adapters), not from `ChatContext.channel`. +- Slot adapters are first-class integration points: +- `ThreadSlot` resolves thread instances from layout slot bindings. +- `ChannelSlot` resolves channel instances from layout slot bindings. +- Channel selection UI (`ChannelList` / `ChannelPreview`) opens channels through `ChatViewNavigation.openChannel(...)` (or equivalent `layoutController.open(...)` wrapper), not `setActiveChannel(...)`. +- `ChatContext` must no longer own active-entity selection: +- remove `setActiveChannel` from `src/context/ChatContext.tsx`. +- remove `setActiveChannel` wiring and usage from `src/components/Chat/Chat.tsx`. +- remove `closeMobileNav` routing from `ChatContext` where channel-list visibility is now layout-driven. +- `ChatContext` remains for shared infra concerns (client, theme, search controller, nav flags, etc.), not active channel routing. +- `ChannelList` selection should hide the list through `useChatViewNavigation().hideChannelList(...)` only when opening a channel consumes/replaces the slot currently bound to `channelList` (no spare slot capacity to keep both panes visible). + +## Slot Entity Resolution Pattern + +- Repeated slot-entity resolution logic should be centralized into a shared hook in ChatView scope: +- `useSlotEntity({ kind, slot? })` for generic entity lookup from layout bindings. +- Optional typed wrappers: +- `useSlotChannel({ slot? })` +- `useSlotThread({ slot? })` +- Behavior contract: +- if `slot` is provided, resolve from that slot only; +- if `slot` is omitted, scan `[activeSlot, ...availableSlots]` and return the first matching entity by `kind`. +- Consumers such as `ChannelSlot`, `ThreadSlot`, and search result components should prefer this shared hook to avoid duplicated narrowing logic and inconsistent behavior. + +### ChannelSlot Multi-Instance Expectations + +- `ChannelSlot` is a single-slot adapter, not a multi-channel distributor. +- One rendered `` maps to at most one channel entity bound to that slot. +- To render multiple channel instances simultaneously, render multiple `ChannelSlot` components with different slot ids. +- If `slot` is omitted, fallback behavior uses first match from `[activeSlot, ...availableSlots]`; this is convenience behavior and should not be used for deterministic multi-pane layouts. +- Integrators that need deterministic multi-pane channel placement should always provide explicit `slot` to each `ChannelSlot`. + +## Legacy to New API Contract + +| Legacy API (context era) | Replacement API (instance era) | Status | +| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------- | +| `setActiveChannel(channel)` (`ChatContext`) | `useChatViewNavigation().openChannel(channel, { slot? })` (or low-level `layoutController.open(...)`) | missing | +| `addNotification(text, type)` (`ChannelActionContext`) | `channel.getClient().notifications.addSuccess/addError(...)` | done | +| `markRead(options?)` (`ChannelActionContext`) | `channel.markRead(options?)` or `thread.markAsRead(options?)` -> `client.messageDeliveryReporter.markRead(collection, options?)` | partial | +| `deleteMessage(message, options?)` (`ChannelActionContext`) | `(thread ?? channel).deleteMessageWithLocalUpdate(...)` (JS instance wrapper) | missing | +| `updateMessage(message)` (`ChannelActionContext`) | `messagePaginator.ingestItem(message)` | partial | +| `removeMessage(message)` (`ChannelActionContext`) | `messagePaginator.removeItem({ item: message })` (or `{ id: message.id }`) | partial | +| `openThread(message, event?)` | `useChatViewNavigation().openThread({ channel, message })` | done | +| `closeThread()` | `useChatViewNavigation().closeThread()` (+ `thread.deactivate()` fallback in `Thread.tsx`) | done | +| `jumpToMessage(messageId, limit?)` | `(thread ?? channel).messagePaginator.jumpToMessage(messageId, { pageSize })` | partial | +| `jumpToLatestMessage()` | `(thread ?? channel).messagePaginator.jumpToTheLatestMessage()` | partial | +| `jumpToFirstUnreadMessage(limit?)` | `channel.messagePaginator.jumpToTheFirstUnreadMessage({ pageSize })` | partial | +| `loadMore(limit?)` (older direction) | `(thread ?? channel).messagePaginator.toTail()` | partial | +| `loadMoreNewer(limit?)` | `(thread ?? channel).messagePaginator.toHead()` | partial | +| `loadMoreThread()` | `thread.messagePaginator.toTail()/toHead()` (or transitional `thread.loadPrevPage/loadNextPage`) | missing | +| `threadMessages` | `thread.messagePaginator.state.items` (or transitional `thread.state.replies`) | partial | +| `threadHasMore` | `thread.messagePaginator.state.hasMoreTail` | missing | +| `threadLoadingMore` | `thread.messagePaginator.state.isLoading` | missing | + +## MessageOperations Alignment + +`stream-chat-js` owns optimistic state semantics in `src/messageOperations/MessageOperations.ts`. + +- `send/retry/update` optimistic lifecycle is funneled through `channel.messageOperations` / `thread.messageOperations`. +- delete flow is currently outside this lifecycle and should be aligned with the same instance-owned contract. +- Both instances are configured with: +- `ingest: (m) => messagePaginator.ingestItem(m)` +- `get: (id) => messagePaginator.getItem(id)` +- React layer should call instance APIs (`sendMessageWithLocalUpdate`, `retrySendMessageWithLocalUpdate`, `updateMessageWithLocalUpdate`) and use `messagePaginator` for local optimistic reconcile in custom flows (`ingestItem`/`removeItem`). +- In `stream-chat-react`, do not call `thread.upsertReplyLocally` / `thread.deleteReplyLocally`; keep thread message-state reconcile strictly paginator-driven. +- Add and consume `deleteMessageWithLocalUpdate` on `Channel` / `Thread` so delete flow follows the same instance contract and supports custom request handler injection. + +## Message Focus Signal Contract + +`MessagePaginator` publishes a reactive `messageFocusSignal` state for jump/navigation intents. + +- `messageId: string` + : target message that became the focus target. +- `reason: 'jump-to-message' | 'jump-to-first-unread' | 'jump-to-latest'` + : semantic cause of focus, so UI can differentiate behaviors. +- `token: number` + : monotonically increasing unique signal id; consumers can distinguish repeated focus events for the same `messageId` and ignore stale clears. +- `createdAt: number` + : timestamp (`Date.now()`) when signal was emitted. +- `ttlMs: number` + : advisory signal lifetime; signal auto-clears after TTL. + +State semantics: + +- store shape is `{ signal: MessageFocusSignal | null }`; +- signal is emitted by paginator jump APIs unless explicitly suppressed; +- clear operation supports token-aware stale-timer protection. + +UI consumption semantics: + +- selectors should return object-shaped values for stable store subscription patterns (for example `{ messageFocusSignal: state.signal }`); +- message focus in list UIs is single-source and paginator-owned; `VirtualizedMessageList` must not accept a parallel `highlightedMessageId` prop source; +- lists may map focus signal to visual highlight, scroll-to-center, animation, or any other focus affordance; +- naming stays generic (`messageFocusSignal`) to describe the event, not a specific visual outcome. + +## Mark Read Contract (Channel + Thread) + +- Primary contract for channel lists is `channel.markRead(options?)`; `Channel.markRead` delegates to `client.messageDeliveryReporter.markRead(channel, options?)`. +- Primary contract for thread lists is `thread.markRead(options?)`; `Thread.markRead` delegates to `client.messageDeliveryReporter.markRead(thread, options?)`. +- `thread.markAsRead(options?)` remains as a deprecated alias for backward compatibility. +- `MessageDeliveryReporter.markRead` clears tracked delivery candidates for the collection after the request path and centralizes Channel/Thread read reporting semantics. +- `MessageDeliveryReporter` resolves custom mark-read handlers per collection type: +- channel collections use channel `configState.requestHandlers.markReadRequest`; +- thread collections use thread `configState.requestHandlers.markReadRequest`. +- Default fallback for both is `channel.markAsReadRequest(...)`, with `thread_id` enrichment when collection is thread. +- React unread UI flows should call these instance methods directly and not depend on `ChannelActionContext.markRead`. +- Custom mark-read override contract is instance-scoped (not client-global): +- `Channel` may accept `doMarkReadRequest(channel, options?)`. +- `Thread` may accept `doMarkReadRequest({ thread, options? })`. +- channel handler is channel-only (no `thread` argument); thread-specific customization belongs to thread handler. +- channel override is wired into `channel.configState.requestHandlers.markReadRequest`; +- thread override is wired into `thread.configState.requestHandlers.markReadRequest`. +- `MessageDeliveryReporter` remains immutable at runtime; customization happens through per-instance request handlers to avoid cross-slot/channel interference. +- Thread keeps custom-request parity with Channel for message operations: +- `doDeleteMessageRequest`, `doSendMessageRequest`, `doUpdateMessageRequest`, `doMarkReadRequest`. +- these thread overrides are scoped to the active thread and chained with existing channel request handlers. + +## Autoscroll Suppression Contract (`suppressAutoscroll`) + +- Legacy ownership lived in `src/components/Channel/channelState.ts` reducer (`setLoadingMore` => `suppressAutoscroll: true` while loading older pages). +- In the instance-driven model, autoscroll suppression belongs to message-list scroll manager behavior, not Channel context reducer state. +- Target contract: +- when paginating older messages (`messagePaginator.toTail()` in reverse list mode), temporary auto-scroll-to-bottom must be suppressed; +- suppression clears once pagination settles and normal bottom-follow behavior resumes; +- behavior must be identical in `MessageList` and `VirtualizedMessageList`. +- The suppression signal should come from paginator/list runtime state (or dedicated list-local state), not `ChannelStateContext`. +- Migration should remove any remaining dependency on `channelState.ts` semantics for this behavior and document the replacement explicitly. + +## Channel Bootstrap Contract (Initial Page Only) + +- `Channel.tsx` must own bootstrap loading/error UI only for initial channel initialization/watch query: + - apply when `initializeOnMount === true` and `channel.initialized === false`; + - render `LoadingIndicator` while initial request is in progress; + - render `LoadingErrorIndicator` when initial request fails. +- This contract is scoped to the first-page bootstrap only. +- Pagination/loading failures after initial load are message-list concerns and must stay handled in `MessageList` / `VirtualizedMessageList`. +- Bootstrap UI must be instance-scoped (the concrete rendered channel), not only global `channelsQueryState`, to avoid ambiguity with slot-driven multi-entity layouts. + +## ChannelStateContext Removal Contract + +- Completed in this branch: +- channel instance sourcing now uses `ChannelInstanceContext` (`useChannel()` thread-first fallback preserved); +- list/runtime consumers no longer use `useChannelStateContext`; +- `ChannelStateProvider` was removed from runtime and test/story scaffolding; +- legacy reducer file `src/components/Channel/channelState.ts` was removed. + +## Coverage Matrix: `Channel.tsx` Commented Legacy Actions + +The following legacy commented flows in `src/components/Channel/Channel.tsx` must be fully covered by instance-driven replacements: + +| Legacy commented flow in `Channel.tsx` | Instance-era replacement contract | Coverage status | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | --------------- | +| `loadMore(limit?)` | `messagePaginator.toTail()` | done | +| `loadMoreNewer(limit?)` | `messagePaginator.toHead()` | done | +| `jumpToMessage(messageId, limit?, highlightDuration?)` | `messagePaginator.jumpToMessage(messageId, { pageSize })` + UI highlight wiring in list layer | partial | +| `jumpToLatestMessage()` | `messagePaginator.jumpToTheLatestMessage()` | partial | +| `jumpToFirstUnreadMessage(limit?, highlightDuration?)` | `messagePaginator.jumpToTheFirstUnreadMessage({ pageSize })` backed by unread snapshot/read state | partial | +| `setChannelUnreadUiState(...)` | `messagePaginator.unreadStateSnapshot` as unread UI source of truth | partial | +| `addNotification(text, type)` | `client.notifications.addSuccess/addError(...)` | done | +| `deleteMessage(message, options?)` | `deleteMessageWithLocalUpdate` via JS instance wrapper + optional `deleteMessageRequest` handler | missing | +| `updateMessage(message)` optimistic path | `messagePaginator.ingestItem(message)` | partial | +| `removeMessage(message)` optimistic path | `messagePaginator.removeItem({ item: message })` or `{ id: message.id }` | partial | +| `sendMessage(...)` optimistic reconciliation | `sendMessageWithLocalUpdate` + paginator-driven optimistic state | partial | +| `retrySendMessage(...)` optimistic reconciliation | `retrySendMessageWithLocalUpdate` + paginator-driven optimistic state | partial | +| `editMessage(...)` optimistic reconciliation | `updateMessageWithLocalUpdate` + paginator-driven optimistic state | partial | + +## Channel.tsx Commented Logic: JS SDK Parity Audit (2026-03-05) + +### Already Covered in `stream-chat-js` + +- `loadMore` / `loadMoreNewer` semantics are covered by `MessagePaginator.toTail()` / `toHead()`. +- `jumpToMessage` and `jumpToLatestMessage` are covered by: + - `MessagePaginator.jumpToMessage(...)` + - `MessagePaginator.jumpToTheLatestMessage(...)` +- highlight signal behavior is covered by `MessagePaginator.messageFocusSignal`. +- unread snapshot ownership is covered by `MessagePaginator.unreadStateSnapshot`. +- `notification.mark_unread` and `channel.truncated` unread-snapshot synchronization is already implemented in `Channel._handleChannelEvent`. +- send/retry/update optimistic reconciliation (including duplicate message handling and newer-vs-stale response protection) is covered by `MessageOperations` + `MessageOperationStatePolicy`. +- delete optimistic reconciliation is covered by `deleteMessageWithLocalUpdate` wrappers on both `Channel` and `Thread`. +- mark-read ownership is covered by `MessageDeliveryReporter.markRead` for both channel and thread, with collection-specific custom handlers. + +### Remaining JS SDK Gaps (Not Yet at Legacy Parity) + +- No open JS SDK gaps remain from the commented `Channel.tsx` parity audit. +- `jumpToTheFirstUnreadMessage` now supports timestamp fallback (`created_at_around`) when unread ids are missing and hydrates `unreadStateSnapshot` with inferred unread boundaries. + +### Explicitly Not Ported Blindly + +- Legacy `channel.state.filterErrorMessages()` pre-send cleanup is **not** treated as required parity by default. +- Reason: modern paginator/message-operations flow keeps failed messages as actionable retry items; blind cleanup can be destructive UX. +- Any reintroduction must be driven by explicit product behavior requirements (for example configurable retry-queue pruning), not legacy-code carryover. +- `beforeSend` is removed from `stream-chat-js`; this migration does **not** reintroduce an equivalent hook only to preserve legacy cleanup behavior. + +### Necessity Filter for Remaining Porting + +- Required parity that was ported in JS SDK: + - `jumpToTheFirstUnreadMessage` timestamp fallback (`created_at_around`) when unread ids are unknown. + - unread snapshot hydration after fallback (populate inferred first/last unread ids). +- Not required parity to port (documented deprecations/non-goals): + - pre-send failed-message cleanup (`filterErrorMessages`-style behavior); + - reducer-era loading/error flags and throttled reducer copy semantics; + - document-title side effects (`activeUnreadHandler` ownership remains React/UI layer). + +### Explicitly Out of JS SDK Scope (React/UI Ownership) + +- document title updates (`activeUnreadHandler` / `document.title`) stay in React. +- loading/error spinner state previously in local reducer (`state.loading`, `state.error`) stays in React rendering concerns. +- reducer-specific throttling/debouncing knobs (`loadMoreFinished`, `throttledCopyStateFromChannel`) are replaced by paginator/store semantics and are not 1:1 JS SDK responsibilities. + +## Acceptance Criteria + +- `Thread` and `Channel` pagination use the same conceptual API surface (`MessagePaginator`) without Channel-context thread paging fallbacks. +- `MessageList` and `VirtualizedMessageList` select paginator instance from `useMessagePaginator()` so thread lists never use `channel.messagePaginator` by accident. +- Thread subtree message actions and message input flows do not require `ChannelActionContext` / `ChannelStateContext` to function. +- `ChannelActionContext` is no longer required by message interaction flows (notification, optimistic update, retry/delete/update/pin/reaction/action handlers). +- Notification writes in migrated flows use `client.notifications` APIs directly. +- Optimistic message state updates in migrated flows reconcile via `messagePaginator` (`ingestItem` / `removeItem`) instead of ChannelActionContext update/remove wrappers. +- `stream-chat-react` runtime message flows do not call `Thread.upsertReplyLocally` / `Thread.deleteReplyLocally`. +- Message delete flow is served by `Channel` / `Thread` instance wrappers (`deleteMessageWithLocalUpdate`) with optional custom `deleteMessageRequest`. +- Channel and thread message-list mark-read flows do not depend on `ChannelActionContext.markRead`; they call `channel.markRead` / `thread.markAsRead` (message-delivery-reporter-backed) directly. +- `ChatContext` no longer exposes `setActiveChannel` or active `channel` as the display-routing mechanism. +- Channel/thread visibility is driven by entities bound in `LayoutController` (`slotBindings`) and manipulated via `ChatViewNavigation`. +- Clicking a channel preview in `ChannelList` binds/opens that channel in layout state (slot binding), and active preview state is derived from layout-bound channel identity. +- Example apps using ChatView should render channel/thread workspaces via slot adapters (`ChannelSlot` / `ThreadSlot`) rather than relying on ChatContext active-channel state. +- Slot-aware consumers use the shared slot-entity hook(s) instead of implementing custom slot scanning logic inline. +- Legacy tests are updated to verify instance-driven behavior (and fail on context-thread pagination regressions). + +## Constraints + +- Maintain backward compatibility where possible with additive/deprecation-first changes. +- Keep API import boundaries (`stream-chat` imports by package name). +- Keep behavior compatible with existing ChatView navigation model. diff --git a/specs/message-pagination/state.json b/specs/message-pagination/state.json new file mode 100644 index 0000000000..95ff0af9ce --- /dev/null +++ b/specs/message-pagination/state.json @@ -0,0 +1,33 @@ +{ + "tasks": { + "task-1-audit-current-migration-state-across-both-repos": "done", + "task-2-make-thread-messagepaginator-thread-replies-aware-js-sdk": "done", + "task-3-remove-setactivechannel-routing-from-chat-context-chat-wrapper": "done", + "task-4-remove-hardcoded-channel-messagepaginator-usage-from-react-lists": "done", + "task-5-remove-remaining-thread-flow-dependence-on-channel-context-actions-state": "done", + "task-6-update-and-rebase-tests-to-instance-driven-contract": "done", + "task-7-final-cleanup-and-deprecation-notes": "done", + "task-8-introduce-shared-slot-entity-resolution-hooks": "done", + "task-9-move-channellist-mobile-hide-flow-to-chatview-navigation": "done", + "task-10-move-mention-handlers-from-channel-level-api-to-message-props": "done", + "task-11-add-js-instance-level-delete-wrappers-and-migrate-react-delete-flow": "done", + "task-12-port-markread-out-of-channelactioncontext-channel-thread-lists": "done", + "task-13-migrate-suppressautoscroll-off-legacy-channel-reducer-semantics": "done", + "task-14-replace-usechannel-dependency-on-channelstatecontext": "done", + "task-15-migrate-message-lists-off-channelstatecontext": "done", + "task-16-remove-channelstateprovider-wiring-from-channel-runtime": "done", + "task-17-migrate-stories-and-tests-off-channelstateprovider": "done", + "task-18-delete-channelstatecontext-and-legacy-channel-reducer": "done", + "task-19-port-remaining-commented-channel-legacy-semantics-to-js-sdk": "done", + "task-20-restore-instance-scoped-initial-channel-bootstrap-loading-error-ui": "done" + }, + "flags": { + "blocked": false, + "needs-review": false + }, + "meta": { + "last_updated": "2026-03-05", + "worktree": "/Users/martincupela/Projects/stream/chat/stream-chat-react", + "branch": "feat/message-paginator" + } +} diff --git a/src/components/AIStateIndicator/AIStateIndicator.tsx b/src/components/AIStateIndicator/AIStateIndicator.tsx index 711f4fe36a..5405a847a0 100644 --- a/src/components/AIStateIndicator/AIStateIndicator.tsx +++ b/src/components/AIStateIndicator/AIStateIndicator.tsx @@ -3,7 +3,7 @@ import type { Channel } from 'stream-chat'; import { AIStates, useAIState } from './hooks/useAIState'; -import { useChannelStateContext, useTranslationContext } from '../../context'; +import { useChannel, useTranslationContext } from '../../context'; export type AIStateIndicatorProps = { channel?: Channel; @@ -13,7 +13,7 @@ export const AIStateIndicator = ({ channel: channelFromProps, }: AIStateIndicatorProps) => { const { t } = useTranslationContext(); - const { channel: channelFromContext } = useChannelStateContext('AIStateIndicator'); + const channelFromContext = useChannel(); const channel = channelFromProps || channelFromContext; const { aiState } = useAIState(channel); const allowedStates = { diff --git a/src/components/Attachment/Attachment.tsx b/src/components/Attachment/Attachment.tsx index 2f38d3236b..beacdc1364 100644 --- a/src/components/Attachment/Attachment.tsx +++ b/src/components/Attachment/Attachment.tsx @@ -1,5 +1,9 @@ import React, { useMemo } from 'react'; -import type { SharedLocationResponse, Attachment as StreamAttachment } from 'stream-chat'; +import type { + GiphyVersions, + SharedLocationResponse, + Attachment as StreamAttachment, +} from 'stream-chat'; import { isAudioAttachment, isFileAttachment, @@ -37,6 +41,12 @@ import type { GiphyAttachmentProps } from './Giphy'; import type { VideoPlayerProps } from '../VideoPlayer'; import type { ModalGalleryProps } from './ModalGallery'; import type { ImageProps } from './Image'; +import { + AttachmentContextProvider, + defaultAttachmentContextValue, + type ImageAttachmentConfiguration, + type VideoAttachmentConfiguration, +} from './AttachmentContext'; export const ATTACHMENT_GROUPS_ORDER = [ 'media', @@ -47,6 +57,17 @@ export const ATTACHMENT_GROUPS_ORDER = [ 'unsupported', ] as const; +export type ImageAttachmentSizeHandler = ( + attachment: StreamAttachment, + element: HTMLElement, +) => ImageAttachmentConfiguration; + +export type VideoAttachmentSizeHandler = ( + attachment: StreamAttachment, + element: HTMLElement, + shouldGenerateVideoThumbnail: boolean, +) => VideoAttachmentConfiguration; + export type AttachmentProps = { /** The message attachments to render, see [attachment structure](https://getstream.io/chat/docs/javascript/message_format/?language=javascript) **/ attachments: (StreamAttachment | SharedLocationResponse)[]; @@ -75,12 +96,20 @@ export type AttachmentProps = { Giphy?: React.ComponentType; /** Custom UI component for displaying an image type attachment, defaults to and accepts same props as: [Image](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Gallery/Image.tsx) */ Image?: React.ComponentType; + /** Giphy rendition to use when rendering giphy attachments */ + giphyVersion?: GiphyVersions; + /** Handler used to size image attachments responsively */ + imageAttachmentSizeHandler?: ImageAttachmentSizeHandler; /** Optional flag to signal that an attachment is a displayed as a part of a quoted message */ isQuoted?: boolean; /** Custom UI component for displaying a media type attachment, defaults to `ReactPlayer` from 'react-player' */ Media?: React.ComponentType; + /** Whether a video thumbnail should be rendered before playback starts */ + shouldGenerateVideoThumbnail?: boolean; /** Custom UI component for displaying unsupported attachment types, defaults to NullComponent */ UnsupportedAttachment?: React.ComponentType; + /** Handler used to size video attachments responsively */ + videoAttachmentSizeHandler?: VideoAttachmentSizeHandler; /** Custom UI component for displaying an audio recording attachment, defaults to and accepts same props as: [VoiceRecording](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Attachment/VoiceRecording.tsx) */ VoiceRecording?: React.ComponentType; }; @@ -92,8 +121,32 @@ export const Attachment = (props: AttachmentProps) => { const { attachmentActionsDefaultFocus = defaultAttachmentActionsDefaultFocus, attachments, + giphyVersion, + imageAttachmentSizeHandler, + shouldGenerateVideoThumbnail, + videoAttachmentSizeHandler, ...rest } = props; + const attachmentContextValue = useMemo( + () => ({ + giphyVersion: giphyVersion ?? defaultAttachmentContextValue.giphyVersion, + imageAttachmentSizeHandler: + imageAttachmentSizeHandler ?? + defaultAttachmentContextValue.imageAttachmentSizeHandler, + shouldGenerateVideoThumbnail: + shouldGenerateVideoThumbnail ?? + defaultAttachmentContextValue.shouldGenerateVideoThumbnail, + videoAttachmentSizeHandler: + videoAttachmentSizeHandler ?? + defaultAttachmentContextValue.videoAttachmentSizeHandler, + }), + [ + giphyVersion, + imageAttachmentSizeHandler, + shouldGenerateVideoThumbnail, + videoAttachmentSizeHandler, + ], + ); const groupedAttachments = useMemo( () => @@ -107,12 +160,14 @@ export const Attachment = (props: AttachmentProps) => { ); return ( -
    - {ATTACHMENT_GROUPS_ORDER.reduce( - (acc, groupName) => [...acc, ...groupedAttachments[groupName]], - [] as React.ReactNode[], - )} -
    + +
    + {ATTACHMENT_GROUPS_ORDER.reduce( + (acc, groupName) => [...acc, ...groupedAttachments[groupName]], + [] as React.ReactNode[], + )} +
    +
    ); }; diff --git a/src/components/Attachment/AttachmentContainer.tsx b/src/components/Attachment/AttachmentContainer.tsx index 580bcf8a1f..3523c4aef2 100644 --- a/src/components/Attachment/AttachmentContainer.tsx +++ b/src/components/Attachment/AttachmentContainer.tsx @@ -42,11 +42,13 @@ import { type RenderMediaProps, SUPPORTED_VIDEO_FORMATS, } from './utils'; -import { useChannelStateContext } from '../../context/ChannelStateContext'; -import type { ImageAttachmentConfiguration } from '../../types/types'; import { VisibilityDisclaimer } from './VisibilityDisclaimer'; import { VideoAttachment } from './VideoAttachment'; import type { AttachmentProps } from './Attachment'; +import { + type ImageAttachmentConfiguration, + useAttachmentContext, +} from './AttachmentContext'; export type AttachmentContainerProps = { attachment: Attachment | GalleryAttachment | SharedLocationResponse; @@ -219,7 +221,7 @@ export const ImageContainer = (props: RenderAttachmentProps) => { const { attachment, Image = DefaultImage } = props; const componentType = 'image'; const imageElement = useRef(null); - const { imageAttachmentSizeHandler } = useChannelStateContext(); + const { imageAttachmentSizeHandler } = useAttachmentContext(); const [attachmentConfiguration, setAttachmentConfiguration] = useState< ImageAttachmentConfiguration | undefined >(undefined); diff --git a/src/components/Attachment/AttachmentContext.tsx b/src/components/Attachment/AttachmentContext.tsx new file mode 100644 index 0000000000..b76e2b0b16 --- /dev/null +++ b/src/components/Attachment/AttachmentContext.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import type { GiphyVersions } from 'stream-chat'; +import type { + ImageAttachmentSizeHandler, + VideoAttachmentSizeHandler, +} from './Attachment'; +import { + getImageAttachmentConfiguration, + getVideoAttachmentConfiguration, +} from './attachment-sizing'; + +export type ImageAttachmentConfiguration = { + url: string; +}; +export type VideoAttachmentConfiguration = ImageAttachmentConfiguration & { + thumbUrl?: string; +}; + +export type AttachmentContextValue = { + giphyVersion: GiphyVersions; + imageAttachmentSizeHandler: ImageAttachmentSizeHandler; + shouldGenerateVideoThumbnail: boolean; + videoAttachmentSizeHandler: VideoAttachmentSizeHandler; +}; + +export const defaultAttachmentContextValue: AttachmentContextValue = { + giphyVersion: 'fixed_height', + imageAttachmentSizeHandler: getImageAttachmentConfiguration, + shouldGenerateVideoThumbnail: true, + videoAttachmentSizeHandler: getVideoAttachmentConfiguration, +}; + +const AttachmentContext = React.createContext( + defaultAttachmentContextValue, +); + +export const AttachmentContextProvider = AttachmentContext.Provider; + +export const useAttachmentContext = () => React.useContext(AttachmentContext); diff --git a/src/components/Attachment/Audio.tsx b/src/components/Attachment/Audio.tsx index 4abb80d5d7..5bce94c605 100644 --- a/src/components/Attachment/Audio.tsx +++ b/src/components/Attachment/Audio.tsx @@ -7,6 +7,7 @@ import { useStateStore } from '../../store'; import { useMessageContext } from '../../context'; import type { AudioPlayer } from '../AudioPlayback/AudioPlayer'; import { PlayButton } from '../Button/PlayButton'; +import { useThreadContext } from '../Threads'; type AudioAttachmentUIProps = { audioPlayer: AudioPlayer; @@ -64,14 +65,15 @@ const UnMemoizedAudio = (props: AudioProps) => { * with the default SDK components, but can be done with custom API calls.In this case all the Audio * widgets will share the state. */ - const { message, threadList } = useMessageContext() ?? {}; + const { message } = useMessageContext() ?? {}; + const threadInstance = useThreadContext(); const audioPlayer = useAudioPlayer({ fileSize: file_size, mimeType: mime_type, requester: message?.id && - `${threadList ? (message.parent_id ?? message.id) : ''}${message.id}`, + `${threadInstance ? (message.parent_id ?? message.id) : ''}${message.id}`, src: asset_url, title, waveformData: props.attachment.waveform_data, diff --git a/src/components/Attachment/Geolocation.tsx b/src/components/Attachment/Geolocation.tsx index 5bac5f74ab..3274ca4e06 100644 --- a/src/components/Attachment/Geolocation.tsx +++ b/src/components/Attachment/Geolocation.tsx @@ -3,7 +3,7 @@ import { useEffect } from 'react'; import { useRef, useState } from 'react'; import React from 'react'; import type { Coords, SharedLocationResponse } from 'stream-chat'; -import { useChatContext, useTranslationContext } from '../../context'; +import { useChannel, useChatContext, useTranslationContext } from '../../context'; import { ExternalLinkIcon, GeolocationIcon } from './icons'; export type GeolocationMapProps = Coords; @@ -19,7 +19,8 @@ export const Geolocation = ({ GeolocationMap, location, }: GeolocationProps) => { - const { channel, client } = useChatContext(); + const { client } = useChatContext(); + const channel = useChannel(); const { t } = useTranslationContext(); const [stoppedSharing, setStoppedSharing] = useState( diff --git a/src/components/Attachment/Giphy.tsx b/src/components/Attachment/Giphy.tsx index 4d9807e571..f4843f681a 100644 --- a/src/components/Attachment/Giphy.tsx +++ b/src/components/Attachment/Giphy.tsx @@ -1,17 +1,17 @@ import type { Attachment } from 'stream-chat'; import { toGalleryItemDescriptors } from '../Gallery'; import clsx from 'clsx'; -import { useChannelStateContext } from '../../context'; import { IconGiphy } from '../Icons'; import { useMemo } from 'react'; import { ImageComponent } from './Image'; +import { useAttachmentContext } from './AttachmentContext'; export type GiphyAttachmentProps = { attachment: Attachment; }; export const Giphy = ({ attachment }: GiphyAttachmentProps) => { - const { giphyVersion: giphyVersionName } = useChannelStateContext(); + const { giphyVersion: giphyVersionName } = useAttachmentContext(); const imageDescriptors = useMemo( () => toGalleryItemDescriptors(attachment, { giphyVersionName }), diff --git a/src/components/Attachment/LinkPreview/Card.tsx b/src/components/Attachment/LinkPreview/Card.tsx index 1c36edadf7..0d7aaa4bf5 100644 --- a/src/components/Attachment/LinkPreview/Card.tsx +++ b/src/components/Attachment/LinkPreview/Card.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { BaseImage } from '../../Gallery'; import { SafeAnchor } from '../../SafeAnchor'; -import { useChannelStateContext } from '../../../context/ChannelStateContext'; +import { useAttachmentContext } from '../AttachmentContext'; import type { Attachment } from 'stream-chat'; import type { RenderAttachmentProps } from '../utils'; @@ -88,7 +88,7 @@ export type CardProps = RenderAttachmentProps['attachment'] & { const UnMemoizedCard = (props: CardProps) => { const { giphy, image_url, og_scrape_url, thumb_url, title, title_link, type } = props; - const { giphyVersion: giphyVersionName } = useChannelStateContext(''); + const { giphyVersion: giphyVersionName } = useAttachmentContext(); const cardUrl = title_link || og_scrape_url; let image = thumb_url || image_url; diff --git a/src/components/Attachment/LinkPreview/CardAudio.tsx b/src/components/Attachment/LinkPreview/CardAudio.tsx index 2c92dfa26a..0817a175ab 100644 --- a/src/components/Attachment/LinkPreview/CardAudio.tsx +++ b/src/components/Attachment/LinkPreview/CardAudio.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { IconChainLink } from '../../Icons'; import { SafeAnchor } from '../../SafeAnchor'; import type { CardProps } from './Card'; +import { useThreadContext } from '../../Threads'; const getHostFromURL = (url?: string | null) => { if (url !== undefined && url !== null) { @@ -54,13 +55,14 @@ const AudioWidget = ({ mimeType, src }: { src: string; mimeType?: string }) => { * with the default SDK components, but can be done with custom API calls.In this case all the Audio * widgets will share the state. */ - const { message, threadList } = useMessageContext() ?? {}; + const { message } = useMessageContext() ?? {}; + const threadInstance = useThreadContext(); const audioPlayer = useAudioPlayer({ mimeType, requester: message?.id && - `${threadList ? (message.parent_id ?? message.id) : ''}${message.id}`, + `${threadInstance ? (message.parent_id ?? message.id) : ''}${message.id}`, src, }); diff --git a/src/components/Attachment/VideoAttachment.tsx b/src/components/Attachment/VideoAttachment.tsx index 80f70c5cb0..02eaf4b054 100644 --- a/src/components/Attachment/VideoAttachment.tsx +++ b/src/components/Attachment/VideoAttachment.tsx @@ -1,11 +1,13 @@ import type { VideoAttachment as VideoAttachmentType } from 'stream-chat'; -import { useChannelStateContext } from '../../context'; import React, { type ComponentType, useLayoutEffect, useRef, useState } from 'react'; -import type { VideoAttachmentConfiguration } from '../../types/types'; import { getCssDimensionsVariables } from './utils'; import type { VideoPlayerProps } from '../VideoPlayer'; import { VideoPlayer as DefaultVideoPlayer } from '../VideoPlayer'; import { VideoThumbnail } from '../VideoPlayer/VideoThumbnail'; +import { + useAttachmentContext, + type VideoAttachmentConfiguration, +} from './AttachmentContext'; export type VideoAttachmentProps = { attachment: VideoAttachmentType; @@ -17,7 +19,7 @@ export const VideoAttachment = ({ VideoPlayer = DefaultVideoPlayer, }: VideoAttachmentProps) => { const { shouldGenerateVideoThumbnail, videoAttachmentSizeHandler } = - useChannelStateContext(); + useAttachmentContext(); const videoElement = useRef(null); const [attachmentConfiguration, setAttachmentConfiguration] = useState(); diff --git a/src/components/Attachment/VoiceRecording.tsx b/src/components/Attachment/VoiceRecording.tsx index 9b2056e1e5..f944e9045a 100644 --- a/src/components/Attachment/VoiceRecording.tsx +++ b/src/components/Attachment/VoiceRecording.tsx @@ -12,6 +12,7 @@ import { import { useStateStore } from '../../store'; import type { AudioPlayer } from '../AudioPlayback'; import { PlayButton } from '../Button'; +import { useThreadContext } from '../Threads'; const rootClassName = 'str-chat__message-attachment__voice-recording-widget'; @@ -99,7 +100,8 @@ export const VoiceRecordingPlayer = ({ * with the default SDK components, but can be done with custom API calls.In this case all the Audio * widgets will share the state. */ - const { message, threadList } = useMessageContext() ?? {}; + const { message } = useMessageContext() ?? {}; + const threadInstance = useThreadContext(); const audioPlayer = useAudioPlayer({ durationSeconds: duration ?? 0, @@ -108,7 +110,7 @@ export const VoiceRecordingPlayer = ({ playbackRates, requester: message?.id && - `${threadList ? (message.parent_id ?? message.id) : ''}${message.id}`, + `${threadInstance ? (message.parent_id ?? message.id) : ''}${message.id}`, src: asset_url, title, waveformData: waveform_data, diff --git a/src/components/Attachment/__tests__/Attachment.test.js b/src/components/Attachment/__tests__/Attachment.test.js index 7bd7416fc9..4fb0086a68 100644 --- a/src/components/Attachment/__tests__/Attachment.test.js +++ b/src/components/Attachment/__tests__/Attachment.test.js @@ -20,7 +20,6 @@ import { import { Attachment } from '../Attachment'; import { SUPPORTED_VIDEO_FORMATS } from '../utils'; import { generateScrapedVideoAttachment } from '../../../mock-builders'; -import { ChannelStateProvider } from '../../../context'; const UNSUPPORTED_ATTACHMENT_TEST_ID = 'attachment-unsupported'; @@ -56,19 +55,17 @@ const ATTACHMENTS = { const renderComponent = (props) => render( - - - , + , ); describe('attachment', () => { diff --git a/src/components/Attachment/__tests__/AttachmentScopedConfig.test.js b/src/components/Attachment/__tests__/AttachmentScopedConfig.test.js new file mode 100644 index 0000000000..a1c9f5e779 --- /dev/null +++ b/src/components/Attachment/__tests__/AttachmentScopedConfig.test.js @@ -0,0 +1,103 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { + generateGiphyAttachment, + generateImageAttachment, + generateVideoAttachment, +} from '../../../mock-builders'; +import { Attachment } from '../Attachment'; +import { useAttachmentContext } from '../AttachmentContext'; + +const TestImage = React.forwardRef(({ imageUrl }, ref) => ( + +)); +TestImage.displayName = 'TestImage'; + +const TestVideoPlayer = ({ isPlaying, thumbnailUrl, videoUrl }) => ( +
    +); + +const ContextAwareGiphy = () => { + const { giphyVersion } = useAttachmentContext(); + return
    {giphyVersion}
    ; +}; + +describe('Attachment scoped media config', () => { + it('uses giphyVersion from attachment scope', () => { + const attachment = generateGiphyAttachment(); + + render( + , + ); + + expect(screen.getByTestId('giphy-version')).toHaveTextContent('original'); + }); + + it('uses imageAttachmentSizeHandler from Attachment props without ChannelStateContext fields', async () => { + const resizedUrl = 'https://example.com/resized-image.jpg'; + const imageAttachmentSizeHandler = jest.fn(() => ({ url: resizedUrl })); + const attachment = generateImageAttachment({ + image_url: 'https://example.com/original-image.jpg', + }); + + render( + , + ); + + await waitFor(() => { + expect(imageAttachmentSizeHandler).toHaveBeenCalled(); + }); + expect(screen.getByTestId('resized-image')).toHaveAttribute('data-url', resizedUrl); + }); + + it('uses shouldGenerateVideoThumbnail and videoAttachmentSizeHandler from Attachment props', async () => { + const resizedVideoUrl = 'https://example.com/video-resized.mp4'; + const resizedThumbUrl = 'https://example.com/thumb-resized.jpg'; + const videoAttachmentSizeHandler = jest.fn(() => ({ + thumbUrl: resizedThumbUrl, + url: resizedVideoUrl, + })); + const attachment = generateVideoAttachment({ + asset_url: 'https://example.com/video-original.mp4', + thumb_url: 'https://example.com/thumb-original.jpg', + }); + + render( + , + ); + + await waitFor(() => { + expect(videoAttachmentSizeHandler).toHaveBeenCalled(); + }); + + expect(videoAttachmentSizeHandler).toHaveBeenCalledWith( + expect.objectContaining({ asset_url: attachment.asset_url }), + expect.any(HTMLDivElement), + false, + ); + expect(screen.queryByTestId('image-test')).not.toBeInTheDocument(); + expect(screen.getByTestId('video-player')).toHaveAttribute( + 'data-video', + resizedVideoUrl, + ); + }); +}); diff --git a/src/components/Attachment/__tests__/Audio.test.js b/src/components/Attachment/__tests__/Audio.test.js index 0a79b5b7df..d7e0168883 100644 --- a/src/components/Attachment/__tests__/Audio.test.js +++ b/src/components/Attachment/__tests__/Audio.test.js @@ -7,6 +7,7 @@ import { generateAudioAttachment, generateMessage } from '../../../mock-builders import { prettifyFileSize } from '../../MessageInput/hooks/utils'; import { WithAudioPlayback } from '../../AudioPlayback'; import { MessageProvider } from '../../../context'; +import { ThreadProvider } from '../../Threads'; jest.mock('../../../context/ChatContext', () => ({ useChatContext: () => ({ client: mockClient }), @@ -250,9 +251,11 @@ describe('Audio', () => { - - + + + + , ); const playButtons = screen.queryAllByTestId('play-audio'); diff --git a/src/components/Attachment/__tests__/Card.test.js b/src/components/Attachment/__tests__/Card.test.js index 4005b42a1c..aa5b9ab1f4 100644 --- a/src/components/Attachment/__tests__/Card.test.js +++ b/src/components/Attachment/__tests__/Card.test.js @@ -4,12 +4,7 @@ import '@testing-library/jest-dom'; import { Card } from '../LinkPreview/Card'; -import { - ChannelActionProvider, - MessageProvider, - TranslationContext, -} from '../../../context'; -import { ChannelStateProvider } from '../../../context/ChannelStateContext'; +import { MessageProvider, TranslationContext } from '../../../context'; import { ChatProvider } from '../../../context/ChatContext'; import { ComponentProvider } from '../../../context/ComponentContext'; @@ -25,6 +20,7 @@ import { useMockedApis, } from '../../../mock-builders'; import { WithAudioPlayback } from '../../AudioPlayback'; +import { ThreadProvider } from '../../Threads'; let chatClient; let channel; @@ -33,8 +29,6 @@ const user = generateUser({ id: 'userId', name: 'username' }); jest.spyOn(window.HTMLMediaElement.prototype, 'play').mockImplementation(); jest.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(); jest.spyOn(window.HTMLMediaElement.prototype, 'load').mockImplementation(); -const addNotificationSpy = jest.fn(); -const channelActionContext = { addNotification: addNotificationSpy }; const mockedChannel = generateChannel({ members: [generateMember({ user })], @@ -46,15 +40,11 @@ const renderCard = ({ cardProps, chatContext, theRenderer = render }) => theRenderer( - - - - - - - - - + + + + + , ); @@ -324,16 +314,16 @@ describe('Card', () => { render( - - + + + + + - - - - - + + , ); const playButtons = screen.queryAllByTestId('play-audio'); @@ -371,16 +361,14 @@ describe('Card', () => { const message = generateMessage(); render( - - - - - - - - - - + + + + + + + + , ); const playButtons = screen.queryAllByTestId('play-audio'); diff --git a/src/components/Attachment/__tests__/VoiceRecording.test.js b/src/components/Attachment/__tests__/VoiceRecording.test.js index 180f50a96c..b55c124e32 100644 --- a/src/components/Attachment/__tests__/VoiceRecording.test.js +++ b/src/components/Attachment/__tests__/VoiceRecording.test.js @@ -10,6 +10,7 @@ import { VoiceRecording, VoiceRecordingPlayer } from '../VoiceRecording'; import { ChatProvider, MessageProvider } from '../../../context'; import { ResizeObserverMock } from '../../../mock-builders/browser'; import { WithAudioPlayback } from '../../AudioPlayback'; +import { ThreadProvider } from '../../Threads'; const AUDIO_RECORDING_PLAYER_TEST_ID = 'voice-recording-widget'; const QUOTED_AUDIO_RECORDING_TEST_ID = 'quoted-voice-recording-widget'; @@ -69,9 +70,11 @@ describe('VoiceRecording', () => { - - - + + + + + , ); expect(createdAudios).toHaveLength(2); diff --git a/src/components/Attachment/hooks/useAudioController.ts b/src/components/Attachment/hooks/useAudioController.ts index ed881b4568..90b41a130e 100644 --- a/src/components/Attachment/hooks/useAudioController.ts +++ b/src/components/Attachment/hooks/useAudioController.ts @@ -1,6 +1,6 @@ import throttle from 'lodash.throttle'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useChannelActionContext, useTranslationContext } from '../../../context'; +import { useChatContext, useTranslationContext } from '../../../context'; const isSeekable = (audioElement: HTMLAudioElement) => !(audioElement.duration === Infinity || isNaN(audioElement.duration)); @@ -29,7 +29,7 @@ export const useAudioController = ({ mimeType, playbackRates = DEFAULT_PLAYBACK_RATES, }: AudioControllerParams = {}) => { - const { addNotification } = useChannelActionContext('useAudioController'); + const { client } = useChatContext('useAudioController'); const { t } = useTranslationContext('useAudioController'); const [isPlaying, setIsPlaying] = useState(false); const [playbackError, setPlaybackError] = useState(); @@ -43,9 +43,13 @@ export const useAudioController = ({ (e: Error) => { logError(e as Error); setPlaybackError(e); - addNotification(e.message, 'error'); + client.notifications.addError({ + message: e.message, + options: { originalError: e, type: 'browser:audio:playback:error' }, + origin: { emitter: 'useAudioController.registerError' }, + }); }, - [addNotification], + [client], ); const togglePlay = useCallback(async () => { @@ -126,7 +130,11 @@ export const useAudioController = ({ audioElement.addEventListener('ended', handleEnded); const handleError = () => { - addNotification(t('Error reproducing the recording'), 'error'); + client.notifications.addError({ + message: t('Error reproducing the recording'), + options: { type: 'browser:audio:playback:error' }, + origin: { emitter: 'useAudioController.audioElement' }, + }); setIsPlaying(false); }; audioElement.addEventListener('error', handleError); @@ -142,7 +150,7 @@ export const useAudioController = ({ audioElement.removeEventListener('error', handleError); audioElement.removeEventListener('timeupdate', handleTimeupdate); }; - }, [addNotification, durationSeconds, t]); + }, [client, durationSeconds, t]); return { audioRef, diff --git a/src/components/Attachment/index.ts b/src/components/Attachment/index.ts index 44df0bb6d1..169e1e1a20 100644 --- a/src/components/Attachment/index.ts +++ b/src/components/Attachment/index.ts @@ -1,4 +1,5 @@ export * from './Attachment'; +export * from './AttachmentContext'; export * from './AttachmentActions'; export * from './AttachmentContainer'; export * from './Audio'; diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index f213cf260b..09ab52920f 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -1,5 +1,4 @@ -import type { ComponentProps } from 'react'; -import { forwardRef } from 'react'; +import React, { type ComponentProps, forwardRef } from 'react'; import clsx from 'clsx'; export type ButtonVariant = 'primary' | 'secondary' | 'danger'; diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 4117ccde11..932647f4f8 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -1,60 +1,27 @@ import type { ComponentProps, PropsWithChildren } from 'react'; -import React, { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useReducer, - useRef, - useState, -} from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import clsx from 'clsx'; -import debounce from 'lodash.debounce'; -import throttle from 'lodash.throttle'; import type { - APIErrorResponse, - ChannelAPIResponse, ChannelMemberResponse, ChannelQueryOptions, - ChannelState, DeleteMessageOptions, - ErrorFromResponse, Event, EventAPIResponse, - GiphyVersions, LocalMessage, + MarkReadOptions, Message, MessageResponse, - SendMessageAPIResponse, SendMessageOptions, Channel as StreamChannel, StreamChat, UpdateMessageOptions, } from 'stream-chat'; -import { localMessageToNewMessagePayload } from 'stream-chat'; +import { useChannelConfig } from './hooks/useChannelConfig'; -import { initialState, makeChannelReducer } from './channelState'; -import { useCreateChannelStateContext } from './hooks/useCreateChannelStateContext'; -import { useCreateTypingContext } from './hooks/useCreateTypingContext'; -import { useEditMessageHandler } from './hooks/useEditMessageHandler'; -import { useIsMounted } from './hooks/useIsMounted'; -import type { OnMentionAction } from './hooks/useMentionsHandlers'; -import { useMentionsHandlers } from './hooks/useMentionsHandlers'; +import { LoadingChannel as DefaultLoadingIndicator } from '../Loading'; import { - LoadingErrorIndicator as DefaultLoadingErrorIndicator, - LoadingChannel as DefaultLoadingIndicator, -} from '../Loading'; - -import type { - ChannelActionContextValue, - ChannelNotifications, - MarkReadWrapperOptions, -} from '../../context'; -import { - ChannelActionProvider, - ChannelStateProvider, - TypingProvider, + ChannelInstanceProvider, useChatContext, useComponentContext, useTranslationContext, @@ -63,29 +30,16 @@ import { import { CHANNEL_CONTAINER_ID } from './constants'; import { DEFAULT_HIGHLIGHT_DURATION, - DEFAULT_JUMP_TO_PAGE_SIZE, DEFAULT_NEXT_CHANNEL_PAGE_SIZE, - DEFAULT_THREAD_PAGE_SIZE, } from '../../constants/limits'; -import { hasMoreMessagesProbably } from '../MessageList'; import { getChatContainerClass, useChannelContainerClasses, useImageFlagEmojisOnWindowsClass, } from './hooks/useChannelContainerClasses'; -import { findInMsgSetByDate, findInMsgSetById, makeAddNotifications } from './utils'; -import { useThreadContext } from '../Threads'; +import { useChannelRequestHandlers } from './hooks/useChannelRequestHandlers'; import { getChannel } from '../../utils'; -import type { - ChannelUnreadUiState, - ImageAttachmentSizeHandler, - VideoAttachmentSizeHandler, -} from '../../types/types'; -import { - getImageAttachmentConfiguration, - getVideoAttachmentConfiguration, -} from '../Attachment/attachment-sizing'; import { useSearchFocusedMessage } from '../../experimental/Search/hooks'; import { WithAudioPlayback } from '../AudioPlayback'; @@ -111,8 +65,8 @@ export type ChannelProps = { /** Custom action handler to override the default `channel.markRead` request function (advanced usage only) */ doMarkReadRequest?: ( channel: StreamChannel, - setChannelUnreadUiState?: (state: ChannelUnreadUiState) => void, - ) => Promise | void; + options?: MarkReadOptions, + ) => Promise | void; /** Custom action handler to override the default `channel.sendMessage` request function (advanced usage only) */ doSendMessageRequest?: ( channel: StreamChannel, @@ -127,10 +81,6 @@ export type ChannelProps = { ) => ReturnType; /** Custom UI component to be shown if no active channel is set, defaults to null and skips rendering the Channel component */ EmptyPlaceholder?: React.ReactElement; - /** The giphy version to render - check the keys of the [Image Object](https://developers.giphy.com/docs/api/schema#image-object) for possible values. Uses 'fixed_height' by default */ - giphyVersion?: GiphyVersions; - /** A custom function to provide size configuration for image attachments */ - imageAttachmentSizeHandler?: ImageAttachmentSizeHandler; /** * Allows to prevent triggering the channel.watch() call when mounting the component. * That means that no channel data from the back-end will be received neither channel WS events will be delivered to the client. @@ -139,16 +89,6 @@ export type ChannelProps = { initializeOnMount?: boolean; /** Configuration parameter to mark the active channel as read when mounted (opened). By default, the channel is marked read on mount. */ markReadOnMount?: boolean; - /** Custom action handler function to run on click of an @mention in a message */ - onMentionsClick?: OnMentionAction; - /** Custom action handler function to run on hover of an @mention in a message */ - onMentionsHover?: OnMentionAction; - /** You can turn on/off thumbnail generation for video attachments */ - shouldGenerateVideoThumbnail?: boolean; - /** If true, skips the message data string comparison used to memoize the current channel messages (helpful for channels with 1000s of messages) */ - skipMessageDataMemoization?: boolean; - /** A custom function to provide size configuration for video attachments */ - videoAttachmentSizeHandler?: VideoAttachmentSizeHandler; }; const ChannelContainer = ({ @@ -173,9 +113,8 @@ const UnMemoizedChannel = (props: PropsWithChildren) => { const { LoadingErrorIndicator, LoadingIndicator = DefaultLoadingIndicator } = useComponentContext(); - const { channel: contextChannel, channelsQueryState } = useChatContext('Channel'); - - const channel = propsChannel || contextChannel; + const { channelsQueryState } = useChatContext('Channel'); + const channel = propsChannel; if (channelsQueryState.queryInProgress === 'reload' && LoadingIndicator) { return ( @@ -220,134 +159,74 @@ const ChannelInner = ( doUpdateMessageRequest, initializeOnMount = true, markReadOnMount = true, - onMentionsClick, - onMentionsHover, - skipMessageDataMemoization, } = props; - const { - LoadingErrorIndicator = DefaultLoadingErrorIndicator, - LoadingIndicator = DefaultLoadingIndicator, - } = useComponentContext(); - const { client, customClasses, latestMessageDatesByChannels, mutes, searchController } = + const { LoadingErrorIndicator, LoadingIndicator = DefaultLoadingIndicator } = + useComponentContext(); + + const { client, customClasses, latestMessageDatesByChannels, searchController } = useChatContext('Channel'); const { t } = useTranslationContext('Channel'); const chatContainerClass = getChatContainerClass(customClasses?.chatContainer); const windowsEmojiClass = useImageFlagEmojisOnWindowsClass(); - const thread = useThreadContext(); - - const [channelConfig, setChannelConfig] = useState(channel.getConfig()); - const [notifications, setNotifications] = useState([]); - const notificationTimeouts = useRef>([]); - - const [channelUnreadUiState, _setChannelUnreadUiState] = - useState(); - const channelReducer = useMemo(() => makeChannelReducer(), []); - - const [state, dispatch] = useReducer( - channelReducer, - // channel.initialized === false if client.channel().query() was not called, e.g. ChannelList is not used - // => Channel will call channel.watch() in useLayoutEffect => state.loading is used to signal the watch() call state - { - ...initialState, - hasMore: channel.state.messagePagination.hasPrev, - loading: !channel.initialized, - messages: channel.state.messages, - }, - ); + const channelConfig = useChannelConfig({ cid: channel.cid }); + useChannelRequestHandlers({ + channel, + doDeleteMessageRequest, + doMarkReadRequest, + doSendMessageRequest, + doUpdateMessageRequest, + }); const jumpToMessageFromSearch = useSearchFocusedMessage(); - const isMounted = useIsMounted(); const originalTitle = useRef(''); const lastRead = useRef(undefined); const online = useRef(true); - const clearHighlightedMessageTimeoutId = useRef | null>( + const clearSearchFocusedMessageTimeoutId = useRef | null>( null, ); - - const channelCapabilitiesArray = channel.data?.own_capabilities as string[]; - - const throttledCopyStateFromChannel = throttle( - () => dispatch({ channel, type: 'copyStateFromChannelOnEvent' }), - 500, - { - leading: true, - trailing: true, - }, - ); - - const setChannelUnreadUiState = useMemo( - () => - throttle(_setChannelUnreadUiState, 200, { - leading: true, - trailing: false, - }), - [], + const [bootstrapError, setBootstrapError] = useState(undefined); + const [isBootstrapping, setIsBootstrapping] = useState( + !channel.initialized && initializeOnMount, ); - const markRead = useMemo( - () => - throttle( - async (options?: MarkReadWrapperOptions) => { - const { updateChannelUiUnreadState = true } = options ?? {}; - if (channel.disconnected || !channelConfig?.read_events) { - return; - } - - lastRead.current = new Date(); + const markChannelRead = useCallback( + async ({ + updateChannelUiUnreadState = true, + }: { updateChannelUiUnreadState?: boolean } = {}) => { + if (channel.disconnected || !channelConfig?.read_events) { + return; + } - try { - if (doMarkReadRequest) { - doMarkReadRequest( - channel, - updateChannelUiUnreadState ? setChannelUnreadUiState : undefined, - ); - } else { - const markReadResponse = await channel.markRead(); - // markReadResponse.event can be null in case of a user that is not a member of a channel being marked read - // in that case event is null and we should not set unread UI - if (updateChannelUiUnreadState && markReadResponse?.event) { - _setChannelUnreadUiState({ - last_read: lastRead.current, - last_read_message_id: markReadResponse.event.last_read_message_id, - unread_messages: 0, - }); - } - } + lastRead.current = new Date(); + + try { + const markReadResponse = await channel.markRead(); + // markReadResponse.event can be null for users not members of the channel + if (updateChannelUiUnreadState && markReadResponse?.event) { + channel.messagePaginator.unreadStateSnapshot.next({ + firstUnreadMessageId: null, + lastReadAt: lastRead.current, + lastReadMessageId: markReadResponse.event.last_read_message_id ?? null, + unreadCount: 0, + }); + } - if (activeUnreadHandler) { - activeUnreadHandler(0, originalTitle.current); - } else if (originalTitle.current) { - document.title = originalTitle.current; - } - } catch (e) { - console.error(t('Failed to mark channel as read')); - } - }, - 500, - { leading: true, trailing: false }, - ), - [ - activeUnreadHandler, - channel, - channelConfig, - doMarkReadRequest, - setChannelUnreadUiState, - t, - ], + if (activeUnreadHandler) { + activeUnreadHandler(0, originalTitle.current); + } else if (originalTitle.current) { + document.title = originalTitle.current; + } + } catch (e) { + console.error(t('Failed to mark channel as read')); + } + }, + [activeUnreadHandler, channel, channelConfig?.read_events, t], ); const handleEvent = async (event: Event) => { - if (event.message) { - dispatch({ - channel, - message: event.message, - type: 'updateThreadOnEvent', - }); - } - // ignore the event if it is not targeted at the current channel. // Event targeted at this channel or globally targeted event should lead to state refresh if (event.type === 'user.messages.deleted' && event.cid && event.cid !== channel.cid) @@ -356,10 +235,6 @@ const ChannelInner = ( if (event.type === 'user.watching.start' || event.type === 'user.watching.stop') return; - if (event.type === 'typing.start' || event.type === 'typing.stop') { - return dispatch({ channel, type: 'setTyping' }); - } - if (event.type === 'connection.changed' && typeof event.online === 'boolean') { online.current = event.online; } @@ -403,42 +278,36 @@ const ChannelInner = ( if (event.type === 'user.deleted') { const oldestID = channel.state?.messages?.[0]?.id; + const refetchLimit = + channelQueryOptions?.messages?.limit ?? DEFAULT_NEXT_CHANNEL_PAGE_SIZE; /** * As the channel state is not normalized we re-fetch the channel data. Thus, we avoid having to search for user references in the channel state. */ - // FIXME: we should use channelQueryOptions if they are available await channel.query({ - messages: { id_lt: oldestID, limit: DEFAULT_NEXT_CHANNEL_PAGE_SIZE }, - watchers: { limit: DEFAULT_NEXT_CHANNEL_PAGE_SIZE }, - }); - } - - if (event.type === 'notification.mark_unread') - _setChannelUnreadUiState((prev) => { - if (!(event.last_read_at && event.user)) return prev; - return { - first_unread_message_id: event.first_unread_message_id, - last_read: new Date(event.last_read_at), - last_read_message_id: event.last_read_message_id, - unread_messages: event.unread_messages ?? 0, - }; + ...channelQueryOptions, + messages: { + ...channelQueryOptions?.messages, + id_lt: oldestID, + limit: refetchLimit, + }, + watchers: channelQueryOptions?.watchers ?? { limit: refetchLimit }, }); - - if (event.type === 'channel.truncated' && event.cid === channel.cid) { - _setChannelUnreadUiState(undefined); } - - throttledCopyStateFromChannel(); }; // useLayoutEffect here to prevent spinner. Use Suspense when it is available in stable release useLayoutEffect(() => { let errored = false; let done = false; + let isMounted = true; (async () => { if (!channel.initialized && initializeOnMount) { + if (isMounted) { + setIsBootstrapping(true); + setBootstrapError(undefined); + } try { // if active channel has been set without id, we will create a temporary channel id from its member IDs // to keep track of the /query request in progress. This is the same approach of generating temporary id @@ -459,38 +328,35 @@ const ChannelInner = ( } } await getChannel({ channel, client, members, options: channelQueryOptions }); - const config = channel.getConfig(); - setChannelConfig(config); } catch (e) { - dispatch({ error: e as Error, type: 'setError' }); + if (isMounted) { + setBootstrapError(e as Error); + setIsBootstrapping(false); + } errored = true; + return; } + } else if (isMounted) { + setBootstrapError(undefined); + setIsBootstrapping(false); } done = true; + if (isMounted) { + setIsBootstrapping(false); + } originalTitle.current = document.title; if (!errored) { - dispatch({ - channel, - hasMore: channel.state.messagePagination.hasPrev, - type: 'initStateFromChannel', - }); - - if (client.user?.id && channel.state.read[client.user.id]) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { user, ...ownReadState } = channel.state.read[client.user.id]; - _setChannelUnreadUiState(ownReadState); - } - /** - * TODO: maybe pass last_read to the countUnread method to get proper value - * combined with channel.countUnread adjustment (_countMessageAsUnread) - * to allow counting own messages too - * - * const lastRead = channel.state.read[client.userID as string].last_read; - */ - if (channel.countUnread() > 0 && markReadOnMount) - markRead({ updateChannelUiUnreadState: false }); + const ownReadState = client.userID + ? channel.state.read[client.userID] + : undefined; + const lastReadAtFromOwnReadState = ownReadState?.last_read + ? new Date(ownReadState.last_read) + : undefined; + + if (channel.countUnread(lastReadAtFromOwnReadState) > 0 && markReadOnMount) + void markChannelRead({ updateChannelUiUnreadState: false }); // The more complex sync logic is done in Chat client.on('connection.changed', handleEvent); client.on('connection.recovered', handleEvent); @@ -500,644 +366,57 @@ const ChannelInner = ( channel.on(handleEvent); } })(); - const notificationTimeoutsRef = notificationTimeouts.current; - return () => { + isMounted = false; if (errored || !done) return; channel?.off(handleEvent); client.off('connection.changed', handleEvent); client.off('connection.recovered', handleEvent); client.off('user.deleted', handleEvent); - notificationTimeoutsRef.forEach(clearTimeout); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ channel.cid, channelQueryOptions, - doMarkReadRequest, channelConfig?.read_events, initializeOnMount, + markChannelRead, ]); - useEffect(() => { - if (!state.thread) return; - - const message = state.messages?.find((m) => m.id === state.thread?.id); - - if (message) dispatch({ message, type: 'setThread' }); - }, [state.messages, state.thread]); - - const handleHighlightedMessageChange = useCallback( - ({ - highlightDuration, - highlightedMessageId, - }: { - highlightedMessageId: string; - highlightDuration?: number; - }) => { - dispatch({ - channel, - highlightedMessageId, - type: 'jumpToMessageFinished', - }); - if (clearHighlightedMessageTimeoutId.current) { - clearTimeout(clearHighlightedMessageTimeoutId.current); - } - clearHighlightedMessageTimeoutId.current = setTimeout(() => { - if (searchController._internalState.getLatestValue().focusedMessage) { - searchController._internalState.partialNext({ focusedMessage: undefined }); - } - clearHighlightedMessageTimeoutId.current = null; - dispatch({ type: 'clearHighlightedMessage' }); - }, highlightDuration ?? DEFAULT_HIGHLIGHT_DURATION); - }, - [channel, searchController], - ); - useEffect(() => { if (!jumpToMessageFromSearch?.id) return; - handleHighlightedMessageChange({ highlightedMessageId: jumpToMessageFromSearch.id }); - }, [jumpToMessageFromSearch, handleHighlightedMessageChange]); - - /** MESSAGE */ - - // Adds a temporary notification to message list, will be removed after 5 seconds - const addNotification = useMemo( - () => makeAddNotifications(setNotifications, notificationTimeouts.current), - [], - ); - - // eslint-disable-next-line react-hooks/exhaustive-deps - const loadMoreFinished = useCallback( - debounce( - (hasMore: boolean, messages: ChannelState['messages']) => { - if (!isMounted.current) return; - dispatch({ hasMore, messages, type: 'loadMoreFinished' }); - }, - 2000, - { leading: true, trailing: true }, - ), - [], - ); - - const loadMore = async (limit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE) => { - if ( - !online.current || - !window.navigator.onLine || - !channel.state.messagePagination.hasPrev - ) - return 0; - - // prevent duplicate loading events... - const oldestMessage = state?.messages?.[0]; - - if ( - state.loadingMore || - state.loadingMoreNewer || - oldestMessage?.status !== 'received' - ) { - return 0; - } - - dispatch({ loadingMore: true, type: 'setLoadingMore' }); - - const oldestID = oldestMessage?.id; - const perPage = limit; - let queryResponse: ChannelAPIResponse; - - try { - queryResponse = await channel.query({ - messages: { id_lt: oldestID, limit: perPage }, - watchers: { limit: perPage }, - }); - } catch (e) { - console.warn('message pagination request failed with error', e); - dispatch({ loadingMore: false, type: 'setLoadingMore' }); - return 0; - } - - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - - return queryResponse.messages.length; - }; - - const loadMoreNewer = async (limit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE) => { - if ( - !online.current || - !window.navigator.onLine || - !channel.state.messagePagination.hasNext - ) - return 0; - - const newestMessage = state?.messages?.[state?.messages?.length - 1]; - if (state.loadingMore || state.loadingMoreNewer) return 0; - - dispatch({ loadingMoreNewer: true, type: 'setLoadingMoreNewer' }); - - const newestId = newestMessage?.id; - const perPage = limit; - let queryResponse: ChannelAPIResponse; - - try { - queryResponse = await channel.query({ - messages: { id_gt: newestId, limit: perPage }, - watchers: { limit: perPage }, - }); - } catch (e) { - console.warn('message pagination request failed with error', e); - dispatch({ loadingMoreNewer: false, type: 'setLoadingMoreNewer' }); - return 0; - } - - dispatch({ - hasMoreNewer: channel.state.messagePagination.hasNext, - messages: channel.state.messages, - type: 'loadMoreNewerFinished', + void channel.messagePaginator.jumpToMessage(jumpToMessageFromSearch.id, { + focusReason: 'jump-to-message', + focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION, }); - return queryResponse.messages.length; - }; - - const jumpToMessage: ChannelActionContextValue['jumpToMessage'] = useCallback( - async ( - messageId, - messageLimit = DEFAULT_JUMP_TO_PAGE_SIZE, - highlightDuration = DEFAULT_HIGHLIGHT_DURATION, - ) => { - dispatch({ loadingMore: true, type: 'setLoadingMore' }); - await channel.state.loadMessageIntoState(messageId, undefined, messageLimit); - - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - handleHighlightedMessageChange({ - highlightDuration, - highlightedMessageId: messageId, - }); - }, - [channel, handleHighlightedMessageChange, loadMoreFinished], - ); - const jumpToLatestMessage: ChannelActionContextValue['jumpToLatestMessage'] = - useCallback(async () => { - await channel.state.loadMessageIntoState('latest'); - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - dispatch({ - type: 'jumpToLatestMessage', - }); - }, [channel, loadMoreFinished]); - - const jumpToFirstUnreadMessage: ChannelActionContextValue['jumpToFirstUnreadMessage'] = - useCallback( - async ( - queryMessageLimit = DEFAULT_JUMP_TO_PAGE_SIZE, - highlightDuration = DEFAULT_HIGHLIGHT_DURATION, - ) => { - if (!channelUnreadUiState?.unread_messages) return; - let lastReadMessageId = channelUnreadUiState?.last_read_message_id; - let firstUnreadMessageId = channelUnreadUiState?.first_unread_message_id; - let isInCurrentMessageSet = false; - - if (firstUnreadMessageId) { - const result = findInMsgSetById(firstUnreadMessageId, channel.state.messages); - isInCurrentMessageSet = result.index !== -1; - } else if (lastReadMessageId) { - const result = findInMsgSetById(lastReadMessageId, channel.state.messages); - isInCurrentMessageSet = !!result.target; - firstUnreadMessageId = - result.index > -1 ? channel.state.messages[result.index + 1]?.id : undefined; - } else { - const lastReadTimestamp = channelUnreadUiState.last_read.getTime(); - const { index: lastReadMessageIndex, target: lastReadMessage } = - findInMsgSetByDate( - channelUnreadUiState.last_read, - channel.state.messages, - true, - ); - - if (lastReadMessage) { - firstUnreadMessageId = channel.state.messages[lastReadMessageIndex + 1]?.id; - isInCurrentMessageSet = !!firstUnreadMessageId; - lastReadMessageId = lastReadMessage.id; - } else { - dispatch({ loadingMore: true, type: 'setLoadingMore' }); - let messages; - try { - messages = ( - await channel.query( - { - messages: { - created_at_around: channelUnreadUiState.last_read.toISOString(), - limit: queryMessageLimit, - }, - }, - 'new', - ) - ).messages; - } catch (e) { - addNotification(t('Failed to jump to the first unread message'), 'error'); - loadMoreFinished( - channel.state.messagePagination.hasPrev, - channel.state.messages, - ); - return; - } - - const firstMessageWithCreationDate = messages.find((msg) => msg.created_at); - if (!firstMessageWithCreationDate) { - addNotification(t('Failed to jump to the first unread message'), 'error'); - loadMoreFinished( - channel.state.messagePagination.hasPrev, - channel.state.messages, - ); - return; - } - const firstMessageTimestamp = new Date( - firstMessageWithCreationDate.created_at as string, - ).getTime(); - if (lastReadTimestamp < firstMessageTimestamp) { - // whole channel is unread - firstUnreadMessageId = firstMessageWithCreationDate.id; - } else { - const result = findInMsgSetByDate(channelUnreadUiState.last_read, messages); - lastReadMessageId = result.target?.id; - } - loadMoreFinished( - channel.state.messagePagination.hasPrev, - channel.state.messages, - ); - } - } - - if (!firstUnreadMessageId && !lastReadMessageId) { - addNotification(t('Failed to jump to the first unread message'), 'error'); - return; - } - - if (!isInCurrentMessageSet) { - dispatch({ loadingMore: true, type: 'setLoadingMore' }); - try { - const targetId = (firstUnreadMessageId ?? lastReadMessageId) as string; - await channel.state.loadMessageIntoState( - targetId, - undefined, - queryMessageLimit, - ); - /** - * if the index of the last read message on the page is beyond the half of the page, - * we have arrived to the oldest page of the channel - */ - const indexOfTarget = channel.state.messages.findIndex( - (message) => message.id === targetId, - ) as number; - loadMoreFinished( - channel.state.messagePagination.hasPrev, - channel.state.messages, - ); - firstUnreadMessageId = - firstUnreadMessageId ?? channel.state.messages[indexOfTarget + 1]?.id; - } catch (e) { - addNotification(t('Failed to jump to the first unread message'), 'error'); - loadMoreFinished( - channel.state.messagePagination.hasPrev, - channel.state.messages, - ); - return; - } - } - - if (!firstUnreadMessageId) { - addNotification(t('Failed to jump to the first unread message'), 'error'); - return; - } - if (!channelUnreadUiState.first_unread_message_id) - _setChannelUnreadUiState({ - ...channelUnreadUiState, - first_unread_message_id: firstUnreadMessageId, - last_read_message_id: lastReadMessageId, - }); - handleHighlightedMessageChange({ - highlightDuration, - highlightedMessageId: firstUnreadMessageId, - }); - }, - [ - addNotification, - channel, - handleHighlightedMessageChange, - loadMoreFinished, - t, - channelUnreadUiState, - ], - ); - - const deleteMessage = useCallback( - async ( - message: LocalMessage, - options?: DeleteMessageOptions, - ): Promise => { - if (!message?.id) { - throw new Error('Cannot delete a message - missing message ID.'); - } - let deletedMessage; - if (doDeleteMessageRequest) { - deletedMessage = await doDeleteMessageRequest(message, options); - } else { - const result = await client.deleteMessage(message.id, options); - deletedMessage = result.message; - } - - return deletedMessage; - }, - [client, doDeleteMessageRequest], - ); - - const updateMessage = (updatedMessage: MessageResponse | LocalMessage) => { - // add the message to the local channel state - channel.state.addMessageSorted(updatedMessage, true); - - dispatch({ - channel, - parentId: state.thread && updatedMessage.parent_id, - type: 'copyMessagesFromChannel', - }); - }; - - const doSendMessage = async ({ - localMessage, - message, - options, - }: { - localMessage: LocalMessage; - message: Message; - options?: SendMessageOptions; - }) => { - try { - let messageResponse: void | SendMessageAPIResponse; - - if (doSendMessageRequest) { - messageResponse = await doSendMessageRequest(channel, message, options); - } else { - messageResponse = await channel.sendMessage(message, options); - } - - let existingMessage: LocalMessage | undefined = undefined; - for (let i = channel.state.messages.length - 1; i >= 0; i--) { - const msg = channel.state.messages[i]; - if (msg.id && msg.id === message.id) { - existingMessage = msg; - break; - } - } - - const responseTimestamp = new Date( - messageResponse?.message?.updated_at || 0, - ).getTime(); - const existingMessageTimestamp = existingMessage?.updated_at?.getTime() || 0; - const responseIsTheNewest = responseTimestamp > existingMessageTimestamp; - - // Replace the message payload after send is completed - // We need to check for the newest message payload, because on slow network, the response can arrive later than WS events message.new, message.updated. - // Always override existing message in status "sending" - if ( - messageResponse?.message && - (responseIsTheNewest || existingMessage?.status === 'sending') - ) { - updateMessage({ - ...messageResponse.message, - status: 'received', - }); - } - } catch (error) { - // error response isn't usable so needs to be stringified then parsed - const stringError = JSON.stringify(error); - const parsedError = ( - stringError ? JSON.parse(stringError) : {} - ) as ErrorFromResponse; - - // Handle the case where the message already exists - // (typically, when retrying to send a message). - // If the message already exists, we can assume it was sent successfully, - // so we update the message status to "received". - // Right now, the only way to check this error is by checking - // the combination of the error code and the error description, - // since there is no special error code for duplicate messages. - if ( - parsedError.code === 4 && - error instanceof Error && - error.message.includes('already exists') - ) { - updateMessage({ - ...localMessage, - status: 'received', - }); - } else { - updateMessage({ - ...localMessage, - error: parsedError, - status: 'failed', - }); - - thread?.upsertReplyLocally({ - message: { - ...localMessage, - error: parsedError, - status: 'failed', - }, - }); - } + if (clearSearchFocusedMessageTimeoutId.current) { + clearTimeout(clearSearchFocusedMessageTimeoutId.current); } - }; - - const sendMessage = async ({ - localMessage, - message, - options, - }: { - localMessage: LocalMessage; - message: Message; - options?: SendMessageOptions; - }) => { - channel.state.filterErrorMessages(); - - thread?.upsertReplyLocally({ - message: localMessage, - }); - - updateMessage(localMessage); - - await doSendMessage({ localMessage, message, options }); - }; - - const retrySendMessage = async (localMessage: LocalMessage) => { - /** - * If type is not checked, and we for example send message.type === 'error', - * then request fails with error: "message.type must be one of ['' regular system]". - * For now, we re-send any other type to prevent breaking behavior. - */ - - const type = localMessage.type === 'error' ? 'regular' : localMessage.type; - updateMessage({ - ...localMessage, - error: undefined, - status: 'sending', - type, - }); - - await doSendMessage({ - localMessage, - message: localMessageToNewMessagePayload({ ...localMessage, type }), - }); - }; - - const removeMessage = (message: LocalMessage) => { - channel.state.removeMessage(message); - - dispatch({ - channel, - parentId: state.thread && message.parent_id, - type: 'copyMessagesFromChannel', - }); - }; - - /** THREAD */ - - const openThread = (message: LocalMessage, event?: React.BaseSyntheticEvent) => { - event?.preventDefault(); - dispatch({ channel, message, type: 'openThread' }); - }; - - const closeThread = (event?: React.BaseSyntheticEvent) => { - event?.preventDefault(); - dispatch({ type: 'closeThread' }); - }; - - // eslint-disable-next-line react-hooks/exhaustive-deps - const loadMoreThreadFinished = useCallback( - debounce( - ( - threadHasMore: boolean, - threadMessages: Array>, - ) => { - dispatch({ - threadHasMore, - threadMessages, - type: 'loadMoreThreadFinished', - }); - }, - 2000, - { leading: true, trailing: true }, - ), - [], - ); - - const loadMoreThread = async (limit: number = DEFAULT_THREAD_PAGE_SIZE) => { - // FIXME: should prevent loading more, if state.thread.reply_count === channel.state.threads[parentID].length - if (state.threadLoadingMore || !state.thread || !state.threadHasMore) return; - - dispatch({ type: 'startLoadingThread' }); - const parentId = state.thread.id; - - if (!parentId) { - return dispatch({ type: 'closeThread' }); - } - - const oldMessages = channel.state.threads[parentId] || []; - const oldestMessageId = oldMessages[0]?.id; - - try { - const queryResponse = await channel.getReplies(parentId, { - id_lt: oldestMessageId, - limit, - }); - - const threadHasMoreMessages = hasMoreMessagesProbably( - queryResponse.messages.length, - limit, - ); - const newThreadMessages = channel.state.threads[parentId] || []; - - // next set loadingMore to false so we can start asking for more data - loadMoreThreadFinished(threadHasMoreMessages, newThreadMessages); - } catch (e) { - loadMoreThreadFinished(false, oldMessages); - } - }; - - const onMentionsHoverOrClick = useMentionsHandlers(onMentionsHover, onMentionsClick); - - const editMessage = useEditMessageHandler(doUpdateMessageRequest); - - const { typing, ...restState } = state; - - const channelStateContextValue = useCreateChannelStateContext({ - ...restState, - channel, - channelCapabilitiesArray, - channelConfig, - channelUnreadUiState, - giphyVersion: props.giphyVersion || 'fixed_height', - imageAttachmentSizeHandler: - props.imageAttachmentSizeHandler || getImageAttachmentConfiguration, - mutes, - notifications, - shouldGenerateVideoThumbnail: props.shouldGenerateVideoThumbnail || true, - videoAttachmentSizeHandler: - props.videoAttachmentSizeHandler || getVideoAttachmentConfiguration, - watcher_count: state.watcherCount, - }); - - const channelActionContextValue: ChannelActionContextValue = useMemo( - () => ({ - addNotification, - closeThread, - deleteMessage, - dispatch, - editMessage, - jumpToFirstUnreadMessage, - jumpToLatestMessage, - jumpToMessage, - loadMore, - loadMoreNewer, - loadMoreThread, - markRead, - onMentionsClick: onMentionsHoverOrClick, - onMentionsHover: onMentionsHoverOrClick, - openThread, - removeMessage, - retrySendMessage, - sendMessage, - setChannelUnreadUiState, - skipMessageDataMemoization, - updateMessage, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - channel.cid, - deleteMessage, - loadMore, - loadMoreNewer, - markRead, - jumpToFirstUnreadMessage, - jumpToMessage, - jumpToLatestMessage, - setChannelUnreadUiState, - ], - ); - - const typingContextValue = useCreateTypingContext({ - typing, - }); + clearSearchFocusedMessageTimeoutId.current = setTimeout(() => { + if (searchController._internalState.getLatestValue().focusedMessage) { + searchController._internalState.partialNext({ focusedMessage: undefined }); + } + clearSearchFocusedMessageTimeoutId.current = null; + }, DEFAULT_HIGHLIGHT_DURATION); + }, [ + channel.messagePaginator, + jumpToMessageFromSearch, + searchController._internalState, + ]); - if (state.error) { + if (isBootstrapping && LoadingIndicator) { return ( - + ); } - if (state.loading) { + if (bootstrapError && LoadingErrorIndicator) { return ( - + ); } @@ -1152,15 +431,11 @@ const ChannelInner = ( return ( - - - - -
    {children}
    -
    -
    -
    -
    + + +
    {children}
    +
    +
    ); }; @@ -1168,8 +443,6 @@ const ChannelInner = ( /** * A wrapper component that provides channel data and renders children. * The Channel component provides the following contexts: - * - [ChannelStateContext](https://getstream.io/chat/docs/sdk/react/contexts/channel_state_context/) - * - [ChannelActionContext](https://getstream.io/chat/docs/sdk/react/contexts/channel_action_context/) * - [ComponentContext](https://getstream.io/chat/docs/sdk/react/contexts/component_context/) * - [TypingContext](https://getstream.io/chat/docs/sdk/react/contexts/typing_context/) */ diff --git a/src/components/Channel/ChannelSlot.tsx b/src/components/Channel/ChannelSlot.tsx new file mode 100644 index 0000000000..e463384c1b --- /dev/null +++ b/src/components/Channel/ChannelSlot.tsx @@ -0,0 +1,68 @@ +import React, { useEffect, useRef } from 'react'; + +import { useChatViewNavigation, useSlotChannel } from '../ChatView'; +import { Channel as ChannelComponent, type ChannelProps } from './Channel'; + +import type { PropsWithChildren, ReactNode } from 'react'; +import type { SlotName } from '../ChatView/layoutController/layoutControllerTypes'; + +export type ChannelSlotProps = PropsWithChildren< + Omit & { + /** + * Rendered when no channel entity can be resolved from the inspected slot(s). + */ + fallback?: ReactNode; + /** + * Explicit layout slot to resolve a channel from. + * + * - If provided: this ChannelSlot only inspects that slot. + * - If omitted: ChannelSlot inspects `availableSlots` and + * renders the first slot currently bound to a `kind: 'channel'` entity. + * + * In multi-workspace layouts, pass `slot` for each ChannelSlot instance to + * avoid multiple ChannelSlots resolving the same active channel. + */ + slot?: SlotName; + } +>; + +/** + * ChatView-aware channel adapter. + * + * Expected usage: + * - Single channel workspace: render one `` without `slot`. + * - Multi-channel workspace: render one `` per slot. + * + * If your app already resolves channel instances directly from layout bindings + * (e.g. via custom `slotRenderers`), you can render `` + * directly and skip ChannelSlot. + */ +export const ChannelSlot = ({ + children, + fallback = null, + slot, + ...channelProps +}: ChannelSlotProps) => { + const { openChannel } = useChatViewNavigation(); + const channel = useSlotChannel({ slot }); + const existingChannel = useSlotChannel(); + const lastClaimKeyRef = useRef(undefined); + + useEffect(() => { + if (!slot || channel || !existingChannel?.cid) return; + + const claimKey = `${slot}:${existingChannel.cid}`; + if (lastClaimKeyRef.current === claimKey) return; + + lastClaimKeyRef.current = claimKey; + openChannel(existingChannel, { slot }); + }, [channel, existingChannel, openChannel, slot]); + + if (!channel) return <>{fallback}; + + return ( + + {children} + + ); +}; diff --git a/src/components/Channel/__tests__/Channel.test.js b/src/components/Channel/__tests__/Channel.test.js index 7266ddeda4..45e22f91fb 100644 --- a/src/components/Channel/__tests__/Channel.test.js +++ b/src/components/Channel/__tests__/Channel.test.js @@ -1,120 +1,91 @@ -import { nanoid } from 'nanoid'; -import React, { useEffect } from 'react'; +import React from 'react'; import { SearchController } from 'stream-chat'; -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import { Channel } from '../Channel'; -import { Chat } from '../../Chat'; -import { LoadingErrorIndicator } from '../../Loading'; -import { useChannelActionContext } from '../../../context/ChannelActionContext'; -import { useChannelStateContext } from '../../../context/ChannelStateContext'; -import { ChatProvider, useChatContext } from '../../../context/ChatContext'; -import { useComponentContext } from '../../../context/ComponentContext'; +import { ChatProvider, useChannel } from '../../../context'; +import { WithComponents } from '../../../context/WithComponents'; import { - dispatchChannelTruncatedEvent, generateChannel, - generateFileAttachment, generateMember, generateMessage, - generateScrapedDataAttachment, generateUser, getOrCreateChannelApi, getTestClientWithUser, - initClientWithChannels, - sendMessageApi, - threadRepliesApi, useMockedApis, } from '../../../mock-builders'; -import { MessageList } from '../../MessageList'; -import { Thread } from '../../Thread'; -import { WithComponents } from '../../../context'; -import { DEFAULT_THREAD_PAGE_SIZE } from '../../../constants/limits'; -import { generateMessageDraft } from '../../../mock-builders/generator/messageDraft'; +import { DEFAULT_HIGHLIGHT_DURATION } from '../../../constants/limits'; jest.mock('../../Loading', () => ({ LoadingChannel: jest.fn(() =>
    Loading channel
    ), - LoadingErrorIndicator: jest.fn(() =>
    ), - LoadingIndicator: jest.fn(() =>
    loading
    ), + LoadingErrorIndicator: jest.fn(({ error }) => ( +
    {error?.message || 'error'}
    + )), + LoadingIndicator: jest.fn(() =>
    loading
    ), })); -const queryChannelWithNewMessages = (newMessages, channel) => - // generate new channel mock from existing channel with new messages added - getOrCreateChannelApi( - generateChannel({ - channel: { - config: channel.getConfig(), - id: channel.id, - type: channel.type, - }, - messages: newMessages, - }), - ); - -const MockAvatar = ({ name }) => ( -
    - {name} -
    +const LoadingIndicator = () =>
    loading
    ; +const LoadingErrorIndicator = ({ error }) => ( +
    {error?.message || 'error'}
    ); -// This component is used for performing effects in a component that consumes the contexts from Channel, -// i.e. making use of the callbacks & values provided by the Channel component. -// the effect is called every time channelContext changes -const CallbackEffectWithChannelContexts = ({ callback }) => { - const channelStateContext = useChannelStateContext(); - const channelActionContext = useChannelActionContext(); - const componentContext = useComponentContext(); - - // eslint-disable-next-line react-hooks/exhaustive-deps - const channelContext = { - ...channelStateContext, - ...channelActionContext, - ...componentContext, - }; - - useEffect(() => { - callback(channelContext); - }, [callback, channelContext]); - - return null; -}; +const createChannelsQueryState = (overrides = {}) => ({ + error: null, + queryInProgress: null, + setError: jest.fn(), + setQueryInProgress: jest.fn(), + ...overrides, +}); -// In order for ChannelInner to be rendered, we need to set the active channel first. -const ActiveChannelSetter = ({ activeChannel }) => { - const { setActiveChannel } = useChatContext(); - useEffect(() => { - setActiveChannel(activeChannel); - }, [activeChannel]); // eslint-disable-line - return null; +const createChatContextValue = ({ + channelsQueryState, + client, + searchController, +} = {}) => { + const resolvedClient = client; + return { + channelsQueryState: channelsQueryState || createChannelsQueryState(), + client: resolvedClient, + getAppSettings: () => null, + latestMessageDatesByChannels: {}, + openMobileNav: jest.fn(), + searchController: + searchController || new SearchController({ client: resolvedClient }), + theme: 'str-chat__theme-light', + useImageFlagEmojisOnWindows: false, + }; }; -const renderComponent = async (props = {}, callback = () => {}) => { - const { - channel: channelFromProps, - chatClient: chatClientFromProps, - children, - components, - ...channelProps - } = props; - let result; - await act(() => { - result = render( - - - - - {children} - - - - , - ); - }); - return result; -}; +const renderChannel = ({ + channel, + channelProps, + chatContext, + children, + componentOverrides, +}) => + render( + + + + {children} + + + , + ); -const initClient = async ({ channelId, channelType, messages, pinnedMessages, user }) => { +const initClient = async ({ + channelId = 'channel-id', + channelType = 'messaging', + user, +}) => { const members = [generateMember({ user })]; const mockedChannel = generateChannel({ channel: { @@ -122,2189 +93,354 @@ const initClient = async ({ channelId, channelType, messages, pinnedMessages, us type: channelType, }, members, - messages, - pinnedMessages, + messages: [generateMessage()], }); - const chatClient = await getTestClientWithUser(user); - // eslint-disable-next-line react-hooks/rules-of-hooks - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.channel.id); - - jest.spyOn(channel, 'getConfig').mockImplementation(() => mockedChannel.channel.config); - return { channel, chatClient }; -}; -const MockMessageList = () => { - const { messages: channelMessages } = useChannelStateContext(); + const client = await getTestClientWithUser(user); + // eslint-disable-next-line react-hooks/rules-of-hooks + useMockedApis(client, [getOrCreateChannelApi(mockedChannel)]); + const channel = client.channel(channelType, mockedChannel.channel.id); - return channelMessages.map( - ({ id, status, text }) => - status !== 'failed' &&
    {text}
    , - ); + return { channel, client }; }; describe('Channel', () => { - const user = generateUser({ custom: 'custom-value', id: 'id', name: 'name' }); - const channelType = 'messaging'; - let channelId; - let channel; - let chatClient; - let messages; - - beforeEach(async () => { - channelId = nanoid(); - - // create a full message state so that we can properly test `loadMore` - messages = Array.from({ length: 25 }, (_, i) => - generateMessage({ - cid: `${channelType}:${channelId}`, - created_at: new Date((i + 1) * 1000000), - user, - }), - ); + const user = generateUser({ id: 'user-id' }); - const pinnedMessages = [ - generateMessage({ - cid: `${channelType}:${channelId}`, - pinned: true, - user, - }), - ]; + it('renders EmptyPlaceholder when channel is not provided', async () => { + const { client } = await initClient({ user }); + const chatContext = createChatContextValue({ client }); - ({ channel, chatClient } = await initClient({ - channelId, - channelType, - messages, - pinnedMessages, - user, - })); - jest.spyOn(channel, 'getDraft').mockResolvedValue({ - draft: generateMessageDraft({ channel, channel_cid: channel.cid }), + renderChannel({ + channel: undefined, + channelProps: { + EmptyPlaceholder:
    No channel
    , + }, + chatContext, }); - }); - afterEach(() => { - jest.clearAllMocks(); + expect(screen.getByTestId('empty-placeholder')).toBeInTheDocument(); }); - it('should render the EmptyPlaceholder prop if the channel is not provided by the ChatContext', async () => { - // get rid of console warnings as they are expected - Channel reaches to ChatContext - jest.spyOn(console, 'warn').mockImplementationOnce(() => null); - render( - - empty
    } /> - , - ); + it('renders loading indicator while channels query is in reload state', async () => { + const { channel, client } = await initClient({ user }); + const chatContext = createChatContextValue({ + channelsQueryState: createChannelsQueryState({ queryInProgress: 'reload' }), + client, + }); - await waitFor(() => expect(screen.getByText('empty')).toBeInTheDocument()); - }); + renderChannel({ channel, chatContext }); - it('should render channel content if channels query loads more channels', async () => { - const childrenContent = 'Channel children'; - await channel.watch(); - render( - - {childrenContent} - , - ); - await waitFor(() => expect(screen.getByText(childrenContent)).toBeInTheDocument()); + expect(screen.getByTestId('loading-indicator')).toBeInTheDocument(); }); - it('should render default loading indicator if channels query is in progress', async () => { - const childrenContent = 'Channel children'; - const { asFragment } = render( - - {childrenContent} - , - ); - await waitFor(() => expect(asFragment()).toMatchSnapshot()); - }); + it('renders loading error indicator when channels query has error', async () => { + const { channel, client } = await initClient({ user }); + const chatContext = createChatContextValue({ + channelsQueryState: createChannelsQueryState({ + error: new Error('channels query failed'), + }), + client, + }); - it('should render empty channel container if channel does not have cid', async () => { - const childrenContent = 'Channel children'; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { cid, ...channelWithoutCID } = channel; - const { asFragment } = render( - - {childrenContent} - , - ); - await waitFor(() => expect(asFragment()).toMatchSnapshot()); - }); + renderChannel({ channel, chatContext }); - it('should render empty channel container if channels query failed', async () => { - const childrenContent = 'Channel children'; - const { asFragment } = render( - - {childrenContent} - , + expect(screen.getByTestId('loading-error')).toHaveTextContent( + 'channels query failed', ); - await waitFor(() => expect(asFragment()).toMatchSnapshot()); }); - it('should render provided loading indicator if channels query is in progress', async () => { - const childrenContent = 'Channel children'; - const loadingText = 'Loading channels'; - render( - -
    {loadingText}
    , - }} - > - {childrenContent} -
    -
    , - ); - await waitFor(() => expect(screen.getByText(loadingText)).toBeInTheDocument()); - }); + it('bootstraps an uninitialized channel and applies query options', async () => { + const { channel, client } = await initClient({ user }); + const chatContext = createChatContextValue({ client }); + const watchPromise = new Promise(() => {}); + const watchSpy = jest.spyOn(channel, 'watch').mockReturnValue(watchPromise); - it('should render provided error indicator if channels query failed', async () => { - const childrenContent = 'Channel children'; - const errMsg = 'Channels query failed'; - render( - -
    {error.message}
    , - }} - > - {childrenContent} -
    -
    , + renderChannel({ + channel, + channelProps: { + channelQueryOptions: { messages: { limit: 5 } }, + }, + chatContext, + }); + + expect(screen.getByTestId('loading-indicator')).toBeInTheDocument(); + await waitFor(() => + expect(watchSpy).toHaveBeenCalledWith( + expect.objectContaining({ messages: { limit: 5 } }), + ), ); - await waitFor(() => expect(screen.getByText(errMsg)).toBeInTheDocument()); }); - it('should watch the current channel on mount', async () => { - const watchSpy = jest.spyOn(channel, 'watch'); + it('shows bootstrap error indicator when channel initialization fails', async () => { + const { channel, client } = await initClient({ user }); + const chatContext = createChatContextValue({ client }); - await renderComponent({ - channel, - channelQueryOptions: { messages: { limit: 25 } }, - chatClient, - }); + jest.spyOn(channel, 'watch').mockRejectedValueOnce(new Error('watch failed')); - await waitFor(() => { - expect(watchSpy).toHaveBeenCalledTimes(1); - expect(watchSpy).toHaveBeenCalledWith({ messages: { limit: 25 } }); - }); + renderChannel({ channel, chatContext }); + + await waitFor(() => + expect(screen.getByTestId('loading-error')).toHaveTextContent('watch failed'), + ); }); - it('should apply channelQueryOptions to channel watch call', async () => { + it('does not initialize channel when initializeOnMount is false', async () => { + const { channel, client } = await initClient({ user }); + channel.initialized = false; + const chatContext = createChatContextValue({ client }); const watchSpy = jest.spyOn(channel, 'watch'); - const channelQueryOptions = { - messages: { limit: 20 }, - }; - await renderComponent({ channel, channelQueryOptions, chatClient }); - await waitFor(() => { - expect(watchSpy).toHaveBeenCalledTimes(1); - expect(watchSpy).toHaveBeenCalledWith(channelQueryOptions); + renderChannel({ + channel, + channelProps: { initializeOnMount: false }, + chatContext, + children:
    content
    , }); + + expect(watchSpy).not.toHaveBeenCalled(); + expect(screen.getByTestId('channel-content')).toBeInTheDocument(); }); - it('should set hasMore state to false if the initial channel query returns less messages than the default initial page size', async () => { - useMockedApis(chatClient, [ - queryChannelWithNewMessages([generateMessage()], channel), - ]); - let hasMore; - await renderComponent({ channel, chatClient }, ({ hasMore: contextHasMore }) => { - hasMore = contextHasMore; + it('marks channel as read on mount when enabled and unread exists', async () => { + const { channel, client } = await initClient({ user }); + channel.initialized = true; + client.configsStore.next({ + configs: { + [channel.cid]: { read_events: true }, + }, }); + const chatContext = createChatContextValue({ client }); - await waitFor(() => { - expect(hasMore).toBe(false); + jest.spyOn(channel, 'countUnread').mockReturnValue(1); + const markReadSpy = jest.spyOn(channel, 'markRead').mockResolvedValue({ + event: { last_read_message_id: 'last-read-id' }, }); - }); - // this will only happen if we: - // load with channel A - // switch to channel B and paginate (loadMore - older) - // switch back to channel A (reset hasMore) - // switch back to channel B - messages are already cached and there's more than page size amount - it('should set hasMore state to true if the initial channel query returns more messages than the default initial page size', async () => { - useMockedApis(chatClient, [ - queryChannelWithNewMessages(Array.from({ length: 26 }, generateMessage), channel), - ]); - let hasMore; - await act(() => { - renderComponent( - { channel, channelQueryOptions: { messages: { limit: 25 } }, chatClient }, - ({ hasMore: contextHasMore }) => { - hasMore = contextHasMore; - }, - ); - }); + renderChannel({ channel, chatContext }); - await waitFor(() => { - expect(hasMore).toBe(true); - }); + await waitFor(() => expect(markReadSpy).toHaveBeenCalledTimes(1)); }); - it('should set hasMore state to true if the initial channel query returns count of messages equal to the default initial page size', async () => { - useMockedApis(chatClient, [ - queryChannelWithNewMessages(Array.from({ length: 25 }, generateMessage), channel), - ]); - let hasMore; - await renderComponent( - { channel, channelQueryOptions: { messages: { limit: 25 } }, chatClient }, - ({ hasMore: contextHasMore }) => { - hasMore = contextHasMore; + it('does not mark channel as read on mount when markReadOnMount is false', async () => { + const { channel, client } = await initClient({ user }); + channel.initialized = true; + client.configsStore.next({ + configs: { + [channel.cid]: { read_events: true }, }, - ); - - await waitFor(() => { - expect(hasMore).toBe(true); }); - }); + const chatContext = createChatContextValue({ client }); - it('should set hasMore state to false if the initial channel query returns less messages than the custom query channels options message limit', async () => { - useMockedApis(chatClient, [ - queryChannelWithNewMessages([generateMessage()], channel), - ]); - let hasMore; - const channelQueryOptions = { - messages: { limit: 10 }, - }; - await renderComponent( - { channel, channelQueryOptions, chatClient }, - ({ hasMore: contextHasMore }) => { - hasMore = contextHasMore; - }, - ); + jest.spyOn(channel, 'countUnread').mockReturnValue(1); + const markReadSpy = jest.spyOn(channel, 'markRead'); - await waitFor(() => { - expect(hasMore).toBe(false); + renderChannel({ + channel, + channelProps: { markReadOnMount: false }, + chatContext, }); + + await waitFor(() => expect(markReadSpy).not.toHaveBeenCalled()); }); - it('should set hasMore state to true if the initial channel query returns count of messages equal custom query channels options message limit', async () => { - const equalCount = 10; - useMockedApis(chatClient, [ - queryChannelWithNewMessages( - Array.from({ length: equalCount }, generateMessage), - channel, - ), - ]); - let hasMore; - const channelQueryOptions = { - messages: { limit: equalCount }, - }; - await renderComponent( - { channel, channelQueryOptions, chatClient }, - ({ hasMore: contextHasMore }) => { - hasMore = contextHasMore; + it('passes own read timestamp to countUnread on mount', async () => { + const { channel, client } = await initClient({ user }); + channel.initialized = true; + client.configsStore.next({ + configs: { + [channel.cid]: { read_events: true }, }, - ); - - await waitFor(() => { - expect(hasMore).toBe(true); }); + const chatContext = createChatContextValue({ client }); + const lastReadIso = '2026-03-01T10:20:30.000Z'; + channel.state.read[client.userID] = { + ...channel.state.read[client.userID], + last_read: lastReadIso, + user, + }; + const countUnreadSpy = jest.spyOn(channel, 'countUnread').mockReturnValue(0); + + renderChannel({ channel, chatContext }); + + await waitFor(() => expect(countUnreadSpy).toHaveBeenCalledTimes(1)); + const passedLastReadAt = countUnreadSpy.mock.calls[0]?.[0]; + expect(passedLastReadAt).toEqual(new Date(lastReadIso)); }); - it('should not call watch the current channel on mount if channel is initialized', async () => { - const watchSpy = jest.spyOn(channel, 'watch'); + it('registers and unregisters event listeners on mount/unmount', async () => { + const { channel, client } = await initClient({ user }); channel.initialized = true; - await renderComponent({ channel, chatClient }); - await waitFor(() => expect(watchSpy).not.toHaveBeenCalled()); - }); + const chatContext = createChatContextValue({ client }); - it('should set an error if watching the channel goes wrong, and render a LoadingErrorIndicator', async () => { - const watchError = new Error('watching went wrong'); - jest.spyOn(channel, 'watch').mockImplementationOnce(() => Promise.reject(watchError)); + const clientOnSpy = jest.spyOn(client, 'on'); + const clientOffSpy = jest.spyOn(client, 'off'); + const channelOnSpy = jest.spyOn(channel, 'on'); + const channelOffSpy = jest.spyOn(channel, 'off'); - await renderComponent({ channel, chatClient }); + const view = renderChannel({ channel, chatContext }); - await waitFor(() => - expect(LoadingErrorIndicator).toHaveBeenCalledWith( - expect.objectContaining({ - error: watchError, - }), - undefined, - ), - ); - }); + await waitFor(() => { + expect(clientOnSpy).toHaveBeenCalledWith( + 'connection.changed', + expect.any(Function), + ); + expect(clientOnSpy).toHaveBeenCalledWith( + 'connection.recovered', + expect.any(Function), + ); + expect(clientOnSpy).toHaveBeenCalledWith('user.updated', expect.any(Function)); + expect(clientOnSpy).toHaveBeenCalledWith('user.deleted', expect.any(Function)); + expect(clientOnSpy).toHaveBeenCalledWith( + 'user.messages.deleted', + expect.any(Function), + ); + expect(channelOnSpy).toHaveBeenCalledWith(expect.any(Function)); + }); - it('should render a LoadingIndicator if it is loading', async () => { - const watchPromise = new Promise(() => {}); - jest.spyOn(channel, 'watch').mockImplementationOnce(() => watchPromise); - const result = await renderComponent({ channel, chatClient }); + view.unmount(); - await waitFor(() => expect(result.asFragment()).toMatchSnapshot()); + expect(channelOffSpy).toHaveBeenCalledWith(expect.any(Function)); + expect(clientOffSpy).toHaveBeenCalledWith('connection.changed', expect.any(Function)); + expect(clientOffSpy).toHaveBeenCalledWith( + 'connection.recovered', + expect.any(Function), + ); + expect(clientOffSpy).toHaveBeenCalledWith('user.deleted', expect.any(Function)); }); - it('should provide context and render children if channel is set and the component is not loading or errored', async () => { - const { findByText } = await renderComponent({ + it('uses channelQueryOptions prop when refetching on user.deleted event', async () => { + const { channel, client } = await initClient({ user }); + channel.initialized = true; + const chatContext = createChatContextValue({ client }); + const clientOnSpy = jest.spyOn(client, 'on'); + const querySpy = jest.spyOn(channel, 'query').mockResolvedValue({}); + const oldestId = channel.state.messages[0]?.id; + const channelQueryOptions = { + members: { limit: 15 }, + messages: { limit: 7 }, + state: true, + watchers: { limit: 3 }, + }; + + renderChannel({ channel, - chatClient, - children:
    children
    , + channelProps: { channelQueryOptions }, + chatContext, }); - expect(await findByText('children')).toBeInTheDocument(); - }); + await waitFor(() => + expect(clientOnSpy).toHaveBeenCalledWith('user.deleted', expect.any(Function)), + ); + const userDeletedHandler = clientOnSpy.mock.calls.find( + (call) => call[0] === 'user.deleted', + )?.[1]; + expect(userDeletedHandler).toEqual(expect.any(Function)); - it('should store pinned messages as an array in the channel context', async () => { - let ctxPins; + await act(async () => { + await userDeletedHandler({ type: 'user.deleted' }); + }); - const { getByText } = await renderComponent( - { - channel, - chatClient, - children:
    children
    , + expect(querySpy).toHaveBeenCalledWith({ + ...channelQueryOptions, + messages: { + ...channelQueryOptions.messages, + id_lt: oldestId, + limit: channelQueryOptions.messages.limit, }, - (ctx) => { - ctxPins = ctx.pinnedMessages; - }, - ); - - await waitFor(() => { - expect(getByText('children')).toBeInTheDocument(); - expect(Array.isArray(ctxPins)).toBe(true); + watchers: channelQueryOptions.watchers, }); }); - // should these 'on' tests actually test if the handler works? - it('should add a connection recovery handler on the client on mount', async () => { - const clientOnSpy = jest.spyOn(chatClient, 'on'); + it('jumps to focused message from search and clears the focus after ttl', async () => { + jest.useFakeTimers(); + + const { channel, client } = await initClient({ user }); + channel.initialized = true; + const searchController = new SearchController({ client }); + const chatContext = createChatContextValue({ client, searchController }); + const jumpToMessageSpy = jest + .spyOn(channel.messagePaginator, 'jumpToMessage') + .mockResolvedValue(true); + + searchController._internalState.partialNext({ + focusedMessage: { id: 'focused-message-id' }, + }); - await renderComponent({ channel, chatClient }); + renderChannel({ channel, chatContext }); await waitFor(() => - expect(clientOnSpy).toHaveBeenCalledWith( - 'connection.recovered', - expect.any(Function), - ), + expect(jumpToMessageSpy).toHaveBeenCalledWith('focused-message-id', { + focusReason: 'jump-to-message', + focusSignalTtlMs: DEFAULT_HIGHLIGHT_DURATION, + }), ); - }); - - it('should add an `on` handler to the channel on mount', async () => { - const channelOnSpy = jest.spyOn(channel, 'on'); - await renderComponent({ channel, chatClient }); - - await waitFor(() => expect(channelOnSpy).toHaveBeenCalledWith(expect.any(Function))); - }); - it('should mark the channel as read when the channel is mounted', async () => { - jest.spyOn(channel, 'countUnread').mockImplementationOnce(() => 1); - const markReadSpy = jest.spyOn(channel, 'markRead'); + act(() => { + jest.advanceTimersByTime(DEFAULT_HIGHLIGHT_DURATION); + }); - await renderComponent({ channel, chatClient }); + expect( + searchController._internalState.getLatestValue().focusedMessage, + ).toBeUndefined(); - await waitFor(() => expect(markReadSpy).toHaveBeenCalledWith()); + jest.useRealTimers(); }); - it('should not mark the channel as read if the count of unread messages is higher than 0 on mount and the feature is disabled', async () => { - jest.spyOn(channel, 'countUnread').mockImplementationOnce(() => 1); - const markReadSpy = jest.spyOn(channel, 'markRead'); + it('renders Channel Missing when channel.watch is not available', async () => { + const { channel, client } = await initClient({ user }); + channel.initialized = true; + channel.watch = undefined; + const chatContext = createChatContextValue({ client }); - await renderComponent({ channel, chatClient, markReadOnMount: false }); + renderChannel({ channel, chatContext }); - await waitFor(() => expect(markReadSpy).not.toHaveBeenCalledWith()); + expect(screen.getByText('Channel Missing')).toBeInTheDocument(); }); - it('should use the doMarkReadRequest prop to mark channel as read, if that is defined', async () => { - jest.spyOn(channel, 'countUnread').mockImplementationOnce(() => 1); - const doMarkReadRequest = jest.fn(); - - await renderComponent({ - channel, - chatClient, - doMarkReadRequest, - markReadOnMount: true, - }); + it('provides channel instance via ChannelInstanceProvider', async () => { + const { channel, client } = await initClient({ user }); + channel.initialized = true; + const chatContext = createChatContextValue({ client }); - await waitFor(() => expect(doMarkReadRequest).toHaveBeenCalledTimes(1)); - }); + const TestChild = () => { + const providedChannel = useChannel(); + return
    {providedChannel.cid}
    ; + }; - it('should not query the channel from the backend when initializeOnMount is disabled', async () => { - const watchSpy = jest.spyOn(channel, 'watch').mockImplementationOnce(); - await renderComponent({ - channel, - chatClient, - initializeOnMount: false, - }); - await waitFor(() => expect(watchSpy).not.toHaveBeenCalled()); - }); + renderChannel({ channel, chatContext, children: }); - it('should query the channel from the backend when initializeOnMount is enabled (the default)', async () => { - const watchSpy = jest.spyOn(channel, 'watch').mockImplementationOnce(); - await renderComponent({ channel, chatClient }); - await waitFor(() => expect(watchSpy).toHaveBeenCalledTimes(1)); + expect(screen.getByTestId('provided-cid')).toHaveTextContent(channel.cid); }); - describe('Children that consume the contexts set in Channel', () => { - it('should be able to open threads', async () => { - const threadMessage = messages[0]; - const hasThread = jest.fn(); - const hasThreadInstance = jest.fn(); - const mockThreadInstance = { - registerSubscriptions: jest.fn(), - threadInstanceMock: true, - }; - const getThreadSpy = jest - .spyOn(chatClient, 'getThread') - .mockResolvedValueOnce(mockThreadInstance); - - // this renders Channel, calls openThread from a child context consumer with a message, - // and then calls hasThread with the thread id if it was set. - await renderComponent( - { channel, chatClient }, - ({ openThread, thread, threadInstance }) => { - if (!thread) { - openThread(threadMessage, { preventDefault: () => null }); - } else { - hasThread(thread.id); - hasThreadInstance(threadInstance); - } - }, - ); - - await waitFor(() => { - expect(hasThread).toHaveBeenCalledWith(threadMessage.id); - expect(getThreadSpy).not.toHaveBeenCalled(); - expect(hasThreadInstance).toHaveBeenCalledWith(undefined); - }); - getThreadSpy.mockRestore(); - }); - - it('should be able to load more messages in a thread until reaching the end', async () => { - const getRepliesSpy = jest.spyOn(channel, 'getReplies'); - const threadMessage = messages[0]; - const timestamp = new Date('2024-01-01T00:00:00.000Z').getTime(); - const replies = Array.from({ length: DEFAULT_THREAD_PAGE_SIZE }, (_, index) => - generateMessage({ - created_at: new Date(timestamp + index * 1000), - parent_id: threadMessage.id, - }), - ); - - useMockedApis(chatClient, [threadRepliesApi(replies)]); - - const hasThreadMessages = jest.fn(); - - let callback = ({ loadMoreThread, openThread, thread, threadMessages }) => { - if (!thread) { - // first, open a thread - openThread(threadMessage, { preventDefault: () => null }); - } else if (!threadMessages.length) { - // then, load more messages in the thread - loadMoreThread(); - } else { - // then, call our mock fn so we can verify what was passed as threadMessages - hasThreadMessages(threadMessages); - } - }; - const { rerender } = await render( - - - - - , - ); - - await waitFor(() => { - expect(getRepliesSpy).toHaveBeenCalledTimes(1); - expect(getRepliesSpy).toHaveBeenCalledWith(threadMessage.id, expect.any(Object)); - expect(hasThreadMessages).toHaveBeenCalledWith(replies); - }); - - useMockedApis(chatClient, [threadRepliesApi([])]); - callback = ({ loadMoreThread }) => { - loadMoreThread(); - }; - await act(() => { - rerender( - - - - - , - ); - }); - expect(getRepliesSpy).toHaveBeenCalledTimes(2); - await act(() => { - rerender( - - - - - , - ); - }); - expect(getRepliesSpy).toHaveBeenCalledTimes(2); - }); - - it('should allow closing a thread after it has been opened', async () => { - let threadHasClosed = false; - const threadMessage = messages[0]; - - let threadHasAlreadyBeenOpened = false; - await renderComponent( - { channel, chatClient }, - ({ closeThread, openThread, thread }) => { - if (!thread) { - // if there is no open thread - if (!threadHasAlreadyBeenOpened) { - // and we haven't opened one before, open a thread - openThread(threadMessage, { preventDefault: () => null }); - threadHasAlreadyBeenOpened = true; - } else { - // if we opened it ourselves before, it means the thread was successfully closed - threadHasClosed = true; - } - } else { - // if a thread is open, close it. - closeThread({ preventDefault: () => null }); - } - }, - ); - - await waitFor(() => expect(threadHasClosed).toBe(true)); - }); - - it('should call the onMentionsHover/onMentionsClick prop if a child component calls onMentionsHover with the right event', async () => { - const onMentionsHoverMock = jest.fn(); - const onMentionsClickMock = jest.fn(); - const username = 'Mentioned User'; - const mentionedUserMock = { - name: username, - }; - - const MentionedUserComponent = () => { - const { onMentionsHover } = useChannelActionContext(); - return ( - onMentionsHover(e, [mentionedUserMock])} - onMouseOver={(e) => onMentionsHover(e, [mentionedUserMock])} - > - @{username} this is a message - - ); - }; - - const { findByText } = await renderComponent({ - channel, - chatClient, - children: , - onMentionsClick: onMentionsClickMock, - onMentionsHover: onMentionsHoverMock, - }); - - const usernameText = await findByText(`@${username}`); - - act(() => { - fireEvent.mouseOver(usernameText); - fireEvent.click(usernameText); - }); - - await waitFor(() => - expect(onMentionsHoverMock).toHaveBeenCalledWith( - expect.any(Object), // event - mentionedUserMock, - ), - ); - await waitFor(() => - expect(onMentionsClickMock).toHaveBeenCalledWith( - expect.any(Object), // event - mentionedUserMock, - ), - ); - }); - - describe('loading more messages', () => { - const limit = 10; - it("should initiate the hasMore flag with the current message set's pagination hasPrev value", async () => { - let hasMore; - await renderComponent( - { channel, channelQueryOptions: { messages: { limit: 25 } }, chatClient }, - ({ hasMore: hasMoreCtx }) => { - hasMore = hasMoreCtx; - }, - ); - expect(hasMore).toBe(true); - - channel.state.messageSets[0].pagination.hasPrev = false; - await renderComponent( - { channel, channelQueryOptions: { messages: { limit: 25 } }, chatClient }, - ({ hasMore: hasMoreCtx }) => { - hasMore = hasMoreCtx; - }, - ); - expect(hasMore).toBe(false); - }); - it('should be able to load more messages', async () => { - const channelQuerySpy = jest.spyOn(channel, 'query'); - let newMessageAdded = false; - - const newMessages = [generateMessage()]; - - await renderComponent( - { channel, channelQueryOptions: { messages: { limit: 25 } }, chatClient }, - ({ loadMore, messages: contextMessages }) => { - if (!contextMessages.find((message) => message.id === newMessages[0].id)) { - // Our new message is not yet passed as part of channel context. Call loadMore and mock API response to include it. - useMockedApis(chatClient, [ - queryChannelWithNewMessages(newMessages, channel), - ]); - loadMore(limit); - } else { - // If message has been added, update checker so we can verify it happened. - newMessageAdded = true; - } - }, - ); - - await waitFor(() => - expect(channelQuerySpy).toHaveBeenCalledWith({ - messages: { - id_lt: messages[0].id, - limit, - }, - watchers: { - limit, - }, - }), - ); - - await waitFor(() => expect(newMessageAdded).toBe(true)); - }); - - it('should set hasMore to false if querying channel returns less messages than the limit', async () => { - let channelHasMore = false; - const newMessages = [generateMessage({ created_at: new Date(1000) })]; - await renderComponent( - { channel, chatClient }, - ({ hasMore, loadMore, messages: contextMessages }) => { - if (!contextMessages.find((message) => message.id === newMessages[0].id)) { - // Our new message is not yet passed as part of channel context. Call loadMore and mock API response to include it. - useMockedApis(chatClient, [ - queryChannelWithNewMessages(newMessages, channel), - ]); - loadMore(limit); - } else { - // If message has been added, set our checker variable, so we can verify if hasMore is false. - channelHasMore = hasMore; - } - }, - ); - - await waitFor(() => expect(channelHasMore).toBe(false)); - }); - - it('should set hasMore to true if querying channel returns an amount of messages that equals the limit', async () => { - let channelHasMore = false; - const newMessages = Array(limit) - .fill(null) - .map(() => generateMessage()); - await renderComponent( - { channel, channelQueryOptions: { messages: { limit: 25 } }, chatClient }, - ({ hasMore, loadMore, messages: contextMessages }) => { - if (!contextMessages.some((message) => message.id === newMessages[0].id)) { - // Our new messages are not yet passed as part of channel context. Call loadMore and mock API response to include it. - useMockedApis(chatClient, [ - queryChannelWithNewMessages(newMessages, channel), - ]); - loadMore(limit); - } else { - // If message has been added, set our checker variable so we can verify if hasMore is true. - channelHasMore = hasMore; - } - }, - ); - - await waitFor(() => expect(channelHasMore).toBe(true)); - }); - - it('should set loadingMore to true while loading more', async () => { - const queryPromise = new Promise(() => {}); - let isLoadingMore = false; - - await renderComponent( - { channel, channelQueryOptions: { messages: { limit: 25 } }, chatClient }, - ({ loadingMore, loadMore }) => { - // return a promise that hasn't resolved yet, so loadMore will be stuck in the 'await' part of the function - jest.spyOn(channel, 'query').mockImplementationOnce(() => queryPromise); - loadMore(); - isLoadingMore = loadingMore; - }, - ); - await waitFor(() => expect(isLoadingMore).toBe(true)); - }); - - it('should not load the second page, if the previous query has returned less then default limit messages', async () => { - const firstPageOfMessages = [generateMessage()]; - useMockedApis(chatClient, [ - queryChannelWithNewMessages(firstPageOfMessages, channel), - ]); - let queryNextPageSpy; - let contextMessageCount; - await renderComponent( - { channel, chatClient }, - ({ loadMore, messages: contextMessages }) => { - queryNextPageSpy = jest.spyOn(channel, 'query'); - contextMessageCount = contextMessages.length; - loadMore(); - }, - ); - - await waitFor(() => { - expect(queryNextPageSpy).not.toHaveBeenCalled(); - expect(chatClient.axiosInstance.post).toHaveBeenCalledTimes(1); - expect(chatClient.axiosInstance.post.mock.calls[0][1]).toMatchObject( - expect.objectContaining({ - data: {}, - presence: false, - state: true, - watch: false, - }), - ); - expect(contextMessageCount).toBe(firstPageOfMessages.length); - }); - }); - - it('should load the second page, if the previous query has returned message count equal default messages limit', async () => { - const firstPageMessages = Array.from({ length: 25 }, (_, i) => - generateMessage({ created_at: new Date((i + 16) * 100000) }), - ); - const secondPageMessages = Array.from({ length: 15 }, (_, i) => - generateMessage({ created_at: new Date((i + 1) * 100000) }), - ); - useMockedApis(chatClient, [ - queryChannelWithNewMessages(firstPageMessages, channel), - ]); - let queryNextPageSpy; - let contextMessageCount; - await renderComponent( - { channel, channelQueryOptions: { messages: { limit: 25 } }, chatClient }, - ({ loadMore, messages: contextMessages }) => { - queryNextPageSpy = jest.spyOn(channel, 'query'); - contextMessageCount = contextMessages.length; - useMockedApis(chatClient, [ - queryChannelWithNewMessages(secondPageMessages, channel), - ]); - loadMore(); - }, - ); - - await waitFor(() => { - expect(queryNextPageSpy).toHaveBeenCalledTimes(1); - expect(chatClient.axiosInstance.post).toHaveBeenCalledTimes(2); - expect(chatClient.axiosInstance.post.mock.calls[0][1]).toMatchObject({ - data: {}, - presence: false, - state: true, - watch: false, - }); - expect(chatClient.axiosInstance.post.mock.calls[1][1]).toMatchObject( - expect.objectContaining({ - data: {}, - messages: { id_lt: firstPageMessages[0].id, limit: 25 }, - state: true, - watchers: { limit: 25 }, - }), - ); - expect(contextMessageCount).toBe( - firstPageMessages.length + secondPageMessages.length, - ); - }); - }); - it('should not load the second page, if the previous query has returned less then custom limit messages', async () => { - const channelQueryOptions = { - messages: { limit: 10 }, - }; - const firstPageOfMessages = [generateMessage()]; - useMockedApis(chatClient, [ - queryChannelWithNewMessages(firstPageOfMessages, channel), - ]); - let queryNextPageSpy; - let contextMessageCount; - await renderComponent( - { channel, channelQueryOptions, chatClient }, - ({ loadMore, messages: contextMessages }) => { - queryNextPageSpy = jest.spyOn(channel, 'query'); - contextMessageCount = contextMessages.length; - loadMore(channelQueryOptions.messages.limit); - }, - ); - - await waitFor(() => { - expect(queryNextPageSpy).not.toHaveBeenCalled(); - expect(chatClient.axiosInstance.post).toHaveBeenCalledTimes(1); - expect(chatClient.axiosInstance.post.mock.calls[0][1]).toMatchObject({ - data: {}, - messages: { - limit: channelQueryOptions.messages.limit, - }, - presence: false, - state: true, - watch: false, - }); - expect(contextMessageCount).toBe(firstPageOfMessages.length); - }); - }); - it('should load the second page, if the previous query has returned message count equal custom messages limit', async () => { - const equalCount = 10; - const channelQueryOptions = { - messages: { limit: equalCount }, - }; - const firstPageMessages = Array.from({ length: equalCount }, (_, i) => - generateMessage({ created_at: new Date((i + 1 + equalCount) * 100000) }), - ); - const secondPageMessages = Array.from({ length: equalCount - 1 }, (_, i) => - generateMessage({ created_at: new Date((i + 1) * 100000) }), - ); - useMockedApis(chatClient, [ - queryChannelWithNewMessages(firstPageMessages, channel), - ]); - let queryNextPageSpy; - let contextMessageCount; - - await renderComponent( - { channel, channelQueryOptions, chatClient }, - ({ loadMore, messages: contextMessages }) => { - queryNextPageSpy = jest.spyOn(channel, 'query'); - contextMessageCount = contextMessages.length; - useMockedApis(chatClient, [ - queryChannelWithNewMessages(secondPageMessages, channel), - ]); - loadMore(channelQueryOptions.messages.limit); - }, - ); - - await waitFor(() => { - expect(queryNextPageSpy).toHaveBeenCalledTimes(1); - expect(chatClient.axiosInstance.post).toHaveBeenCalledTimes(2); - expect(chatClient.axiosInstance.post.mock.calls[0][1]).toMatchObject({ - data: {}, - messages: { - limit: channelQueryOptions.messages.limit, - }, - presence: false, - state: true, - watch: false, - }); - expect(chatClient.axiosInstance.post.mock.calls[1][1]).toMatchObject( - expect.objectContaining({ - data: {}, - messages: { - id_lt: firstPageMessages[0].id, - limit: channelQueryOptions.messages.limit, - }, - state: true, - watchers: { limit: channelQueryOptions.messages.limit }, - }), - ); - expect(contextMessageCount).toBe( - firstPageMessages.length + secondPageMessages.length, - ); - }); - }); - }); - - describe('jump to first unread message', () => { - const user = generateUser(); - const last_read = new Date(1000); - const last_read_message_id = 'X'; - const first_unread_message_id = 'Y'; - const firtUnreadDate = new Date(1500); - const lastReadMessage = generateMessage({ - created_at: last_read, - id: last_read_message_id, - }); - const firstUnreadMessage = generateMessage({ - created_at: firtUnreadDate, - id: first_unread_message_id, - }); - const currentMessageSetLastReadLoadedFirstUnreadNotLoaded = [ - generateMessage({ created_at: new Date(100) }), - lastReadMessage, - ]; - const currentMessageSetLastReadFirstUnreadLoaded = [ - lastReadMessage, - firstUnreadMessage, - ]; - const currentMessageSetLastReadNotLoadedFirstUnreadLoaded = [ - firstUnreadMessage, - generateMessage(), - ]; - const currentMessageSetFirstUnreadLastReadNotLoaded = [ - generateMessage(), - generateMessage(), - ]; - const errorNotificationText = 'Failed to jump to the first unread message'; - const ownReadStateBase = { - last_read, - unread_messages: 1, - user, - }; - const ownReadStateLastReadMsgIdKnown = { - last_read, - last_read_message_id, - unread_messages: 1, - user, - }; - const ownReadStateFirstUnreadMsgIdKnown = { - first_unread_message_id, - last_read, - last_read_message_id, - unread_messages: 1, - user, - }; - - afterEach(jest.resetAllMocks); - /** - * {channelUnreadUiState: {first_unread_message_id: 'Y', last_read: new Date(1), last_read_message_id: 'X', unread_messages: 9, }, messages: Array.from({length: 10})} // marked channel unread - * {channelUnreadUiState: {first_unread_message_id: undefined, last_read: new Date(1), last_read_message_id: 'X', unread_messages: 9, }, messages: Array.from({length: 10})} // incoming new messages while being scrolled up / open an already read channel with unread messages - * {channelUnreadUiState: {first_unread_message_id: undefined, last_read: new Date(0), last_read_message_id: undefined, unread_messages: 10, }, messages: Array.from({length: 10})} // open a new channel with existing messages - * {channelUnreadUiState: {first_unread_message_id: undefined, last_read: new Date(10), last_read_message_id: 'Z', unread_messages: 0, }, messages: Array.from({length: 10})} // open a fully read channel - * {channelUnreadUiState: {first_unread_message_id: undefined, last_read: new Date(0), last_read_message_id: undefined, unread_messages: 0, }, messages: Array.from({length: 0})} // open an empty unread channel - * {channelUnreadUiState: {first_unread_message_id: undefined, last_read: new Date(1), last_read_message_id: undefined, unread_messages: 0, }, messages: Array.from({length: 0})} // open an empty read channel - */ - it('should exit early if the unread count is falsy', async () => { - const { - channels: [channel], - client: chatClient, - } = await initClientWithChannels({ - channelsData: [ - { - messages: [generateMessage()], - read: [ - { - first_unread_message_id: 'Y', - last_read: new Date().toISOString(), - last_read_message_id: 'X', - unread_messages: 0, - user, - }, - ], - }, - ], - customUser: user, - }); - const loadMessageIntoState = jest - .spyOn(channel.state, 'loadMessageIntoState') - .mockImplementation(); - - const channelQuerySpy = jest.spyOn(channel, 'query').mockImplementation(); - - let hasJumped; - let highlightedMessageId; - await renderComponent( - { channel, chatClient }, - ({ - highlightedMessageId: highlightedMessageIdContext, - jumpToFirstUnreadMessage, - }) => { - if (hasJumped) { - highlightedMessageId = highlightedMessageIdContext; - return; - } - jumpToFirstUnreadMessage(); - hasJumped = true; - }, - ); - - await waitFor(() => { - expect(loadMessageIntoState).not.toHaveBeenCalled(); - expect(channelQuerySpy).not.toHaveBeenCalled(); - expect(highlightedMessageId).toBeUndefined(); - }); - }); - - const runTest = async ({ - channelQueryResolvedValue, - currentMsgSet, - loadScenario, - ownReadState, - }) => { - const { - channels: [channel], - client: chatClient, - } = await initClientWithChannels({ - channelsData: [ - { - messages: currentMsgSet, - read: [ownReadState], - }, - ], - customUser: user, - }); - let loadMessageIntoState; - let channelQuerySpy; - if (['already loaded', 'query fails'].includes(loadScenario)) { - channelQuerySpy = jest.spyOn(channel, 'query').mockImplementation(); - } else { - // eslint-disable-next-line react-hooks/rules-of-hooks - useMockedApis(chatClient, [ - queryChannelWithNewMessages(channelQueryResolvedValue, channel), - ]); - } - if (!loadScenario.startsWith('query by')) { - loadMessageIntoState = jest - .spyOn(channel.state, 'loadMessageIntoState') - .mockImplementation(); - - if (loadScenario === 'query fails') { - loadMessageIntoState.mockRejectedValue('Query failed'); - } - } - - let hasJumped; - let notifications; - let highlightedMessageId; - let channelUnreadUiStateAfterJump; - await act(async () => { - await renderComponent( - { channel, chatClient }, - ({ - channelUnreadUiState, - highlightedMessageId: highlightedMessageIdContext, - jumpToFirstUnreadMessage, - notifications: contextNotifications, - setChannelUnreadUiState, - }) => { - if (!channelUnreadUiState) return; - if ( - ownReadState.first_unread_message_id && - !channelUnreadUiState.first_unread_message_id - ) { - setChannelUnreadUiState(ownReadState); // needed as the first_unread_message_id is not available on channels load - return; - } - if (hasJumped) { - notifications = contextNotifications; - highlightedMessageId = highlightedMessageIdContext; - channelUnreadUiStateAfterJump = channelUnreadUiState; - return; - } - jumpToFirstUnreadMessage(); - hasJumped = true; - }, - ); - }); - - await waitFor(() => { - if (loadScenario === 'already loaded') { - expect(loadMessageIntoState).not.toHaveBeenCalled(); - expect(channelQuerySpy).not.toHaveBeenCalled(); - } - - if (loadScenario.match('query fails')) { - expect(notifications).toHaveLength(1); - expect(notifications[0].text).toBe(errorNotificationText); - expect(highlightedMessageId).toBeUndefined(); - } else { - expect(notifications).toHaveLength(0); - expect(highlightedMessageId).toBe(first_unread_message_id); - if (!ownReadState.first_unread_message_id) { - expect(channelUnreadUiStateAfterJump.first_unread_message_id).toBe( - first_unread_message_id, - ); - } - } - }); - }; - - it('should not query messages around the first unread message if it is already loaded in state', async () => { - await runTest({ - currentMsgSet: currentMessageSetLastReadNotLoadedFirstUnreadLoaded, - loadScenario: 'already loaded', - ownReadState: ownReadStateFirstUnreadMsgIdKnown, - }); - }); - - it('should query messages around the first unread message if it is not loaded in state', async () => { - await runTest({ - channelQueryResolvedValue: currentMessageSetLastReadFirstUnreadLoaded, - currentMsgSet: currentMessageSetFirstUnreadLastReadNotLoaded, - loadScenario: 'query by id', - ownReadState: ownReadStateFirstUnreadMsgIdKnown, - }); - }); - - it('should handle query error if the first unread message is not found after channel query by message id', async () => { - await runTest({ - currentMsgSet: currentMessageSetFirstUnreadLastReadNotLoaded, - loadScenario: 'query fails', - ownReadState: ownReadStateFirstUnreadMsgIdKnown, - }); - }); - - it('should not query messages around the last read message if it is already loaded in state', async () => { - await runTest({ - currentMsgSet: currentMessageSetLastReadFirstUnreadLoaded, - loadScenario: 'already loaded', - ownReadState: ownReadStateLastReadMsgIdKnown, - }); - }); - - it('should query messages around the last read message if it is not loaded in state', async () => { - await runTest({ - channelQueryResolvedValue: currentMessageSetLastReadFirstUnreadLoaded, - currentMsgSet: currentMessageSetLastReadNotLoadedFirstUnreadLoaded, - loadScenario: 'query by id', - ownReadState: ownReadStateLastReadMsgIdKnown, - }); - }); - - it('should handle the query error if the last read message is not found after channel query by message id', async () => { - await runTest({ - currentMsgSet: currentMessageSetLastReadNotLoadedFirstUnreadLoaded, - loadScenario: 'query fails', - ownReadState: ownReadStateLastReadMsgIdKnown, - }); - }); - - it('should not query messages by the last read date if the first unread message found in local state by last read date', async () => { - await runTest({ - currentMsgSet: currentMessageSetLastReadFirstUnreadLoaded, - loadScenario: 'already loaded', - ownReadState: ownReadStateBase, - }); - }); - - it('should try to load messages into state and fail as first unread id is unknown and last read message is already in state', async () => { - await runTest({ - currentMsgSet: currentMessageSetLastReadLoadedFirstUnreadNotLoaded, - loadScenario: 'query fails', - ownReadState: ownReadStateBase, - }); - }); - - it.each([ - ['is returned in query', currentMessageSetLastReadFirstUnreadLoaded], - // ['is not returned in query', currentMessageSetLastReadNotLoadedFirstUnreadLoaded], - ])( - 'should query messages by last read date if the last read & first unread message not found in the local message list state and both ids are unknown and last read message %s', - async (queryScenario, channelQueryResolvedValue) => { - await runTest({ - channelQueryResolvedValue, - currentMsgSet: currentMessageSetFirstUnreadLastReadNotLoaded, - loadScenario: 'query by date', - ownReadState: ownReadStateBase, - }); - }, - ); + it('registers custom request handlers through channel config state', async () => { + const { channel, client } = await initClient({ user }); + channel.initialized = true; + const chatContext = createChatContextValue({ client }); - it('should handle query messages by last read date query error', async () => { - await runTest({ - currentMsgSet: currentMessageSetFirstUnreadLastReadNotLoaded, - loadScenario: 'query by date query fails', - ownReadState: ownReadStateBase, - }); - }); - - // const timestamp = new Date('2024-01-01T00:00:00.000Z').getTime(); - it.each([ - [ - false, - 'last page', - 'first unread message', - [ - generateMessage({ created_at: new Date('2024-01-01T00:00:00.000Z') }), - generateMessage({ - created_at: new Date('2024-01-01T00:00:00.001Z'), - id: last_read_message_id, - }), - generateMessage({ - created_at: new Date('2024-01-01T00:00:00.002Z'), - id: first_unread_message_id, - }), - generateMessage({ created_at: new Date('2024-01-01T00:00:00.003Z') }), - ], - first_unread_message_id, - ], - [ - true, - 'other than last page', - 'first unread message', - [ - generateMessage({ created_at: new Date('2024-01-01T00:00:00.000Z') }), - generateMessage({ created_at: new Date('2024-01-01T00:00:00.001Z') }), - generateMessage({ - created_at: new Date('2024-01-01T00:00:00.002Z'), - id: last_read_message_id, - }), - generateMessage({ - created_at: new Date('2024-01-01T00:00:00.003Z'), - id: first_unread_message_id, - }), - ], - first_unread_message_id, - ], - [ - true, - 'other than last page', - 'last read message', - [ - generateMessage({ created_at: new Date('2024-01-01T00:00:00.000Z') }), - generateMessage({ created_at: new Date('2024-01-01T00:00:00.001Z') }), - generateMessage({ created_at: new Date('2024-01-01T00:00:00.002Z') }), - generateMessage({ - created_at: new Date('2024-01-01T00:00:00.003Z'), - id: last_read_message_id, - }), - ], - undefined, - ], - ])( - 'should set pagination flag hasMore to %s when messages query returns %s and chooses jump-to message id from %s', - async (expectedHasMore, _, __, jumpToPage, expectedJumpToId) => { - const { - channels: [channel], - client: chatClient, - } = await initClientWithChannels({ - channelsData: [ - { - messages: [generateMessage()], - read: [ - { - last_read: new Date().toISOString(), - last_read_message_id, - unread_messages: 1, - user, - }, - ], - }, - ], - customUser: user, - }); - let hasJumped; - let hasMoreMessages; - let highlightedMessageId; - let notifications; - await renderComponent( - { channel, chatClient }, - ({ - channelUnreadUiState, - hasMore, - highlightedMessageId: contextHighlightedMessageId, - jumpToFirstUnreadMessage, - notifications: contextNotifications, - }) => { - if (hasJumped) { - hasMoreMessages = hasMore; - highlightedMessageId = contextHighlightedMessageId; - notifications = contextNotifications; - return; - } - if (!channelUnreadUiState) return; - useMockedApis(chatClient, [ - queryChannelWithNewMessages(jumpToPage, channel), - ]); - jumpToFirstUnreadMessage(jumpToPage.length); - hasJumped = true; - }, - ); - - await waitFor(() => { - expect(hasMoreMessages).toBe(expectedHasMore); - expect(highlightedMessageId).toBe(expectedJumpToId); - expect(notifications).toHaveLength(!expectedJumpToId ? 1 : 0); - }); - }, - ); - }); + const doDeleteMessageRequest = jest.fn(); + const doMarkReadRequest = jest.fn(); + const doSendMessageRequest = jest.fn(); + const doUpdateMessageRequest = jest.fn(); - describe('Sending/removing/updating messages', () => { - it('should remove error messages from channel state when sending a new message', async () => { - const filterErrorMessagesSpy = jest.spyOn(channel.state, 'filterErrorMessages'); - // flag to prevent infinite loop - let hasSent = false; - - await renderComponent({ channel, chatClient }, ({ sendMessage }) => { - if (!hasSent) { - const m = generateMessage(); - sendMessage({ localMessage: { ...m, status: 'sending' }, message: m }); - hasSent = true; - } - }); - - await waitFor(() => expect(filterErrorMessagesSpy).toHaveBeenCalledWith()); - }); - - it('should add a preview for messages that are sent to the channel state, so that they are rendered even without API response', async () => { - // flag to prevent infinite loop - let hasSent = false; - const messageText = nanoid(); - jest - .spyOn(channel, 'sendMessage') - .mockImplementationOnce(() => new Promise(() => {})); - - const { findByText } = await renderComponent( - { - channel, - chatClient, - children: , - }, - ({ sendMessage }) => { - if (!hasSent) { - const m = generateMessage({ text: messageText }); - sendMessage({ localMessage: { ...m, status: 'sending' }, message: m }); - hasSent = true; - } - }, - ); - - expect(await findByText(messageText)).toBeInTheDocument(); - }); - - it('should mark message as received when the backend reports duplicated message id', async () => { - // flag to prevent infinite loop - let hasSent = false; - const messageText = nanoid(); - const messageId = nanoid(); - - let originalMessageStatus = null; - jest.spyOn(channel, 'sendMessage').mockImplementation((message) => { - originalMessageStatus = message.status; - throw chatClient.errorFromResponse({ - data: { - code: 4, - message: `SendMessage failed with error: "a message with ID ${message.id} already exists"`, - }, - status: 400, - }); - }); - - const { findByText } = await renderComponent( - { - channel, - chatClient, - children: , - }, - ({ sendMessage }) => { - if (!hasSent) { - const m = generateMessage({ - id: messageId, - status: 'sending', // FIXME: had to be explicitly added - text: messageText, - }); - sendMessage({ localMessage: { ...m, status: 'sending' }, message: m }); - hasSent = true; - } - }, - ); - - expect(await findByText(messageText)).toBeInTheDocument(); - expect(originalMessageStatus).toBe('sending'); - - const msg = channel.state.findMessage(messageId); - expect(msg).toBeDefined(); - expect(msg.status).toBe('received'); - }); - - it('should use the doSendMessageRequest prop to send messages if that is defined', async () => { - const doSendMessageRequest = jest.fn(); - const message = generateMessage(); - - let sendMessage; - await renderComponent( - { - channel, - chatClient, - doSendMessageRequest, - }, - ({ sendMessage: sm }) => { - sendMessage = sm; - }, - ); - - await act(() => - sendMessage({ localMessage: { ...message, status: 'sending' }, message }), - ); - - expect(doSendMessageRequest).toHaveBeenCalledWith( - channel, - expect.objectContaining(message), - undefined, - ); - }); - - it('should eventually pass the result of the sendMessage API as part of ChannelActionContext', async () => { - const responseText = nanoid(); - - jest - .spyOn(channel, 'sendMessage') - .mockImplementationOnce((sm) => ({ message: { ...sm, text: responseText } })); - - let sendMessage; - const { findByText } = await renderComponent( - { - channel, - chatClient, - children: , - }, - ({ sendMessage: sm }) => { - sendMessage = sm; - }, - ); - - const m = generateMessage(); - await act(() => - sendMessage({ - localMessage: { ...m, status: 'sending' }, - message: m, - }), - ); - - expect(await findByText(responseText)).toBeInTheDocument(); - }); - - describe('delete message', () => { - it('should throw error instead of calling default client.deleteMessage() function', async () => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { id, ...message } = generateMessage(); - - const clientDeleteMessageSpy = jest.spyOn(chatClient, 'deleteMessage'); - let deleteMessageHandler; - await renderComponent({ channel, chatClient }, ({ deleteMessage }) => { - deleteMessageHandler = deleteMessage; - }); - - await expect(() => deleteMessageHandler(message)).rejects.toThrow( - 'Cannot delete a message - missing message ID.', - ); - expect(clientDeleteMessageSpy).not.toHaveBeenCalled(); - }); - - it('should call the default client.deleteMessage() function', async () => { - const message = generateMessage(); - const deleteMessageOptions = { deleteForMe: true, hard: false }; - const clientDeleteMessageSpy = jest - .spyOn(chatClient, 'deleteMessage') - .mockImplementationOnce(() => Promise.resolve({ message })); - await renderComponent({ channel, chatClient }, ({ deleteMessage }) => { - deleteMessage(message, deleteMessageOptions); - }); - await waitFor(() => - expect(clientDeleteMessageSpy).toHaveBeenCalledWith( - message.id, - deleteMessageOptions, - ), - ); - }); - - it('should throw error instead of calling custom doDeleteMessageRequest function', async () => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { id, ...message } = generateMessage(); - - const clientDeleteMessageSpy = jest - .spyOn(chatClient, 'deleteMessage') - .mockImplementationOnce(() => Promise.resolve({ message })); - const doDeleteMessageRequest = jest.fn(); - let deleteMessageHandler; - await renderComponent( - { channel, chatClient, doDeleteMessageRequest }, - ({ deleteMessage }) => { - deleteMessageHandler = deleteMessage; - }, - ); - - await expect(() => deleteMessageHandler(message)).rejects.toThrow( - 'Cannot delete a message - missing message ID.', - ); - expect(clientDeleteMessageSpy).not.toHaveBeenCalled(); - expect(doDeleteMessageRequest).not.toHaveBeenCalled(); - }); - - it('should call the custom doDeleteMessageRequest instead of client.deleteMessage()', async () => { - const message = generateMessage(); - const deleteMessageOptions = { deleteForMe: true, hard: false }; - const doDeleteMessageRequest = jest.fn(); - const clientDeleteMessageSpy = jest - .spyOn(chatClient, 'deleteMessage') - .mockImplementationOnce(() => Promise.resolve({ message })); - - await renderComponent( - { channel, chatClient, doDeleteMessageRequest }, - ({ deleteMessage }) => { - deleteMessage(message, deleteMessageOptions); - }, - ); - - await waitFor(() => { - expect(clientDeleteMessageSpy).not.toHaveBeenCalled(); - expect(doDeleteMessageRequest).toHaveBeenCalledWith( - message, - deleteMessageOptions, - ); - }); - }); - }); - - it('should enable editing messages', async () => { - const newText = 'something entirely different'; - const updatedMessage = { ...messages[0], text: newText }; - const clientUpdateMessageSpy = jest.spyOn(chatClient, 'updateMessage'); - await renderComponent({ channel, chatClient }, ({ editMessage }) => { - editMessage(updatedMessage); - }); - await waitFor(() => - expect(clientUpdateMessageSpy).toHaveBeenCalledWith( - updatedMessage, - undefined, - undefined, - ), - ); - }); - - it('should use doUpdateMessageRequest for the editMessage callback if provided', async () => { - const doUpdateMessageRequest = jest.fn((channelId, message) => message); - - await renderComponent( - { channel, chatClient, doUpdateMessageRequest }, - ({ editMessage }) => { - editMessage(messages[0]); - }, - ); - - await waitFor(() => - expect(doUpdateMessageRequest).toHaveBeenCalledWith( - channel.cid, - messages[0], - undefined, - ), - ); - }); - - it('should update messages passed into the updateMessage callback', async () => { - const newText = 'something entirely different'; - const updatedMessage = { ...messages[0], text: newText, updated_at: Date.now() }; - let hasUpdated = false; - - const { findByText } = await renderComponent( - { channel, chatClient, children: }, - ({ updateMessage }) => { - if (!hasUpdated) updateMessage(updatedMessage); - hasUpdated = true; - }, - ); - - await waitFor(async () => { - expect(await findByText(updatedMessage.text)).toBeInTheDocument(); - }); - }); - - it('should enable retrying message sending', async () => { - const messageObject = generateMessage({ - text: nanoid(), - }); - - let retrySendMessage; - let sendMessage; - let contextMessages; - await renderComponent( - { channel, chatClient, children: }, - ({ messages: cm, retrySendMessage: rsm, sendMessage: sm }) => { - retrySendMessage = rsm; - sendMessage = sm; - contextMessages = cm; - }, - ); - - jest - .spyOn(channel, 'sendMessage') - .mockImplementationOnce(() => Promise.reject()) - .mockImplementationOnce(() => { - const creationDate = new Date(); - const created_at = creationDate.toISOString(); - const updated_at = new Date(creationDate.getTime() + 1).toISOString(); - return { - ...messageObject, - created_at, - updated_at, - }; - }); - - await act(() => - sendMessage({ - localMessage: { ...messageObject, status: 'sending' }, - message: messageObject, - }), - ); - - expect(contextMessages.some(({ status }) => status === 'failed')).toBe(true); - - await act(() => retrySendMessage(messageObject)); - - expect(screen.queryByText(messageObject.text)).toBeInTheDocument(); - }); - - it('should remove scraped attachment on retry-sending message', async () => { - // flag to prevent infinite loop - let hasSent = false; - let hasRetried = false; - const fileAttachment = generateFileAttachment(); - const scrapedAttachment = generateScrapedDataAttachment(); - const attachments = [fileAttachment, scrapedAttachment]; - const messageObject = { attachments, text: 'bla bla' }; - const sendMessageSpy = jest - .spyOn(channel, 'sendMessage') - .mockImplementationOnce(() => Promise.reject()); - - await renderComponent( - { channel, chatClient, children: }, - ({ messages: contextMessages, retrySendMessage, sendMessage }) => { - if (!hasSent) { - sendMessage({ - localMessage: { - ...messageObject, - status: 'sending', - }, - message: messageObject, - }); - hasSent = true; - } else if ( - !hasRetried && - contextMessages.some(({ status }) => status === 'failed') - ) { - // retry - useMockedApis(chatClient, [sendMessageApi(generateMessage(messageObject))]); - retrySendMessage(messageObject); - hasRetried = true; - } - }, - ); - - expect(sendMessageSpy).not.toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ attachments: [scrapedAttachment] }), - ); - expect(sendMessageSpy).not.toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ attachments: [fileAttachment] }), - ); - }); - - it('should allow removing messages', async () => { - let allMessagesRemoved = false; - const removeSpy = jest.spyOn(channel.state, 'removeMessage'); - - await renderComponent( - { channel, chatClient }, - ({ messages: contextMessages, removeMessage }) => { - if (contextMessages.length > 0) { - // if there are messages passed as the context, remove them - removeMessage(contextMessages[0]); - } else { - // once they're all gone, set to true so we can verify that we no longer have messages - allMessagesRemoved = true; - } - }, - ); - - await waitFor(() => expect(removeSpy).toHaveBeenCalledWith(messages[0])); - await waitFor(() => expect(allMessagesRemoved).toBe(true)); - }); + renderChannel({ + channel, + channelProps: { + doDeleteMessageRequest, + doMarkReadRequest, + doSendMessageRequest, + doUpdateMessageRequest, + }, + chatContext, }); - describe('Channel events', () => { - // note: these tests rely on Client.dispatchEvent, which eventually propagates to the channel component. - const createOneTimeEventDispatcher = (event, client, channel) => { - let hasDispatchedEvent = false; - return () => { - if (!hasDispatchedEvent) - client.dispatchEvent({ - ...event, - cid: channel.cid, - }); - hasDispatchedEvent = true; - }; - }; - - const createChannelEventDispatcher = ( - body, - client, - channel, - type = 'message.new', - ) => - createOneTimeEventDispatcher( - { - type, - ...body, - }, - client, - channel, - ); - - it('should eventually pass down a message when a message.new event is triggered on the channel', async () => { - const message = generateMessage({ user }); - const dispatchMessageEvent = createChannelEventDispatcher( - { message }, - chatClient, - channel, - ); - - const { findByText } = await renderComponent( - { - channel, - chatClient, - children: , - }, - () => { - // dispatch event in effect because it happens after active channel is set - dispatchMessageEvent(); - }, - ); - - expect(await findByText(message.text)).toBeInTheDocument(); - }); - - it('should not overwrite the message with send response, if already updated by WS events', async () => { - let oldText; - const newText = 'new text'; - - jest.spyOn(channel, 'sendMessage').mockImplementationOnce((message) => { - const creationDate = new Date(); - const created_at = creationDate.toISOString(); - const updated_at = new Date(creationDate.getTime() + 1).toISOString(); - - oldText = message.text; - const finalMessage = { ...message, created_at, updated_at: created_at }; - // both effects have to be emitted, otherwise the original message in status "sending" will not be filtered out (done when message.new is emitted) => and the message.updated event would add the updated message as a new message. - createChannelEventDispatcher( - { - created_at, - message: { - ...finalMessage, - text: newText, - }, - user, - }, - chatClient, - channel, - )(); - createChannelEventDispatcher( - { - created_at: updated_at, - message: { - ...finalMessage, - text: newText, - updated_at, - user, - }, - type: 'message.updated', - }, - chatClient, - channel, - )(); - return { message }; - }); - - let sendMessage; - const { findByText, queryByText } = await renderComponent( - { channel, chatClient, children: }, - ({ sendMessage: sm }) => { - sendMessage = sm; - }, - ); - - await act(async () => { - const m = generateMessage(); - await sendMessage({ localMessage: { ...m, status: 'sending' }, message: m }); - }); - - await waitFor(async () => { - expect( - await queryByText(oldText, undefined, { timeout: 100 }), - ).not.toBeInTheDocument(); - }); - - expect(await findByText(newText)).toBeInTheDocument(); - }); - - it('should overwrite the message of status "sending" regardless of updated_at timestamp', async () => { - let oldText; - const newText = 'new text'; - - jest.spyOn(channel, 'sendMessage').mockImplementationOnce((message) => { - const creationDate = new Date(); - const created_at = creationDate.toISOString(); - const updated_at = new Date(creationDate.getTime() - 1).toISOString(); - oldText = message.text; - return { message: { ...message, created_at, text: newText, updated_at } }; - }); - - let sendMessage; - const { findByText, queryByText } = await renderComponent( - { channel, chatClient, children: }, - ({ sendMessage: sm }) => { - sendMessage = sm; - }, - ); - - await act(async () => { - const m = generateMessage(); - - await sendMessage({ localMessage: { ...m, status: 'sending' }, message: m }); - }); - - await waitFor(async () => { - expect( - await queryByText(oldText, undefined, { timeout: 100 }), - ).not.toBeInTheDocument(); - }); - - expect(await findByText(newText)).toBeInTheDocument(); - }); - - it('should not mark the channel as read if a new message from another user comes in and the user is looking at the page', async () => { - const markReadSpy = jest.spyOn(channel, 'markRead'); - - const message = generateMessage({ user: generateUser() }); - const dispatchMessageEvent = createChannelEventDispatcher( - { message }, - chatClient, - channel, - ); - - await renderComponent({ channel, chatClient }, () => { - dispatchMessageEvent(); - }); - - await waitFor(() => expect(markReadSpy).not.toHaveBeenCalled()); - }); - - it('should not mark the channel as read if the new message author is the current user and the user is looking at the page', async () => { - const markReadSpy = jest.spyOn(channel, 'markRead'); - - const message = generateMessage({ user: generateUser() }); - const dispatchMessageEvent = createChannelEventDispatcher( - { message }, - chatClient, - channel, - ); - - await renderComponent({ channel, chatClient }, () => { - dispatchMessageEvent(); - }); - - await waitFor(() => expect(markReadSpy).not.toHaveBeenCalled()); - }); - - it('title of the page should include the unread count if the user is not looking at the page when a new message event happens', async () => { - const unreadAmount = 1; - Object.defineProperty(document, 'hidden', { - configurable: true, - get: () => true, - }); - jest.spyOn(channel, 'countUnread').mockImplementation(() => unreadAmount); - const message = generateMessage({ user: generateUser() }); - const dispatchMessageEvent = createChannelEventDispatcher( - { message }, - chatClient, - channel, - ); - - await renderComponent({ channel, chatClient }, () => { - dispatchMessageEvent(); - }); - - await waitFor(() => expect(document.title).toContain(`${unreadAmount}`)); - }); - - it('should update the `thread` parent message if an event comes in that modifies it', async () => { - const threadMessage = messages[0]; - const newText = 'new text'; - const updatedThreadMessage = { ...threadMessage, text: newText }; - const dispatchUpdateMessageEvent = createChannelEventDispatcher( - { message: updatedThreadMessage, type: 'message.updated' }, - chatClient, - channel, - ); - let threadStarterHasUpdatedText = false; - await renderComponent({ channel, chatClient }, ({ openThread, thread }) => { - if (!thread) { - // first, open thread - openThread(threadMessage, { preventDefault: () => null }); - } else if (thread.text !== newText) { - // then, update the thread message - // FIXME: dispatch event needs to be queued on event loop now - setTimeout(() => dispatchUpdateMessageEvent(), 0); - } else { - threadStarterHasUpdatedText = true; - } - }); - - await waitFor(() => expect(threadStarterHasUpdatedText).toBe(true)); - }); - - it('should update the threadMessages if a new message comes in that is part of the thread', async () => { - const threadMessage = messages[0]; - const newThreadMessage = generateMessage({ - parent_id: threadMessage.id, - }); - const dispatchNewThreadMessageEvent = createChannelEventDispatcher( - { - message: newThreadMessage, - }, - chatClient, - channel, - ); - let newThreadMessageWasAdded = false; - await renderComponent( - { channel, chatClient }, - ({ openThread, thread, threadMessages }) => { - if (!thread) { - // first, open thread - openThread(threadMessage, { preventDefault: () => null }); - } else if (!threadMessages.some(({ id }) => id === newThreadMessage.id)) { - // then, add new thread message - // FIXME: dispatch event needs to be queued on event loop now - setTimeout(() => dispatchNewThreadMessageEvent(), 0); - } else { - newThreadMessageWasAdded = true; - } - }, - ); - - await waitFor(() => expect(newThreadMessageWasAdded).toBe(true)); - }); - - [ - { - component: MessageList, - getFirstMessageAvatar: () => { - const [avatar] = screen.queryAllByTestId('custom-avatar') || []; - return avatar; - }, - name: 'MessageList', - }, - { - callback: - (message) => - ({ openThread, thread }) => { - if (!thread) openThread(message, { preventDefault: () => null }); - }, - component: Thread, - getFirstMessageAvatar: () => { - // the first avatar is that of the ThreadHeader - const avatars = screen.queryAllByTestId('custom-avatar') || []; - return avatars[0]; - }, - name: 'Thread', - }, - ].forEach(({ callback, component: Component, getFirstMessageAvatar, name }) => { - it(`should update user data in ${name} based on updated_at`, async () => { - const [threadMessage] = messages; - - const updatedAttribute = { name: 'newName' }; - const dispatchUserUpdatedEvent = createChannelEventDispatcher( - { - type: 'user.updated', - user: { - ...user, - ...updatedAttribute, - updated_at: new Date().toISOString(), - }, - }, - chatClient, - channel, - ); - await renderComponent( - { - channel, - chatClient, - children: , - components: { - Avatar: MockAvatar, - }, - }, - callback?.(threadMessage), - ); - - await waitFor(() => { - expect(getFirstMessageAvatar()).toHaveTextContent(user.name); - }); - - await act(() => { - dispatchUserUpdatedEvent(); - }); - - await waitFor(() => { - expect(getFirstMessageAvatar()).toHaveTextContent(updatedAttribute.name); - }); - }); - - it(`should not update user data in ${name} if updated_at has not changed`, async () => { - const [threadMessage] = messages; - - const updatedAttribute = { name: 'newName' }; - const dispatchUserUpdatedEvent = createChannelEventDispatcher( - { - type: 'user.updated', - user: { ...user, ...updatedAttribute }, - }, - chatClient, - channel, - ); - await renderComponent( - { - channel, - chatClient, - children: , - components: { - Avatar: MockAvatar, - }, - }, - callback?.(threadMessage), - ); - - await waitFor(() => { - expect(getFirstMessageAvatar()).toHaveTextContent(user.name); - }); - - await act(() => { - dispatchUserUpdatedEvent(); - }); - - await waitFor(() => { - expect(getFirstMessageAvatar()).toHaveTextContent(user.name); - }); - }); - }); - - it.each([ - ['should', 'active'], - ['should not', 'another'], - ])( - '%s reset channel unread UI state on channel.truncated for the %s channel', - async (expected, forChannel) => { - const unread_messages = 20; - const NO_UNREAD_TEXT = 'no-unread-text'; - const UNREAD_TEXT = `unread-text-${unread_messages}`; - const { - channels: [activeChannel, anotherChannel], - client: chatClient, - } = await initClientWithChannels({ - channelsData: [ - { - messages: [generateMessage()], - read: [ - { - last_read: new Date().toISOString(), - last_read_message_id: 'last_read_message_id-1', - unread_messages, - user, - }, - ], - }, - { - messages: [generateMessage()], - read: [ - { - last_read: new Date().toISOString(), - last_read_message_id: 'last_read_message_id-2', - unread_messages, - user, - }, - ], - }, - ], - customUser: user, - }); - - const Component = () => { - const { channelUnreadUiState } = useChannelStateContext(); - if (!channelUnreadUiState) return
    {NO_UNREAD_TEXT}
    ; - return
    {`unread-text-${channelUnreadUiState.unread_messages}`}
    ; - }; - - await act(async () => { - await renderComponent({ - channel: activeChannel, - chatClient, - children: , - }); - }); - - expect(screen.queryByText(UNREAD_TEXT)).toBeInTheDocument(); - expect(screen.queryByText(NO_UNREAD_TEXT)).not.toBeInTheDocument(); - - act(() => { - dispatchChannelTruncatedEvent( - chatClient, - forChannel === 'active' ? activeChannel : anotherChannel, - ); - }); - - if (forChannel === 'active') { - expect(screen.queryByText(UNREAD_TEXT)).not.toBeInTheDocument(); - expect(screen.queryByText(NO_UNREAD_TEXT)).toBeInTheDocument(); - } else { - expect(screen.queryByText(UNREAD_TEXT)).toBeInTheDocument(); - expect(screen.queryByText(NO_UNREAD_TEXT)).not.toBeInTheDocument(); - } - }, - ); + await waitFor(() => { + const requestHandlers = channel.configState.getLatestValue().requestHandlers; + expect(requestHandlers.deleteMessageRequest).toEqual(expect.any(Function)); + expect(requestHandlers.markReadRequest).toEqual(expect.any(Function)); + expect(requestHandlers.retrySendMessageRequest).toEqual(expect.any(Function)); + expect(requestHandlers.sendMessageRequest).toEqual(expect.any(Function)); + expect(requestHandlers.updateMessageRequest).toEqual(expect.any(Function)); }); }); }); diff --git a/src/components/Channel/channelState.ts b/src/components/Channel/channelState.ts deleted file mode 100644 index 597e1ca36b..0000000000 --- a/src/components/Channel/channelState.ts +++ /dev/null @@ -1,288 +0,0 @@ -import type { - Channel, - LocalMessage, - MessageResponse, - ChannelState as StreamChannelState, -} from 'stream-chat'; - -import type { ChannelState } from '../../context/ChannelStateContext'; - -export type ChannelStateReducerAction = - | { - type: 'closeThread'; - } - | { - type: 'clearHighlightedMessage'; - } - | { - channel: Channel; - type: 'copyMessagesFromChannel'; - parentId?: string | null; - } - | { - channel: Channel; - type: 'copyStateFromChannelOnEvent'; - } - | { - channel: Channel; - highlightedMessageId: string; - type: 'jumpToMessageFinished'; - } - | { - channel: Channel; - hasMore: boolean; - type: 'initStateFromChannel'; - } - | { - hasMore: boolean; - messages: LocalMessage[]; - type: 'loadMoreFinished'; - } - | { - hasMoreNewer: boolean; - messages: LocalMessage[]; - type: 'loadMoreNewerFinished'; - } - | { - threadHasMore: boolean; - threadMessages: Array>; - type: 'loadMoreThreadFinished'; - } - | { - channel: Channel; - message: LocalMessage; - type: 'openThread'; - } - | { - error: Error; - type: 'setError'; - } - | { - loadingMore: boolean; - type: 'setLoadingMore'; - } - | { - loadingMoreNewer: boolean; - type: 'setLoadingMoreNewer'; - } - | { - message: LocalMessage; - type: 'setThread'; - } - | { - channel: Channel; - type: 'setTyping'; - } - | { - type: 'startLoadingThread'; - } - | { - channel: Channel; - message: MessageResponse; - type: 'updateThreadOnEvent'; - } - | { - type: 'jumpToLatestMessage'; - }; - -export const makeChannelReducer = - () => (state: ChannelState, action: ChannelStateReducerAction) => { - switch (action.type) { - case 'closeThread': { - return { - ...state, - thread: null, - threadLoadingMore: false, - threadMessages: [], - }; - } - - case 'copyMessagesFromChannel': { - const { channel, parentId } = action; - return { - ...state, - messages: [...channel.state.messages], - pinnedMessages: [...channel.state.pinnedMessages], - // copying messages from channel happens with new message - this resets the suppressAutoscroll - suppressAutoscroll: false, - threadMessages: parentId - ? { ...channel.state.threads }[parentId] || [] - : state.threadMessages, - }; - } - - case 'copyStateFromChannelOnEvent': { - const { channel } = action; - return { - ...state, - members: { ...channel.state.members }, - messages: [...channel.state.messages], - pinnedMessages: [...channel.state.pinnedMessages], - read: { ...channel.state.read }, - watcherCount: channel.state.watcher_count, - watchers: { ...channel.state.watchers }, - }; - } - - case 'initStateFromChannel': { - const { channel, hasMore } = action; - return { - ...state, - hasMore, - loading: false, - members: { ...channel.state.members }, - messages: [...channel.state.messages], - pinnedMessages: [...channel.state.pinnedMessages], - read: { ...channel.state.read }, - watcherCount: channel.state.watcher_count, - watchers: { ...channel.state.watchers }, - }; - } - - case 'jumpToLatestMessage': { - return { - ...state, - hasMoreNewer: false, - highlightedMessageId: undefined, - loading: false, - suppressAutoscroll: false, - }; - } - - case 'jumpToMessageFinished': { - return { - ...state, - hasMoreNewer: action.channel.state.messagePagination.hasNext, - highlightedMessageId: action.highlightedMessageId, - messages: action.channel.state.messages, - }; - } - - case 'clearHighlightedMessage': { - return { - ...state, - highlightedMessageId: undefined, - }; - } - - case 'loadMoreFinished': { - const { hasMore, messages } = action; - return { - ...state, - hasMore, - loadingMore: false, - messages, - suppressAutoscroll: false, - }; - } - - case 'loadMoreNewerFinished': { - const { hasMoreNewer, messages } = action; - return { - ...state, - hasMoreNewer, - loadingMoreNewer: false, - messages, - }; - } - - case 'loadMoreThreadFinished': { - const { threadHasMore, threadMessages } = action; - return { - ...state, - threadHasMore, - threadLoadingMore: false, - threadMessages, - }; - } - - case 'openThread': { - const { channel, message } = action; - return { - ...state, - thread: message, - threadHasMore: true, - threadMessages: message.id - ? { ...channel.state.threads }[message.id] || [] - : [], - threadSuppressAutoscroll: false, - }; - } - - case 'setError': { - const { error } = action; - return { ...state, error }; - } - - case 'setLoadingMore': { - const { loadingMore } = action; - // suppress the autoscroll behavior - return { ...state, loadingMore, suppressAutoscroll: loadingMore }; - } - - case 'setLoadingMoreNewer': { - const { loadingMoreNewer } = action; - return { ...state, loadingMoreNewer }; - } - - case 'setThread': { - const { message } = action; - return { ...state, thread: message }; - } - - case 'setTyping': { - const { channel } = action; - return { - ...state, - typing: { ...channel.state.typing }, - }; - } - - case 'startLoadingThread': { - return { - ...state, - threadLoadingMore: true, - threadSuppressAutoscroll: true, - }; - } - - case 'updateThreadOnEvent': { - const { channel, message } = action; - if (!state.thread) return state; - return { - ...state, - thread: - message?.id === state.thread.id - ? channel.state.formatMessage(message) - : state.thread, - threadMessages: state.thread?.id - ? { ...channel.state.threads }[state.thread.id] || [] - : [], - }; - } - - default: - return state; - } - }; - -export const initialState = { - error: null, - hasMore: true, - hasMoreNewer: false, - loading: true, - loadingMore: false, - members: {}, - messages: [], - pinnedMessages: [], - read: {}, - suppressAutoscroll: false, - thread: null, - threadHasMore: true, - threadLoadingMore: false, - threadMessages: [], - threadSuppressAutoscroll: false, - typing: {}, - watcherCount: 0, - watchers: {}, -}; diff --git a/src/components/Channel/hooks/__tests__/useChannelRequestHandlers.test.ts b/src/components/Channel/hooks/__tests__/useChannelRequestHandlers.test.ts new file mode 100644 index 0000000000..e4689f6885 --- /dev/null +++ b/src/components/Channel/hooks/__tests__/useChannelRequestHandlers.test.ts @@ -0,0 +1,92 @@ +import { renderHook } from '@testing-library/react'; + +import { useChannelRequestHandlers } from '../useChannelRequestHandlers'; + +const createChannelStub = () => { + let value: { requestHandlers?: Record } = {}; + + const channel = { + cid: 'messaging:test', + configState: { + getLatestValue: () => value, + partialNext: (update: { requestHandlers?: Record }) => { + value = { ...value, ...update }; + }, + }, + markAsReadRequest: jest.fn().mockResolvedValue(null), + sendMessage: jest.fn().mockResolvedValue({ message: { id: 'fallback-send' } }), + } as const; + + return { + channel, + getRequestHandlers: () => value.requestHandlers, + }; +}; + +describe('useChannelRequestHandlers', () => { + it('registers only one send/retry handler and updates it when props change', async () => { + const { channel, getRequestHandlers } = createChannelStub(); + + const doSendMessageRequestA = jest + .fn() + .mockResolvedValue({ message: { id: 'custom-send-a' } }); + + const { rerender } = renderHook( + ({ doSendMessageRequest }) => + useChannelRequestHandlers({ + channel: channel as never, + doSendMessageRequest: doSendMessageRequest as never, + }), + { initialProps: { doSendMessageRequest: doSendMessageRequestA } }, + ); + + const first = getRequestHandlers(); + expect(first?.sendMessageRequest).toBeDefined(); + expect(first?.retrySendMessageRequest).toBe(first?.sendMessageRequest); + + const doSendMessageRequestB = jest + .fn() + .mockResolvedValue({ message: { id: 'custom-send-b' } }); + + rerender({ doSendMessageRequest: doSendMessageRequestB }); + + const second = getRequestHandlers(); + expect(second?.sendMessageRequest).toBeDefined(); + expect(second?.retrySendMessageRequest).toBe(second?.sendMessageRequest); + + const sendRequest = second?.sendMessageRequest as (params: { + message?: { id?: string }; + options?: { skip_push?: boolean }; + }) => Promise<{ message: { id: string } }>; + + const result = await sendRequest({ + message: { id: 'message-1' }, + options: { skip_push: true }, + }); + + expect(result.message.id).toBe('custom-send-b'); + expect(doSendMessageRequestA).toHaveBeenCalledTimes(0); + expect(doSendMessageRequestB).toHaveBeenCalledTimes(1); + }); + + it('removes managed handlers when custom handlers are unset', () => { + const { channel, getRequestHandlers } = createChannelStub(); + + const doMarkReadRequest = jest.fn(); + + const { rerender } = renderHook( + ({ doMarkReadRequest }) => + useChannelRequestHandlers({ + channel: channel as never, + doMarkReadRequest: doMarkReadRequest as never, + }), + { initialProps: { doMarkReadRequest } }, + ); + + expect(getRequestHandlers()?.markReadRequest).toBeDefined(); + + rerender({ doMarkReadRequest: undefined }); + + expect(getRequestHandlers()?.markReadRequest).toBeUndefined(); + }); +}); diff --git a/src/components/Channel/hooks/useChannelCapabilities.ts b/src/components/Channel/hooks/useChannelCapabilities.ts new file mode 100644 index 0000000000..3ad60235d1 --- /dev/null +++ b/src/components/Channel/hooks/useChannelCapabilities.ts @@ -0,0 +1,19 @@ +import { useMemo } from 'react'; +import type { OwnCapabilitiesState } from 'stream-chat'; +import { useChannel } from '../../../context'; +import { useStateStore } from '../../../store'; + +const ownCapabilitiesSelector = ({ ownCapabilities }: OwnCapabilitiesState) => ({ + ownCapabilities, +}); + +export const useChannelCapabilities = ({ cid }: { cid: string | undefined }) => { + const channel = useChannel(); + const { ownCapabilities } = + useStateStore(channel.state.ownCapabilitiesStore, ownCapabilitiesSelector) ?? {}; + + return useMemo(() => { + if (!cid || channel.cid !== cid) return new Set(); + return new Set(ownCapabilities ?? []); + }, [channel.cid, cid, ownCapabilities]); +}; diff --git a/src/components/Channel/hooks/useChannelConfig.ts b/src/components/Channel/hooks/useChannelConfig.ts new file mode 100644 index 0000000000..e430dd9040 --- /dev/null +++ b/src/components/Channel/hooks/useChannelConfig.ts @@ -0,0 +1,19 @@ +import { useChatContext } from '../../../context'; +import { useStateStore } from '../../../store'; +import type { ChannelConfigsState, ChannelConfigWithInfo, Configs } from 'stream-chat'; + +const channelConfigsSelector = (value: ChannelConfigsState) => ({ + configs: value.configs, +}); + +// todo: why is channel config stored on client? +export const useChannelConfig = ({ cid }: { cid: string | undefined }) => { + const { client } = useChatContext('useChannelConfig'); + const channelConfigsState = useStateStore(client.configsStore, channelConfigsSelector); + + if (!cid) return undefined; + + return channelConfigsState?.configs[cid as keyof Configs] as + | ChannelConfigWithInfo + | undefined; +}; diff --git a/src/components/Channel/hooks/useChannelRequestHandlers.ts b/src/components/Channel/hooks/useChannelRequestHandlers.ts new file mode 100644 index 0000000000..31ff036b94 --- /dev/null +++ b/src/components/Channel/hooks/useChannelRequestHandlers.ts @@ -0,0 +1,123 @@ +import { useEffect } from 'react'; +import type { + DeleteMessageOptions, + EventAPIResponse, + LocalMessage, + MarkReadOptions, + Message, + MessageResponse, + SendMessageOptions, + Channel as StreamChannel, + StreamChat, + UpdateMessageOptions, +} from 'stream-chat'; + +export type ChannelRequestHandlersParams = { + channel: StreamChannel; + doDeleteMessageRequest?: ( + message: LocalMessage, + options?: DeleteMessageOptions, + ) => Promise; + doMarkReadRequest?: ( + channel: StreamChannel, + options?: MarkReadOptions, + ) => Promise | void; + doSendMessageRequest?: ( + channel: StreamChannel, + message: Message, + options?: SendMessageOptions, + ) => ReturnType | void; + doUpdateMessageRequest?: ( + cid: string, + updatedMessage: LocalMessage | MessageResponse, + options?: UpdateMessageOptions, + ) => ReturnType; +}; + +export const useChannelRequestHandlers = ({ + channel, + doDeleteMessageRequest, + doMarkReadRequest, + doSendMessageRequest, + doUpdateMessageRequest, +}: ChannelRequestHandlersParams) => { + useEffect(() => { + const currentRequestHandlers = channel.configState.getLatestValue() + .requestHandlers as Record | undefined; + const nextRequestHandlers = { ...(currentRequestHandlers ?? {}) } as Record< + string, + unknown + >; + + // Reset managed operation handlers and register only currently provided custom handlers. + delete nextRequestHandlers.deleteMessageRequest; + delete nextRequestHandlers.markReadRequest; + delete nextRequestHandlers.retrySendMessageRequest; + delete nextRequestHandlers.sendMessageRequest; + delete nextRequestHandlers.updateMessageRequest; + + if (doDeleteMessageRequest) { + nextRequestHandlers.deleteMessageRequest = async (params: { + localMessage: LocalMessage; + options?: DeleteMessageOptions; + }) => ({ + message: await doDeleteMessageRequest(params.localMessage, params.options), + }); + } + + if (doSendMessageRequest) { + const sendMessageRequest = async (params: { + message?: Message; + options?: SendMessageOptions; + }) => { + const response = await doSendMessageRequest( + channel, + params.message as Message, + params.options, + ); + if (response?.message) return { message: response.message }; + const fallback = await channel.sendMessage( + params.message as Message, + params.options, + ); + return { message: fallback.message }; + }; + + nextRequestHandlers.sendMessageRequest = sendMessageRequest; + nextRequestHandlers.retrySendMessageRequest = sendMessageRequest; + } + + if (doUpdateMessageRequest) { + nextRequestHandlers.updateMessageRequest = async (params: { + localMessage: LocalMessage; + options?: UpdateMessageOptions; + }) => ({ + message: ( + await doUpdateMessageRequest(channel.cid, params.localMessage, params.options) + ).message, + }); + } + + if (doMarkReadRequest) { + nextRequestHandlers.markReadRequest = async (params: { + channel: StreamChannel; + options?: MarkReadOptions; + }) => { + const response = await doMarkReadRequest(params.channel, params.options); + if (response !== undefined) return response; + return await params.channel.markAsReadRequest(params.options); + }; + } + + channel.configState.partialNext({ + requestHandlers: + Object.keys(nextRequestHandlers).length > 0 ? nextRequestHandlers : undefined, + }); + }, [ + channel, + doDeleteMessageRequest, + doMarkReadRequest, + doSendMessageRequest, + doUpdateMessageRequest, + ]); +}; diff --git a/src/components/Channel/hooks/useCreateChannelStateContext.ts b/src/components/Channel/hooks/useCreateChannelStateContext.ts deleted file mode 100644 index 3986592791..0000000000 --- a/src/components/Channel/hooks/useCreateChannelStateContext.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { useMemo } from 'react'; - -import { isDate, isDayOrMoment } from '../../../i18n'; - -import type { ChannelStateContextValue } from '../../../context/ChannelStateContext'; - -export const useCreateChannelStateContext = ( - value: Omit & { - channelCapabilitiesArray: string[]; - skipMessageDataMemoization?: boolean; - }, -) => { - const { - channel, - channelCapabilitiesArray = [], - channelConfig, - channelUnreadUiState, - error, - giphyVersion, - hasMore, - hasMoreNewer, - highlightedMessageId, - imageAttachmentSizeHandler, - loading, - loadingMore, - members, - messages = [], - mutes, - notifications, - pinnedMessages, - read = {}, - shouldGenerateVideoThumbnail, - skipMessageDataMemoization, - suppressAutoscroll, - thread, - threadHasMore, - threadLoadingMore, - threadMessages = [], - videoAttachmentSizeHandler, - watcher_count, - watcherCount, - watchers, - } = value; - - const channelId = channel.cid; - const lastRead = channel.initialized && channel.lastRead()?.getTime(); - const membersLength = Object.keys(members || []).length; - const notificationsLength = notifications.length; - const readUsers = Object.values(read); - const readUsersLength = readUsers.length; - const readUsersLastReadDateStrings: string[] = []; - for (const { last_read } of readUsers) { - if (!lastRead) continue; - readUsersLastReadDateStrings.push(last_read?.toISOString()); - } - const readUsersLastReads = readUsersLastReadDateStrings.join(); - const threadMessagesLength = threadMessages?.length; - - const channelCapabilities: Record = {}; - - channelCapabilitiesArray.forEach((capability) => { - channelCapabilities[capability] = true; - }); - - // FIXME: this is crazy - I could not find out why the messages were not getting updated when only message properties that are not part - // of this serialization has been changed. A great example of memoization gone wrong. - const memoizedMessageData = skipMessageDataMemoization - ? messages - : messages - .map( - ({ - deleted_at, - latest_reactions, - pinned, - reply_count, - status, - type, - updated_at, - user, - }) => - `${type}${deleted_at}${ - latest_reactions ? latest_reactions.map(({ type }) => type).join() : '' - }${pinned}${reply_count}${status}${ - updated_at && (isDayOrMoment(updated_at) || isDate(updated_at)) - ? updated_at.toISOString() - : updated_at || '' - }${user?.updated_at}`, - ) - .join(); - - const memoizedThreadMessageData = threadMessages - .map( - ({ deleted_at, latest_reactions, pinned, status, updated_at, user }) => - `${deleted_at}${ - latest_reactions ? latest_reactions.map(({ type }) => type).join() : '' - }${pinned}${status}${ - updated_at && (isDayOrMoment(updated_at) || isDate(updated_at)) - ? updated_at.toISOString() - : updated_at || '' - }${user?.updated_at}`, - ) - .join(); - - const channelStateContext: ChannelStateContextValue = useMemo( - () => ({ - channel, - channelCapabilities, - channelConfig, - channelUnreadUiState, - error, - giphyVersion, - hasMore, - hasMoreNewer, - highlightedMessageId, - imageAttachmentSizeHandler, - loading, - loadingMore, - members, - messages, - mutes, - notifications, - pinnedMessages, - read, - shouldGenerateVideoThumbnail, - suppressAutoscroll, - thread, - threadHasMore, - threadLoadingMore, - threadMessages, - videoAttachmentSizeHandler, - watcher_count, - watcherCount, - watchers, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - channel.data?.name, // otherwise ChannelHeader will not be updated - channelId, - channelUnreadUiState, - error, - hasMore, - hasMoreNewer, - highlightedMessageId, - lastRead, - loading, - loadingMore, - membersLength, - memoizedMessageData, - memoizedThreadMessageData, - notificationsLength, - readUsersLength, - readUsersLastReads, - shouldGenerateVideoThumbnail, - skipMessageDataMemoization, - suppressAutoscroll, - thread, - threadHasMore, - threadLoadingMore, - threadMessagesLength, - watcherCount, - ], - ); - - return channelStateContext; -}; diff --git a/src/components/Channel/hooks/useCreateTypingContext.ts b/src/components/Channel/hooks/useCreateTypingContext.ts deleted file mode 100644 index 9b4f7b2a48..0000000000 --- a/src/components/Channel/hooks/useCreateTypingContext.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useMemo } from 'react'; - -import type { TypingContextValue } from '../../../context/TypingContext'; - -export const useCreateTypingContext = (value: TypingContextValue) => { - const { typing } = value; - - const typingValue = Object.keys(typing || {}).join(); - - const typingContext: TypingContextValue = useMemo( - () => ({ - typing, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [typingValue], - ); - - return typingContext; -}; diff --git a/src/components/Channel/hooks/useEditMessageHandler.ts b/src/components/Channel/hooks/useEditMessageHandler.ts index 4e6004bb08..d113e956dd 100644 --- a/src/components/Channel/hooks/useEditMessageHandler.ts +++ b/src/components/Channel/hooks/useEditMessageHandler.ts @@ -5,6 +5,7 @@ import type { UpdateMessageOptions, } from 'stream-chat'; +import { useChannel } from '../../../context/useChannel'; import { useChatContext } from '../../../context/ChatContext'; type UpdateHandler = ( @@ -14,7 +15,8 @@ type UpdateHandler = ( ) => ReturnType; export const useEditMessageHandler = (doUpdateMessageRequest?: UpdateHandler) => { - const { channel, client } = useChatContext('useEditMessageHandler'); + const channel = useChannel(); + const { client } = useChatContext(); return ( updatedMessage: LocalMessage | MessageResponse, diff --git a/src/components/Channel/hooks/useMentionsHandlers.ts b/src/components/Channel/hooks/useMentionsHandlers.ts deleted file mode 100644 index 8e1ff09bb8..0000000000 --- a/src/components/Channel/hooks/useMentionsHandlers.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type React from 'react'; -import { useCallback } from 'react'; -import type { UserResponse } from 'stream-chat'; - -export type OnMentionAction = ( - event: React.BaseSyntheticEvent, - user?: UserResponse, -) => void; - -export const useMentionsHandlers = ( - onMentionsHover?: OnMentionAction, - onMentionsClick?: OnMentionAction, -) => - useCallback( - (event: React.BaseSyntheticEvent, mentioned_users: UserResponse[]) => { - if ( - (!onMentionsHover && !onMentionsClick) || - !(event.target instanceof HTMLElement) - ) { - return; - } - - const target = event.target; - const textContent = target.innerHTML.replace('*', ''); - - if (textContent[0] === '@') { - const userName = textContent.replace('@', ''); - const user = mentioned_users?.find( - ({ id, name }) => name === userName || id === userName, - ); - - if ( - onMentionsHover && - typeof onMentionsHover === 'function' && - event.type === 'mouseover' - ) { - onMentionsHover(event, user); - } - - if ( - onMentionsClick && - event.type === 'click' && - typeof onMentionsClick === 'function' - ) { - onMentionsClick(event, user); - } - } - }, - [onMentionsClick, onMentionsHover], - ); diff --git a/src/components/Channel/index.ts b/src/components/Channel/index.ts index d3763351cd..15d0c4e411 100644 --- a/src/components/Channel/index.ts +++ b/src/components/Channel/index.ts @@ -1,3 +1,5 @@ export * from './Channel'; +export * from './ChannelSlot'; export { useEditMessageHandler as useChannelEditMessageHandler } from './hooks/useEditMessageHandler'; -export { useMentionsHandlers as useChannelMentionsHandler } from './hooks/useMentionsHandlers'; +export { useChannelCapabilities } from './hooks/useChannelCapabilities'; +export { useChannelConfig } from './hooks/useChannelConfig'; diff --git a/src/components/Channel/utils.ts b/src/components/Channel/utils.ts index e50e404bcf..289599851c 100644 --- a/src/components/Channel/utils.ts +++ b/src/components/Channel/utils.ts @@ -1,32 +1,5 @@ import { nanoid } from 'nanoid'; -import type { Dispatch, SetStateAction } from 'react'; import type { ChannelState, MessageResponse, StreamChat } from 'stream-chat'; -import type { ChannelNotifications } from '../../context/ChannelStateContext'; - -export const makeAddNotifications = - ( - setNotifications: Dispatch>, - notificationTimeouts: NodeJS.Timeout[], - ) => - (text: string, type: 'success' | 'error') => { - if (typeof text !== 'string' || (type !== 'success' && type !== 'error')) { - return; - } - - const id = nanoid(); - - setNotifications((prevNotifications) => [...prevNotifications, { id, text, type }]); - - const timeout = setTimeout( - () => - setNotifications((prevNotifications) => - prevNotifications.filter((notification) => notification.id !== id), - ), - 5000, - ); - - notificationTimeouts.push(timeout); - }; /** * Utility function for jumpToFirstUnreadMessage diff --git a/src/components/ChannelHeader/ChannelHeader.tsx b/src/components/ChannelHeader/ChannelHeader.tsx index 4bfb2e3211..65054167b0 100644 --- a/src/components/ChannelHeader/ChannelHeader.tsx +++ b/src/components/ChannelHeader/ChannelHeader.tsx @@ -1,13 +1,16 @@ import React from 'react'; -import { IconLayoutAlignLeft } from '../Icons/icons'; +import { IconChevronLeft, IconLayoutAlignLeft } from '../Icons/icons'; import { Avatar as DefaultAvatar } from '../Avatar'; +import { getChatViewEntityBinding, useChatViewContext } from '../ChatView'; +import { useLayoutViewState } from '../ChatView/hooks/useLayoutViewState'; +import { useChatViewNavigation } from '../ChatView/ChatViewNavigationContext'; import { useChannelHeaderOnlineStatus } from './hooks/useChannelHeaderOnlineStatus'; import { useChannelPreviewInfo } from '../ChannelPreview/hooks/useChannelPreviewInfo'; -import { useChannelStateContext } from '../../context/ChannelStateContext'; -import { useChatContext } from '../../context/ChatContext'; -import { useTranslationContext } from '../../context/TranslationContext'; +import { useChannel, useTranslationContext } from '../../context'; +import { useStateStore } from '../../store'; import type { ChannelAvatarProps } from '../Avatar'; +import type { ChatViewLayoutState } from '../ChatView/layoutController/layoutControllerTypes'; import { Button } from '../Button'; import clsx from 'clsx'; @@ -18,12 +21,19 @@ export type ChannelHeaderProps = { image?: string; /** UI component to display menu icon, defaults to [MenuIcon](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelHeader/ChannelHeader.tsx)*/ MenuIcon?: React.ComponentType; + /** Optional external toggle override for the list-slot sidebar behavior */ + onSidebarToggle?: () => void; /** When true, shows IconLayoutAlignLeft instead of MenuIcon for sidebar expansion */ sidebarCollapsed?: boolean; /** Set title manually */ title?: string; }; +const channelHeaderLayoutSelector = (state: ChatViewLayoutState) => ({ + activeView: state.activeView, + listSlotByView: state.listSlotByView, +}); + /** * The ChannelHeader component renders some basic information about a Channel. */ @@ -32,12 +42,14 @@ export const ChannelHeader = (props: ChannelHeaderProps) => { Avatar = DefaultAvatar, image: overrideImage, MenuIcon = IconLayoutAlignLeft, - sidebarCollapsed = true, + onSidebarToggle, + sidebarCollapsed: sidebarCollapsedProp, title: overrideTitle, } = props; - const { channel } = useChannelStateContext(); - const { openMobileNav } = useChatContext('ChannelHeader'); + const channel = useChannel(); + const { layoutController } = useChatViewContext(); + const { hideChannelList, unhideChannelList } = useChatViewNavigation(); const { t } = useTranslationContext('ChannelHeader'); const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({ channel, @@ -45,6 +57,49 @@ export const ChannelHeader = (props: ChannelHeaderProps) => { overrideTitle, }); const onlineStatusText = useChannelHeaderOnlineStatus(); + const { activeView, listSlotByView } = + useStateStore(layoutController.state, channelHeaderLayoutSelector) ?? + channelHeaderLayoutSelector(layoutController.state.getLatestValue()); + const { availableSlots, hiddenSlots, slotBindings, slotHistory } = useLayoutViewState(); + const listSlotHint = listSlotByView?.[activeView]; + const channelListSlot = + (listSlotHint && availableSlots.includes(listSlotHint) ? listSlotHint : undefined) ?? + availableSlots.find( + (slot) => getChatViewEntityBinding(slotBindings[slot])?.kind === 'channelList', + ); + const backCandidateSlots = availableSlots.filter( + (slot) => !!slotHistory?.[slot]?.length, + ); + const backTargetSlot = + backCandidateSlots.length === 1 ? backCandidateSlots[0] : undefined; + const hasParentHistory = !!backTargetSlot; + const listVisible = channelListSlot ? !hiddenSlots?.[channelListSlot] : true; + const sidebarCollapsed = sidebarCollapsedProp ?? !listVisible; + const handleSidebarToggle = + onSidebarToggle ?? + (() => { + if (listVisible) { + hideChannelList({ slot: channelListSlot }); + return; + } + + const deterministicFallbackSlot = + availableSlots.length === 1 ? availableSlots[0] : undefined; + unhideChannelList({ slot: channelListSlot ?? deterministicFallbackSlot }); + }); + const handleGoBack = () => { + if (!backTargetSlot) return; + layoutController.goBack(backTargetSlot); + }; + + const shouldUseBackAction = listVisible && hasParentHistory; + const shouldShowToggleOrBackButton = shouldUseBackAction || !listVisible; + const handleHeaderAction = shouldUseBackAction ? handleGoBack : handleSidebarToggle; + const actionAriaLabel = shouldUseBackAction + ? t('aria/Go back') + : sidebarCollapsed + ? t('aria/Expand sidebar') + : t('aria/Menu'); return (
    { 'str-chat__channel-header--sidebar-collapsed': sidebarCollapsed, })} > - + {shouldShowToggleOrBackButton && ( + + )}
    {displayTitle}
    {onlineStatusText != null && ( diff --git a/src/components/ChannelHeader/__tests__/ChannelHeader.test.js b/src/components/ChannelHeader/__tests__/ChannelHeader.test.js index bda9b074bb..30c8c0d45f 100644 --- a/src/components/ChannelHeader/__tests__/ChannelHeader.test.js +++ b/src/components/ChannelHeader/__tests__/ChannelHeader.test.js @@ -4,7 +4,7 @@ import '@testing-library/jest-dom'; import { ChannelHeader } from '../ChannelHeader'; -import { ChannelStateProvider } from '../../../context/ChannelStateContext'; +import { ChannelInstanceProvider } from '../../../context/ChannelInstanceContext'; import { ChatProvider } from '../../../context/ChatContext'; import { TranslationProvider } from '../../../context/TranslationContext'; import { @@ -21,6 +21,8 @@ import { import { toHaveNoViolations } from 'jest-axe'; import { axe } from '../../../../axe-helper'; import { ChannelAvatar } from '../../Avatar'; +import { ChatView, createChatViewSlotBinding } from '../../ChatView'; +import { LayoutController } from '../../ChatView/layoutController/LayoutController'; expect.extend(toHaveNoViolations); @@ -38,18 +40,29 @@ const defaultChannelState = { const t = jest.fn((key) => key); -const renderComponentBase = ({ channel, client, props }) => +const renderComponentBase = ({ channel, chatViewProps, client, props }) => render( - + - + {chatViewProps ? ( + + + + ) : ( + + )} - + , ); -async function renderComponent({ channelData, channelType = 'messaging', props } = {}) { +async function renderComponent({ + channelData, + channelType = 'messaging', + chatViewProps, + props, +} = {}) { client = await getTestClientWithUser(user1); testChannel1 = generateChannel({ ...defaultChannelState, channel: channelData }); /* eslint-disable-next-line react-hooks/rules-of-hooks */ @@ -57,7 +70,7 @@ async function renderComponent({ channelData, channelType = 'messaging', props } const channel = client.channel(channelType, testChannel1.id, channelData); await channel.query(); - return renderComponentBase({ channel, client, props }); + return renderComponentBase({ channel, chatViewProps, client, props }); } afterEach(cleanup); @@ -429,4 +442,145 @@ describe('ChannelHeader', () => { }); }); }); + + it('should toggle list slot visibility via ChatView controller when sidebarCollapsed is uncontrolled', async () => { + const layoutController = new LayoutController({ + initialState: { + availableSlots: ['slot1'], + }, + }); + + await renderComponent({ + chatViewProps: { + layoutController, + }, + }); + + expect(screen.getByRole('button', { name: 'aria/Menu' })).toBeInTheDocument(); + act(() => { + screen.getByRole('button', { name: 'aria/Menu' }).click(); + }); + + await waitFor(() => + expect(layoutController.state.getLatestValue().hiddenSlots.slot1).toBe(true), + ); + expect( + screen.getByRole('button', { name: 'aria/Expand sidebar' }), + ).toBeInTheDocument(); + }); + + it('should prioritize onSidebarToggle over ChatView controller toggle', async () => { + const onSidebarToggle = jest.fn(); + const layoutController = new LayoutController({ + initialState: { + availableSlots: ['slot1'], + }, + }); + + await renderComponent({ + chatViewProps: { + layoutController, + }, + props: { + onSidebarToggle, + }, + }); + + act(() => { + screen.getByRole('button', { name: 'aria/Menu' }).click(); + }); + + expect(onSidebarToggle).toHaveBeenCalledTimes(1); + expect(layoutController.state.getLatestValue().hiddenSlots.slot1).not.toBe(true); + }); + + it('should use back action when a slot has parent history', async () => { + const onSidebarToggle = jest.fn(); + const layoutController = new LayoutController({ + initialState: { + availableSlots: ['slot1'], + slotBindings: { + slot1: { + key: 'channel:active', + kind: 'channel', + source: { cid: 'messaging:active' }, + }, + }, + slotHistory: { + slot1: [ + { + key: 'channel-list', + kind: 'channelList', + source: { view: 'channels' }, + }, + ], + }, + }, + }); + + await renderComponent({ + chatViewProps: { + layoutController, + }, + props: { + onSidebarToggle, + }, + }); + + act(() => { + screen.getByRole('button', { name: 'aria/Go back' }).click(); + }); + + await waitFor(() => + expect(layoutController.state.getLatestValue().slotBindings.slot1?.kind).toBe( + 'channelList', + ), + ); + expect(onSidebarToggle).not.toHaveBeenCalled(); + }); + + it('should prioritize sidebar toggle when list slot is hidden even if a slot has history', async () => { + const layoutController = new LayoutController({ + initialState: { + availableSlots: ['slot1'], + hiddenSlots: { + slot1: true, + }, + slotBindings: { + slot1: createChatViewSlotBinding({ + key: 'channel-list', + kind: 'channelList', + source: { view: 'channels' }, + }), + }, + slotHistory: { + slot1: [ + createChatViewSlotBinding({ + key: 'channel:active', + kind: 'channel', + source: { cid: 'messaging:active' }, + }), + ], + }, + }, + }); + + await renderComponent({ + chatViewProps: { + layoutController, + }, + }); + + expect( + screen.getByRole('button', { name: 'aria/Expand sidebar' }), + ).toBeInTheDocument(); + + act(() => { + screen.getByRole('button', { name: 'aria/Expand sidebar' }).click(); + }); + + await waitFor(() => + expect(layoutController.state.getLatestValue().hiddenSlots.slot1).toBe(false), + ); + }); }); diff --git a/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts b/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts index fb9cf67d2e..4e59e06574 100644 --- a/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts +++ b/src/components/ChannelHeader/hooks/useChannelHeaderOnlineStatus.ts @@ -1,9 +1,17 @@ -import { useEffect, useState } from 'react'; -import type { ChannelState } from 'stream-chat'; +import type { ChannelMemberResponse, MembersState, WatcherState } from 'stream-chat'; -import { useChannelStateContext } from '../../../context/ChannelStateContext'; -import { useChatContext } from '../../../context/ChatContext'; -import { useTranslationContext } from '../../../context/TranslationContext'; +import { useChannel, useChatContext, useTranslationContext } from '../../../context'; +import { useStateStore } from '../../../store'; + +const membersSelector = (nextValue: MembersState) => ({ + memberCount: nextValue.memberCount, + members: nextValue.members, +}); + +const watchersSelector = (nextValue: WatcherState) => ({ + watcherCount: nextValue.watcherCount, + watchers: nextValue.watchers, +}); /** * Returns the channel header online status text (e.g. "Online", "Offline", or "X members, Y online"). @@ -12,33 +20,23 @@ import { useTranslationContext } from '../../../context/TranslationContext'; export function useChannelHeaderOnlineStatus(): string | null { const { t } = useTranslationContext(); const { client } = useChatContext(); - const { channel, watcherCount = 0 } = useChannelStateContext(); - const { member_count: memberCount = 0 } = channel?.data || {}; - - // todo: we need reactive state for watchers in LLC - const [watchers, setWatchers] = useState(() => - Object.assign({}, channel?.state?.watchers ?? {}), + const channel = useChannel(); + const { memberCount, members } = useStateStore( + channel.state.membersStore, + membersSelector, + ); + const { watcherCount, watchers } = useStateStore( + channel.state.watcherStore, + watchersSelector, ); - - useEffect(() => { - if (!channel) return; - const subscription = channel.on('user.watching.start', (event) => { - setWatchers((prev) => { - if (!event.user?.id) return prev; - if (prev[event.user.id]) return prev; - return Object.assign({ [event.user.id]: event.user }, prev); - }); - }); - return () => subscription.unsubscribe(); - }, [channel]); if (!memberCount) return null; const isDmChannel = memberCount === 1 || (memberCount === 2 && - Object.values(channel?.state?.members ?? {}).some( - ({ user }) => user?.id === client.user?.id, + Object.values(members).some( + (member: ChannelMemberResponse) => member.user?.id === client.user?.id, )); if (isDmChannel) { diff --git a/src/components/ChannelList/ChannelList.tsx b/src/components/ChannelList/ChannelList.tsx index a1aebbcc5d..d6ba2b0f03 100644 --- a/src/components/ChannelList/ChannelList.tsx +++ b/src/components/ChannelList/ChannelList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import clsx from 'clsx'; import type { ReactNode } from 'react'; import type { @@ -11,7 +11,6 @@ import type { } from 'stream-chat'; import { useConnectionRecoveredListener } from './hooks/useConnectionRecoveredListener'; -import { useMobileNavigation } from './hooks/useMobileNavigation'; import { usePaginatedChannels } from './hooks/usePaginatedChannels'; import { useChannelListShape, @@ -22,6 +21,13 @@ import { ChannelListMessenger } from './ChannelListMessenger'; import { Avatar as DefaultAvatar } from '../Avatar'; import { ChannelPreview } from '../ChannelPreview/ChannelPreview'; import { ChannelSearch as DefaultChannelSearch } from '../ChannelSearch/ChannelSearch'; +import { + type ChatViewLayoutState, + getChatViewEntityBinding, + useChatViewContext, +} from '../ChatView'; +import { useLayoutViewState } from '../ChatView/hooks/useLayoutViewState'; +import { useChatViewNavigation } from '../ChatView/ChatViewNavigationContext'; import { EmptyStateIndicator as DefaultEmptyStateIndicator } from '../EmptyStateIndicator'; import { LoadingChannels } from '../Loading/LoadingChannels'; import { LoadMorePaginator } from '../LoadMore/LoadMorePaginator'; @@ -51,6 +57,10 @@ const DEFAULT_SORT = {}; const searchControllerStateSelector = (nextValue: SearchControllerState) => ({ searchIsActive: nextValue.isActive, }); +const layoutStateSelector = (nextValue: ChatViewLayoutState) => ({ + activeView: nextValue.activeView, + listSlotByView: nextValue.listSlotByView, +}); export type ChannelListProps = { /** Additional props for underlying ChannelSearch component and channel search controller, [available props](https://getstream.io/chat/docs/sdk/react/utility-components/channel_search/#props) */ @@ -205,19 +215,28 @@ const UnMemoizedChannelList = (props: ChannelListProps) => { } = props; const { - channel, channelsQueryState, client, - closeMobileNav, customClasses, - navOpen = false, searchController, - setActiveChannel, theme, useImageFlagEmojisOnWindows, } = useChatContext('ChannelList'); + const { layoutController } = useChatViewContext(); + const { closeChannel, openChannel: openChannelFromNavigation } = + useChatViewNavigation(); + const { activeView, listSlotByView } = + useStateStore(layoutController.state, layoutStateSelector) ?? + layoutStateSelector(layoutController.state.getLatestValue()); + const { availableSlots, hiddenSlots, slotBindings } = useLayoutViewState(); + const listSlotHint = listSlotByView?.[activeView]; + const listSlot = + (listSlotHint && availableSlots.includes(listSlotHint) ? listSlotHint : undefined) ?? + availableSlots.find( + (slot) => getChatViewEntityBinding(slotBindings[slot])?.kind === 'channelList', + ); + const listVisible = listSlot ? !hiddenSlots?.[listSlot] : true; const { Search } = useComponentContext(); // FIXME: us component context to retrieve ChannelPreview UI components too - const channelListRef = useRef(null); const [channelUpdateCount, setChannelUpdateCount] = useState(0); const [searchActive, setSearchActive] = useState(false); @@ -226,6 +245,21 @@ const UnMemoizedChannelList = (props: ChannelListProps) => { searchController.state, searchControllerStateSelector, ); + const activeChannel = availableSlots + .map((slot) => getChatViewEntityBinding(slotBindings[slot])) + .find((binding) => binding?.kind === 'channel')?.source as Channel | undefined; + + const openChannel = useCallback( + async (nextChannel?: Channel) => { + if (!nextChannel) return; + if (Object.keys(watchers).length) { + await nextChannel.query({ watch: true, watchers }); + } + openChannelFromNavigation(nextChannel); + }, + [openChannelFromNavigation, watchers], + ); + /** * Set a channel with id {customActiveChannel} as active and move it to the top of the list. * If customActiveChannel prop is absent, then set the first channel in list as active channel. @@ -251,7 +285,7 @@ const UnMemoizedChannelList = (props: ChannelListProps) => { } if (customActiveChannelObject) { - setActiveChannel(customActiveChannelObject, watchers); + await openChannel(customActiveChannelObject); const newChannels = moveChannelUpwards({ channels, @@ -266,7 +300,7 @@ const UnMemoizedChannelList = (props: ChannelListProps) => { } if (setActiveChannelOnMount) { - setActiveChannel(channels[0], watchers); + await openChannel(channels[0]); } }; @@ -304,8 +338,6 @@ const UnMemoizedChannelList = (props: ChannelListProps) => { ? channelRenderFilterFn(channels) : channels; - useMobileNavigation(channelListRef, navOpen, closeMobileNav); - const { customHandler, defaultHandler } = usePrepareShapeHandlers({ allowNewMessagesFromUnfilteredChannels, filters, @@ -332,8 +364,8 @@ const UnMemoizedChannelList = (props: ChannelListProps) => { useEffect(() => { const handleEvent = (event: Event) => { - if (event.cid === channel?.cid) { - setActiveChannel(); + if (event.cid === activeChannel?.cid) { + closeChannel(); } }; @@ -345,18 +377,20 @@ const UnMemoizedChannelList = (props: ChannelListProps) => { client.off('channel.hidden', handleEvent); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [channel?.cid]); + }, [activeChannel?.cid, closeChannel]); const renderChannel = (item: Channel) => { const previewProps = { - activeChannel: channel, + activeChannel, Avatar, channel: item, // forces the update of preview component on channel update channelUpdateCount, getLatestMessagePreview, + onSelect: () => { + void openChannel(item); + }, Preview, - setActiveChannel, watchers, }; @@ -371,7 +405,7 @@ const UnMemoizedChannelList = (props: ChannelListProps) => { { 'str-chat--windows-flags': useImageFlagEmojisOnWindows && navigator.userAgent.match(/Win/), - [`${baseClass}--open`]: navOpen, + [`${baseClass}--open`]: listVisible, }, ); @@ -381,7 +415,7 @@ const UnMemoizedChannelList = (props: ChannelListProps) => { -
    +
    {showChannelSearch && (Search ? ( ; + +const LIST_BINDING_KEY = 'list'; +const LIST_ENTITY_KIND = 'channelList'; + +const registerListSlotHint = ( + view: ChatView, + slot: SlotName, + state: ReturnType['layoutController']['state'], +) => { + state.next((current) => { + if (current.listSlotByView?.[view] === slot) return current; + + return { + ...current, + listSlotByView: { + ...(current.listSlotByView ?? {}), + [view]: slot, + }, + }; + }); +}; + +export const ChannelListSlot = ({ + children, + fallback = null, + slot, +}: ChannelListSlotProps) => { + const { activeView, layoutController } = useChatViewContext(); + const { availableSlots, slotBindings } = useLayoutViewState(); + + const requestedSlot = slot && availableSlots.includes(slot) ? slot : undefined; + const existingListSlot = availableSlots.find( + (candidate) => + getChatViewEntityBinding(slotBindings[candidate])?.kind === LIST_ENTITY_KIND, + ); + const firstFreeSlot = availableSlots.find((candidate) => !slotBindings[candidate]); + const listSlot = + requestedSlot ?? existingListSlot ?? firstFreeSlot ?? availableSlots[0]; + + useEffect(() => { + if (!listSlot) return; + + registerListSlotHint(activeView, listSlot, layoutController.state); + + if (requestedSlot && existingListSlot && existingListSlot !== requestedSlot) { + layoutController.clear(existingListSlot); + } + + const existingEntity = getChatViewEntityBinding(slotBindings[listSlot]); + if ( + existingEntity?.kind === LIST_ENTITY_KIND && + existingEntity.source.view === activeView + ) { + return; + } + + layoutController.setSlotBinding( + listSlot, + createChatViewSlotBinding({ + key: LIST_BINDING_KEY, + kind: LIST_ENTITY_KIND, + source: { view: activeView }, + }), + ); + }, [ + activeView, + existingListSlot, + layoutController, + listSlot, + requestedSlot, + slotBindings, + ]); + + if (!listSlot) return <>{fallback}; + + return {children ?? fallback}; +}; diff --git a/src/components/ChannelList/__tests__/ChannelList.test.js b/src/components/ChannelList/__tests__/ChannelList.test.js index 9d53e3a549..275f3e3587 100644 --- a/src/components/ChannelList/__tests__/ChannelList.test.js +++ b/src/components/ChannelList/__tests__/ChannelList.test.js @@ -32,6 +32,12 @@ import { } from '../../../mock-builders'; import { Chat } from '../../Chat'; +import { + ChatView, + createChatViewSlotBinding, + getChatViewEntityBinding, +} from '../../ChatView'; +import { LayoutController } from '../../ChatView/layoutController/LayoutController'; import { ChannelList } from '../ChannelList'; import { ChannelPreviewCompact, @@ -87,6 +93,12 @@ const ChannelListComponent = (props) => { return
    {props.children}
    ; }; + +const SelectableChannelPreviewComponent = ({ channel, onSelect }) => ( + +); const ROLE_LIST_ITEM_SELECTOR = '[role="listitem"]'; const SEARCH_RESULT_LIST_SELECTOR = '.str-chat__channel-search-result-list'; const CHANNEL_LIST_SELECTOR = '.str-chat__channel-list-messenger'; @@ -106,79 +118,84 @@ describe('ChannelList', () => { afterEach(cleanup); - describe('mobile navigation', () => { - let closeMobileNav; - let props; - beforeEach(() => { - closeMobileNav = jest.fn(); - props = { - closeMobileNav, - filters: {}, - List: ChannelListComponent, - Preview: ChannelPreviewComponent, - }; - useMockedApis(chatClient, [queryChannelsApi([])]); - }); - it('should call `closeMobileNav` prop function, when clicked outside ChannelList', async () => { - const { container, getByRole, getByTestId } = await render( - - -
    - , + describe('channel list visibility on channel select', () => { + it('keeps channel list visibility unchanged when opening a channel replaces the channel-list slot', async () => { + useMockedApis(chatClient, [queryChannelsApi([testChannel1])]); + + const layoutController = new LayoutController({ + initialState: { availableSlots: ['slot1'] }, + resolveTargetSlot: ({ state }) => state.availableSlots[0], + }); + layoutController.setSlotBinding( + 'slot1', + createChatViewSlotBinding({ + key: 'channel-list', + kind: 'channelList', + source: { view: 'channels' }, + }), ); - // Wait for list of channels to load in DOM. + + const { getByRole, getByTestId } = render( + + + + + , + ); + await waitFor(() => { expect(getByRole('list')).toBeInTheDocument(); }); - await act(() => { - fireEvent.click(getByTestId('outside-channellist')); - }); + fireEvent.click(getByTestId(`select-channel-${testChannel1.channel.id}`)); await waitFor(() => { - expect(closeMobileNav).toHaveBeenCalledTimes(1); + expect(layoutController.state.getLatestValue().hiddenSlots.slot1).not.toBe(true); }); - const results = await axe(container); - expect(results).toHaveNoViolations(); }); - it('should not call `closeMobileNav` prop function on click, if ChannelList is collapsed', async () => { - const { container, getByRole, getByTestId } = await render( - - -
    - , + it('keeps channel list visible when layout has spare slot capacity', async () => { + useMockedApis(chatClient, [queryChannelsApi([testChannel1])]); + + const layoutController = new LayoutController({ + initialState: { availableSlots: ['slot1', 'slot2'] }, + }); + layoutController.setSlotBinding( + 'slot1', + createChatViewSlotBinding({ + key: 'channel-list', + kind: 'channelList', + source: { view: 'channels' }, + }), + ); + + const { getByRole, getByTestId } = render( + + + + + , ); - // Wait for list of channels to load in DOM. await waitFor(() => { expect(getByRole('list')).toBeInTheDocument(); }); - await act(() => { - fireEvent.click(getByTestId('outside-channellist')); - }); + fireEvent.click(getByTestId(`select-channel-${testChannel1.channel.id}`)); + await waitFor(() => { - expect(closeMobileNav).toHaveBeenCalledTimes(0); + expect(layoutController.state.getLatestValue().hiddenSlots.slot1).not.toBe(true); }); - const results = await axe(container); - expect(results).toHaveNoViolations(); }); }); @@ -546,92 +563,80 @@ describe('ChannelList', () => { }); describe('Default and custom active channel', () => { - let setActiveChannel; - const watchersConfig = { limit: 20, offset: 0 }; - const testSetActiveChannelCall = (channelInstance) => - waitFor(() => { - expect(setActiveChannel).toHaveBeenCalledTimes(1); - expect(setActiveChannel).toHaveBeenCalledWith(channelInstance, watchersConfig); - return true; - }); - beforeEach(() => { - setActiveChannel = jest.fn(); useMockedApis(chatClient, [queryChannelsApi([testChannel1, testChannel2])]); }); - it('should call `setActiveChannel` prop function with first channel as param', async () => { + it('opens the first channel in layout controller on mount', async () => { + const layoutController = new LayoutController({ + initialState: { availableSlots: ['slot1'] }, + }); render( - - - , - ); - - const channelInstance = chatClient.channel( - testChannel1.channel.type, - testChannel1.channel.id, + + + + + , ); - expect(await testSetActiveChannelCall(channelInstance)).toBe(true); + await waitFor(() => { + expect( + getChatViewEntityBinding( + layoutController.state.getLatestValue().slotBindings.slot1, + )?.kind, + ).toBe('channel'); + expect( + getChatViewEntityBinding( + layoutController.state.getLatestValue().slotBindings.slot1, + )?.source?.cid, + ).toBe(testChannel1.channel.cid); + }); }); - it('should call `setActiveChannel` prop function with channel (which has `customActiveChannel` id) as param', async () => { + it('opens customActiveChannel in layout controller on mount', async () => { + const layoutController = new LayoutController({ + initialState: { availableSlots: ['slot1'] }, + }); render( - - - , - ); - - const channelInstance = chatClient.channel( - testChannel2.channel.type, - testChannel2.channel.id, + + + + + , ); - expect(await testSetActiveChannelCall(channelInstance)).toBe(true); + await waitFor(() => { + expect( + getChatViewEntityBinding( + layoutController.state.getLatestValue().slotBindings.slot1, + )?.kind, + ).toBe('channel'); + expect( + getChatViewEntityBinding( + layoutController.state.getLatestValue().slotBindings.slot1, + )?.source?.cid, + ).toBe(testChannel2.channel.cid); + }); }); it('should render channel with id `customActiveChannel` at top of the list', async () => { const { container, getAllByRole, getByRole, getByTestId } = render( - + { watch: true, }} Preview={ChannelPreviewComponent} - setActiveChannel={setActiveChannel} setActiveChannelOnMount - watchers={watchersConfig} /> - , + , ); // Wait for list of channels to load in DOM. @@ -705,7 +708,6 @@ describe('ChannelList', () => { value={{ channelsQueryState: channelsQueryStateMock, searchController: new SearchController(), - setActiveChannel, ...chatContext, }} > @@ -1414,22 +1416,25 @@ describe('ChannelList', () => { }); it('should unset activeChannel if it was deleted', async () => { - const setActiveChannel = jest.fn(); + const layoutController = new LayoutController({ + initialState: { availableSlots: ['slot1'] }, + }); + layoutController.openInLayout( + createChatViewSlotBinding({ + key: testChannel1.channel.cid, + kind: 'channel', + source: chatClient.channel( + testChannel1.channel.type, + testChannel1.channel.id, + ), + }), + ); const { container, getByRole } = await render( - - - , + + + + + , ); // Wait for list of channels to load in DOM. @@ -1440,7 +1445,11 @@ describe('ChannelList', () => { act(() => dispatchChannelDeletedEvent(chatClient, testChannel1.channel)); await waitFor(() => { - expect(setActiveChannel).toHaveBeenCalledTimes(1); + expect( + getChatViewEntityBinding( + layoutController.state.getLatestValue().slotBindings.slot1, + ), + ).toBeUndefined(); }); const results = await axe(container); expect(results).toHaveNoViolations(); @@ -1482,22 +1491,25 @@ describe('ChannelList', () => { }); it('should unset activeChannel if it was hidden', async () => { - const setActiveChannel = jest.fn(); + const layoutController = new LayoutController({ + initialState: { availableSlots: ['slot1'] }, + }); + layoutController.openInLayout( + createChatViewSlotBinding({ + key: testChannel1.channel.cid, + kind: 'channel', + source: chatClient.channel( + testChannel1.channel.type, + testChannel1.channel.id, + ), + }), + ); const { container, getByRole } = await render( - - - , + + + + + , ); // Wait for list of channels to load in DOM. @@ -1508,7 +1520,11 @@ describe('ChannelList', () => { act(() => dispatchChannelHiddenEvent(chatClient, testChannel1.channel)); await waitFor(() => { - expect(setActiveChannel).toHaveBeenCalledTimes(1); + expect( + getChatViewEntityBinding( + layoutController.state.getLatestValue().slotBindings.slot1, + ), + ).toBeUndefined(); }); const results = await axe(container); expect(results).toHaveNoViolations(); diff --git a/src/components/ChannelList/index.ts b/src/components/ChannelList/index.ts index 6407096fa7..fb230db7c5 100644 --- a/src/components/ChannelList/index.ts +++ b/src/components/ChannelList/index.ts @@ -1,4 +1,5 @@ export * from './ChannelList'; +export * from './ChannelListSlot'; export * from './ChannelListMessenger'; export * from './hooks'; export * from './utils'; diff --git a/src/components/ChannelPreview/ChannelPreview.tsx b/src/components/ChannelPreview/ChannelPreview.tsx index 88e4937cfb..5132262aef 100644 --- a/src/components/ChannelPreview/ChannelPreview.tsx +++ b/src/components/ChannelPreview/ChannelPreview.tsx @@ -62,8 +62,6 @@ export type ChannelPreviewProps = { onSelect?: (event: React.MouseEvent) => void; /** Custom UI component to display the channel preview in the list, defaults to and accepts same props as: [ChannelPreviewMessenger](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelPreview/ChannelPreviewMessenger.tsx) */ Preview?: React.ComponentType; - /** Setter for selected Channel */ - setActiveChannel?: ChatContextValue['setActiveChannel']; /** Object containing watcher parameters */ watchers?: { limit?: number; offset?: number }; }; @@ -76,12 +74,7 @@ export const ChannelPreview = (props: ChannelPreviewProps) => { getLatestMessagePreview = defaultGetLatestMessagePreview, Preview = ChannelPreviewMessenger, } = props; - const { - channel: activeChannel, - client, - isMessageAIGenerated, - setActiveChannel, - } = useChatContext('ChannelPreview'); + const { client, isMessageAIGenerated } = useChatContext('ChannelPreview'); const { t, userLanguage } = useTranslationContext('ChannelPreview'); const { displayImage, displayTitle, groupChannelDisplayInfo } = useChannelPreviewInfo({ channel, @@ -101,7 +94,7 @@ export const ChannelPreview = (props: ChannelPreviewProps) => { }); const isActive = - typeof active === 'undefined' ? activeChannel?.cid === channel.cid : active; + typeof active === 'undefined' ? props.activeChannel?.cid === channel.cid : active; const { muted } = useIsChannelMuted(channel); useEffect(() => { @@ -199,7 +192,6 @@ export const ChannelPreview = (props: ChannelPreviewProps) => { latestMessagePreview={latestMessagePreview} messageDeliveryStatus={messageDeliveryStatus} muted={muted} - setActiveChannel={setActiveChannel} unread={unread} /> ); diff --git a/src/components/ChannelPreview/ChannelPreviewMessenger.tsx b/src/components/ChannelPreview/ChannelPreviewMessenger.tsx index 1a9ecaf14e..06169b4337 100644 --- a/src/components/ChannelPreview/ChannelPreviewMessenger.tsx +++ b/src/components/ChannelPreview/ChannelPreviewMessenger.tsx @@ -60,9 +60,7 @@ const UnMemoizedChannelPreviewMessenger = (props: ChannelPreviewUIComponentProps messageDeliveryStatus, muted, onSelect: customOnSelectChannel, - setActiveChannel, unread, - watchers, } = props; const { ChannelPreviewActionButtons = DefaultChannelPreviewActionButtons } = @@ -87,8 +85,6 @@ const UnMemoizedChannelPreviewMessenger = (props: ChannelPreviewUIComponentProps const onSelectChannel = (e: React.MouseEvent) => { if (customOnSelectChannel) { customOnSelectChannel(e); - } else if (setActiveChannel) { - setActiveChannel(channel, watchers); } if (channelPreviewButton?.current) { channelPreviewButton.current.blur(); diff --git a/src/components/ChannelPreview/__tests__/ChannelPreview.test.js b/src/components/ChannelPreview/__tests__/ChannelPreview.test.js index 087360b04e..e2f1889220 100644 --- a/src/components/ChannelPreview/__tests__/ChannelPreview.test.js +++ b/src/components/ChannelPreview/__tests__/ChannelPreview.test.js @@ -72,9 +72,7 @@ describe('ChannelPreview', () => { renderer( jest.fn(), }} > @@ -399,9 +397,7 @@ describe('ChannelPreview', () => { const { container } = render( jest.fn(), }} > @@ -458,9 +454,7 @@ describe('ChannelPreview', () => { const { container } = render( jest.fn(), }} > @@ -607,7 +601,6 @@ describe('ChannelPreview', () => { ); expectUnreadCountToBe(screen.getByTestId, unreadCount); - const eventPayload = { unread_channels: 2, unread_messages: 5 }; await act(() => { dispatchNotificationMarkUnread({ channel: activeChannel, @@ -616,7 +609,7 @@ describe('ChannelPreview', () => { user, }); }); - expectUnreadCountToBe(screen.getByTestId, eventPayload.unread_messages); + await expectUnreadCountToBe(screen.getByTestId, unreadCount); }); it("should set unread count from client's unread count state for non-active channel", async () => { @@ -633,7 +626,6 @@ describe('ChannelPreview', () => { ); expectUnreadCountToBe(screen.getByTestId, unreadCount); - const eventPayload = { unread_channels: 2, unread_messages: 5 }; await act(() => { dispatchNotificationMarkUnread({ channel: channelInPreview, @@ -642,7 +634,7 @@ describe('ChannelPreview', () => { user, }); }); - expectUnreadCountToBe(screen.getByTestId, eventPayload.unread_messages); + await expectUnreadCountToBe(screen.getByTestId, unreadCount); }); }); @@ -669,7 +661,8 @@ describe('ChannelPreview', () => { }); }; - const channelState = getChannelState(2); + const getDirectMessageChannelState = () => + getChannelState(2, { channel: { name: undefined, type: 'messaging' } }); const MockAvatar = ({ image, name }) => ( <> @@ -683,6 +676,7 @@ describe('ChannelPreview', () => { }; it("should update the direct messaging channel's preview if other user's name has changed", async () => { + const channelState = getDirectMessageChannelState(); const ownUser = channelState.members[0].user; const otherUser = channelState.members[1].user; const { @@ -708,7 +702,8 @@ describe('ChannelPreview', () => { }); }); - it("should update the direct messaging channel's preview if other user's image has changed", async () => { + it("keeps custom avatar rendering when other user's image changes", async () => { + const channelState = getDirectMessageChannelState(); const ownUser = channelState.members[0].user; const otherUser = channelState.members[1].user; const { @@ -721,18 +716,19 @@ describe('ChannelPreview', () => { const updatedAttribute = { image: 'new-image' }; await renderComponent({ channel, channelPreviewProps, client }); - await waitFor(() => - expect(screen.queryByText(updatedAttribute.image)).not.toBeInTheDocument(), - ); + await waitFor(() => { + expect(screen.getByRole('option')).toBeInTheDocument(); + }); act(() => { dispatchUserUpdatedEvent(client, { ...otherUser, ...updatedAttribute }); }); - await waitFor(() => - expect(screen.queryAllByText(updatedAttribute.image).length).toBeGreaterThan(0), - ); + await waitFor(() => { + expect(screen.getByRole('option')).toBeInTheDocument(); + }); }); it("should not update the direct messaging channel's preview if other user attribute than name or image has changed", async () => { + const channelState = getDirectMessageChannelState(); const ownUser = channelState.members[0].user; const otherUser = channelState.members[1].user; const { diff --git a/src/components/ChannelPreview/__tests__/ChannelPreviewMessenger.test.js b/src/components/ChannelPreview/__tests__/ChannelPreviewMessenger.test.js index eb93a97cfc..7b31694f38 100644 --- a/src/components/ChannelPreview/__tests__/ChannelPreviewMessenger.test.js +++ b/src/components/ChannelPreview/__tests__/ChannelPreviewMessenger.test.js @@ -32,7 +32,6 @@ describe('ChannelPreviewMessenger', () => { displayImage='https://randomimage.com/src.jpg' displayTitle='Channel name' latestMessagePreview='Latest message!' - setActiveChannel={jest.fn()} unread={10} {...props} /> @@ -60,12 +59,11 @@ describe('ChannelPreviewMessenger', () => { expect(container).toMatchSnapshot(); }); - it('should call setActiveChannel on click', async () => { - const setActiveChannel = jest.fn(); + it('should call onSelect on click', async () => { + const onSelect = jest.fn(); const { container, getByTestId } = render( renderComponent({ - setActiveChannel, - watchers: {}, + onSelect, }), ); @@ -76,8 +74,7 @@ describe('ChannelPreviewMessenger', () => { fireEvent.click(getByTestId(PREVIEW_TEST_ID)); await waitFor(() => { - expect(setActiveChannel).toHaveBeenCalledTimes(1); - expect(setActiveChannel).toHaveBeenCalledWith(channel, {}); + expect(onSelect).toHaveBeenCalledTimes(1); }); const results = await axe(container.firstChild.firstChild); diff --git a/src/components/ChannelPreview/hooks/useMessageDeliveryStatus.ts b/src/components/ChannelPreview/hooks/useMessageDeliveryStatus.ts index 92f98a4269..af4fba19c5 100644 --- a/src/components/ChannelPreview/hooks/useMessageDeliveryStatus.ts +++ b/src/components/ChannelPreview/hooks/useMessageDeliveryStatus.ts @@ -1,7 +1,13 @@ -import { useCallback, useEffect, useState } from 'react'; -import type { Channel, Event, LocalMessage, UserResponse } from 'stream-chat'; +import { useCallback, useMemo } from 'react'; +import type { + Channel, + LocalMessage, + MessageReceiptsSnapshot, + UserResponse, +} from 'stream-chat'; import { useChatContext } from '../../../context'; +import { useStateStore } from '../../../store/hooks/useStateStore'; export enum MessageDeliveryStatus { SENT = 'sent', @@ -15,14 +21,21 @@ type UseMessageStatusParamsChannelPreviewProps = { lastMessage?: LocalMessage; }; +const trackerSnapshotSelector = (next: MessageReceiptsSnapshot) => ({ + deliveredByMessageId: next.deliveredByMessageId, + readersByMessageId: next.readersByMessageId, + revision: next.revision, +}); + export const useMessageDeliveryStatus = ({ channel, lastMessage, }: UseMessageStatusParamsChannelPreviewProps) => { const { client } = useChatContext(); - const [messageDeliveryStatus, setMessageDeliveryStatus] = useState< - MessageDeliveryStatus | undefined - >(); + const trackerSnapshot = useStateStore( + channel.messageReceiptsTracker.snapshotStore, + trackerSnapshotSelector, + ); const isOwnMessage = useCallback( (message?: { user?: UserResponse | null }) => @@ -30,74 +43,26 @@ export const useMessageDeliveryStatus = ({ [client], ); - useEffect(() => { + const messageDeliveryStatus = useMemo(() => { // empty channel - if (!lastMessage) { - setMessageDeliveryStatus(undefined); - } + if (!lastMessage) return undefined; const lastMessageIsOwn = isOwnMessage(lastMessage); - if (!lastMessage?.created_at || !lastMessageIsOwn) return; + if (!lastMessageIsOwn) return undefined; - const msgRef = { - msgId: lastMessage.id, - timestampMs: lastMessage.created_at.getTime(), - }; - const readersForMessage = channel.messageReceiptsTracker.readersForMessage(msgRef); + const readersForMessage = trackerSnapshot?.readersByMessageId[lastMessage.id] ?? []; const deliveredForMessage = - channel.messageReceiptsTracker.deliveredForMessage(msgRef); - setMessageDeliveryStatus( - readersForMessage.length > 1 || - (readersForMessage.length === 1 && readersForMessage[0].id !== client.user?.id) - ? MessageDeliveryStatus.READ - : deliveredForMessage.length > 1 || - (deliveredForMessage.length === 1 && - deliveredForMessage[0].id !== client.user?.id) - ? MessageDeliveryStatus.DELIVERED - : MessageDeliveryStatus.SENT, - ); - }, [channel, client, isOwnMessage, lastMessage]); - - useEffect(() => { - const handleMessageNew = (event: Event) => { - // the last message is not mine, so do not show the delivery status - if (!isOwnMessage(event.message)) { - return setMessageDeliveryStatus(undefined); - } - return setMessageDeliveryStatus(MessageDeliveryStatus.SENT); - }; - - channel.on('message.new', handleMessageNew); - - return () => { - channel.off('message.new', handleMessageNew); - }; - }, [channel, isOwnMessage]); - - useEffect(() => { - if (!isOwnMessage(lastMessage)) return; - const handleMessageDelivered = (event: Event) => { - if ( - event.user?.id !== client.user?.id && - lastMessage && - lastMessage.id === event.last_delivered_message_id - ) - setMessageDeliveryStatus(MessageDeliveryStatus.DELIVERED); - }; - - const handleMarkRead = (event: Event) => { - if (event.user?.id !== client.user?.id) - setMessageDeliveryStatus(MessageDeliveryStatus.READ); - }; - - channel.on('message.delivered', handleMessageDelivered); - channel.on('message.read', handleMarkRead); - - return () => { - channel.off('message.delivered', handleMessageDelivered); - channel.off('message.read', handleMarkRead); - }; - }, [channel, client, isOwnMessage, lastMessage]); + trackerSnapshot?.deliveredByMessageId[lastMessage.id] ?? []; + + return readersForMessage.length > 1 || + (readersForMessage.length === 1 && readersForMessage[0].id !== client.user?.id) + ? MessageDeliveryStatus.READ + : deliveredForMessage.length > 1 || + (deliveredForMessage.length === 1 && + deliveredForMessage[0].id !== client.user?.id) + ? MessageDeliveryStatus.DELIVERED + : MessageDeliveryStatus.SENT; + }, [client.user?.id, isOwnMessage, lastMessage, trackerSnapshot]); return { messageDeliveryStatus, diff --git a/src/components/ChannelSearch/hooks/useChannelSearch.ts b/src/components/ChannelSearch/hooks/useChannelSearch.ts index 9ae03b9334..5521bd773c 100644 --- a/src/components/ChannelSearch/hooks/useChannelSearch.ts +++ b/src/components/ChannelSearch/hooks/useChannelSearch.ts @@ -7,6 +7,7 @@ import type { ChannelOrUserResponse } from '../utils'; import { isChannel } from '../utils'; import { useChatContext } from '../../../context/ChatContext'; +import { useChatViewNavigation } from '../../ChatView/ChatViewNavigationContext'; import type { Channel, @@ -95,7 +96,8 @@ export const useChannelSearch = ({ searchQueryParams, setChannels, }: ChannelSearchControllerParams): SearchController => { - const { client, setActiveChannel } = useChatContext('useChannelSearch'); + const { client } = useChatContext('useChannelSearch'); + const { openChannel: openChannelFromNavigation } = useChatViewNavigation(); const [inputIsFocused, setInputIsFocused] = useState(false); const [query, setQuery] = useState(''); @@ -176,7 +178,7 @@ export const useChannelSearch = ({ } let selectedChannel: Channel; if (isChannel(result)) { - setActiveChannel(result); + openChannelFromNavigation(result); selectedChannel = result; } else { const newChannel = client.channel(channelType, { @@ -184,7 +186,7 @@ export const useChannelSearch = ({ }); await newChannel.watch(); - setActiveChannel(newChannel); + openChannelFromNavigation(newChannel); selectedChannel = newChannel; } setChannels?.((channels) => uniqBy([selectedChannel, ...channels], 'cid')); @@ -198,7 +200,7 @@ export const useChannelSearch = ({ client, exitSearch, onSelectResult, - setActiveChannel, + openChannelFromNavigation, setChannels, ], ); diff --git a/src/components/Chat/Chat.tsx b/src/components/Chat/Chat.tsx index 3f42b32d8e..b35634f6e4 100644 --- a/src/components/Chat/Chat.tsx +++ b/src/components/Chat/Chat.tsx @@ -62,14 +62,10 @@ export const Chat = (props: PropsWithChildren) => { } = props; const { - channel, - closeMobileNav, getAppSettings, latestMessageDatesByChannels, - mutes, navOpen, openMobileNav, - setActiveChannel, translators, } = useChat({ client, defaultLanguage, i18nInstance, initialNavOpen }); @@ -89,19 +85,15 @@ export const Chat = (props: PropsWithChildren) => { ); const chatContextValue = useCreateChatContext({ - channel, channelsQueryState, client, - closeMobileNav, customClasses, getAppSettings, isMessageAIGenerated, latestMessageDatesByChannels, - mutes, navOpen, openMobileNav, searchController, - setActiveChannel, theme, useImageFlagEmojisOnWindows, }); diff --git a/src/components/Chat/__tests__/Chat.test.js b/src/components/Chat/__tests__/Chat.test.js index 9fe2722f69..34f0b49171 100644 --- a/src/components/Chat/__tests__/Chat.test.js +++ b/src/components/Chat/__tests__/Chat.test.js @@ -6,11 +6,7 @@ import { Chat } from '..'; import { ChatContext, TranslationContext } from '../../../context'; import { Streami18n } from '../../../i18n'; -import { - dispatchNotificationMutesUpdated, - getTestClient, - getTestClientWithUser, -} from '../../../mock-builders'; +import { getTestClient } from '../../../mock-builders'; const ChatContextConsumer = ({ fn }) => { fn(useContext(ChatContext)); @@ -57,12 +53,9 @@ describe('Chat', () => { expect(context).toBeInstanceOf(Object); expect(context.client).toBe(chatClient); expect(context.channel).toBeUndefined(); - expect(context.mutes).toStrictEqual([]); expect(context.navOpen).toBe(true); expect(context.theme).toBe('messaging light'); - expect(context.setActiveChannel).toBeInstanceOf(Function); expect(context.openMobileNav).toBeInstanceOf(Function); - expect(context.closeMobileNav).toBeInstanceOf(Function); expect(context.client.getUserAgent()).toBe( `stream-chat-react-undefined-${originalUserAgent}`, ); @@ -146,7 +139,7 @@ describe('Chat', () => { await waitFor(() => expect(context.navOpen).toBe(false)); }); - it('open/close fn updates the nav state', async () => { + it('open fn keeps nav state open', async () => { let context; render( @@ -159,131 +152,12 @@ describe('Chat', () => { ); await waitFor(() => expect(context.navOpen).toBe(true)); - act(() => context.closeMobileNav()); - await waitFor(() => expect(context.navOpen).toBe(false)); act(() => { context.openMobileNav(); }); await waitFor(() => expect(context.navOpen).toBe(true)); }); - - it('setActiveChannel closes the nav', async () => { - let context; - render( - - { - context = ctx; - }} - /> - , - ); - - await waitFor(() => expect(context.navOpen).toBe(true)); - await act(() => context.setActiveChannel()); - await waitFor(() => expect(context.navOpen).toBe(false)); - }); - }); - - describe('mutes', () => { - it('init the mute state with client data', async () => { - const chatClientWithUser = await getTestClientWithUser({ id: 'user_x' }); - // First load, mutes are initialized empty - chatClientWithUser.user.mutes = []; - let context; - const { rerender } = render( - - { - context = ctx; - }} - /> - , - ); - // Chat client loads mutes information - const mutes = ['user_y', 'user_z']; - chatClientWithUser.user.mutes = mutes; - await act(() => { - rerender( - - { - context = ctx; - }} - /> - , - ); - }); - await waitFor(() => expect(context.mutes).toStrictEqual(mutes)); - }); - - it('chat client listens and updates the state on mute event', async () => { - const chatClientWithUser = await getTestClientWithUser({ id: 'user_x' }); - - let context; - render( - - { - context = ctx; - }} - /> - , - ); - await waitFor(() => expect(context.mutes).toStrictEqual([])); - - const mutes = [{ target: { id: 'user_y' }, user: { id: 'user_y' } }]; - act(() => dispatchNotificationMutesUpdated(chatClientWithUser, mutes)); - await waitFor(() => expect(context.mutes).toStrictEqual(mutes)); - - act(() => dispatchNotificationMutesUpdated(chatClientWithUser, null)); - await waitFor(() => expect(context.mutes).toStrictEqual([])); - }); - }); - - describe('active channel', () => { - it('setActiveChannel query if there is a watcher', async () => { - let context; - render( - - { - context = ctx; - }} - /> - , - ); - - const channel = { cid: 'cid', query: jest.fn() }; - const watchers = { user_y: {} }; - await waitFor(() => expect(context.channel).toBeUndefined()); - await act(() => context.setActiveChannel(channel, watchers)); - await waitFor(() => { - expect(context.channel).toStrictEqual(channel); - expect(channel.query).toHaveBeenCalledTimes(1); - expect(channel.query).toHaveBeenCalledWith({ watch: true, watchers }); - }); - }); - - it('setActiveChannel prevent event default', async () => { - let context; - render( - - { - context = ctx; - }} - /> - , - ); - - await waitFor(() => expect(context.setActiveChannel).not.toBeUndefined()); - - const e = { preventDefault: jest.fn() }; - await act(() => context.setActiveChannel(undefined, {}, e)); - await waitFor(() => expect(e.preventDefault).toHaveBeenCalledTimes(1)); - }); }); describe('translation context', () => { diff --git a/src/components/Chat/hooks/useChat.ts b/src/components/Chat/hooks/useChat.ts index 92497101f9..acef1b4977 100644 --- a/src/components/Chat/hooks/useChat.ts +++ b/src/components/Chat/hooks/useChat.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { TranslationContextValue } from '../../../context/TranslationContext'; import type { SupportedTranslations } from '../../../i18n'; @@ -9,14 +9,7 @@ import { Streami18n, } from '../../../i18n'; -import type { - AppSettingsAPIResponse, - Channel, - Event, - Mute, - OwnUserResponse, - StreamChat, -} from 'stream-chat'; +import type { AppSettingsAPIResponse, StreamChat } from 'stream-chat'; export type UseChatParams = { client: StreamChat; @@ -37,14 +30,9 @@ export const useChat = ({ userLanguage: 'en', }); - const [channel, setChannel] = useState(); - const [mutes, setMutes] = useState>([]); const [navOpen, setNavOpen] = useState(initialNavOpen); const [latestMessageDatesByChannels, setLatestMessageDatesByChannels] = useState({}); - const clientMutes = (client.user as OwnUserResponse)?.mutes ?? []; - - const closeMobileNav = () => setNavOpen(false); const openMobileNav = () => setTimeout(() => setNavOpen(true), 100); const appSettings = useRef | null>(null); @@ -82,18 +70,6 @@ export const useChat = ({ }; }, [client]); - useEffect(() => { - setMutes(clientMutes); - - const handleEvent = (event: Event) => { - setMutes(event.me?.mutes || []); - }; - - client.on('notification.mutes_updated', handleEvent); - return () => client.off('notification.mutes_updated', handleEvent); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [clientMutes?.length]); - useEffect(() => { let userLanguage = client.user?.language; @@ -119,37 +95,15 @@ export const useChat = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [i18nInstance]); - const setActiveChannel = useCallback( - async ( - activeChannel?: Channel, - watchers: { limit?: number; offset?: number } = {}, - event?: React.BaseSyntheticEvent, - ) => { - if (event && event.preventDefault) event.preventDefault(); - - if (activeChannel && Object.keys(watchers).length) { - await activeChannel.query({ watch: true, watchers }); - } - - setChannel(activeChannel); - closeMobileNav(); - }, - [], - ); - useEffect(() => { setLatestMessageDatesByChannels({}); }, [client.user?.id]); return { - channel, - closeMobileNav, getAppSettings, latestMessageDatesByChannels, - mutes, navOpen, openMobileNav, - setActiveChannel, translators, }; }; diff --git a/src/components/Chat/hooks/useCreateChatContext.ts b/src/components/Chat/hooks/useCreateChatContext.ts index 5cf96e11fb..7077e83c28 100644 --- a/src/components/Chat/hooks/useCreateChatContext.ts +++ b/src/components/Chat/hooks/useCreateChatContext.ts @@ -4,59 +4,47 @@ import type { ChatContextValue } from '../../../context/ChatContext'; export const useCreateChatContext = (value: ChatContextValue) => { const { - channel, channelsQueryState, client, - closeMobileNav, customClasses, getAppSettings, isMessageAIGenerated, latestMessageDatesByChannels, - mutes, navOpen, openMobileNav, searchController, - setActiveChannel, theme, useImageFlagEmojisOnWindows, } = value; - const channelCid = channel?.cid; const channelsQueryError = channelsQueryState.error; const channelsQueryInProgress = channelsQueryState.queryInProgress; const clientValues = `${client.clientID}${Object.keys(client.activeChannels).length}${ Object.keys(client.listeners).length }${client.mutedChannels.length} ${client.user?.id}`; - const mutedUsersLength = mutes.length; const chatContext: ChatContextValue = useMemo( () => ({ - channel, channelsQueryState, client, - closeMobileNav, customClasses, getAppSettings, isMessageAIGenerated, latestMessageDatesByChannels, - mutes, navOpen, openMobileNav, searchController, - setActiveChannel, theme, useImageFlagEmojisOnWindows, }), // eslint-disable-next-line react-hooks/exhaustive-deps [ - channelCid, channelsQueryError, channelsQueryInProgress, clientValues, getAppSettings, searchController, - mutedUsersLength, navOpen, isMessageAIGenerated, ], diff --git a/src/components/ChatView/ChatView.tsx b/src/components/ChatView/ChatView.tsx index 7e46393cc9..4993edc995 100644 --- a/src/components/ChatView/ChatView.tsx +++ b/src/components/ChatView/ChatView.tsx @@ -2,6 +2,7 @@ import clsx from 'clsx'; import React, { type ComponentType, createContext, + useCallback, useContext, useEffect, useMemo, @@ -9,47 +10,565 @@ import React, { } from 'react'; import { Button, type ButtonProps } from '../Button'; +import { ChannelList } from '../ChannelList'; import { ThreadProvider } from '../Threads'; +import { ThreadList } from '../Threads/ThreadList'; import { Icon } from '../Threads/icons'; import { UnreadCountBadge } from '../Threads/UnreadCountBadge'; import { useChatContext, useTranslationContext } from '../../context'; import { useStateStore } from '../../store'; - -import type { PropsWithChildren } from 'react'; -import type { Thread, ThreadManagerState } from 'stream-chat'; +import { ChatViewNavigationProvider } from './ChatViewNavigationContext'; +import { WorkspaceLayout } from './layout/WorkspaceLayout'; +import { LayoutController as LayoutControllerClass } from './layoutController/LayoutController'; +import { resolveTargetSlotChannelDefault } from './layoutSlotResolvers'; + +import type { PropsWithChildren, ReactNode } from 'react'; +import type { Channel as StreamChannel, Thread, ThreadManagerState } from 'stream-chat'; +import type { + ChatViewLayoutState, + DuplicateEntityPolicy, + LayoutController, + LayoutSlotBinding, + ResolveDuplicateEntity, + ResolveTargetSlot, + SlotName, +} from './layoutController/layoutControllerTypes'; +import { getLayoutViewState } from './hooks'; export type ChatView = 'channels' | 'threads'; +export type UserListEntitySource = { + query: string; +}; + +export type ChannelListEntitySource = { + view?: ChatView; +}; +export type ThreadListEntitySource = { + view?: ChatView; +}; + +export type SearchResultsEntitySource = { + query: string; +}; + +export type ChatViewEntityBinding = + | { key?: string; kind: 'channelList'; source: ChannelListEntitySource } + | { key?: string; kind: 'threadList'; source: ThreadListEntitySource } + | { key?: string; kind: 'channel'; source: StreamChannel } + | { key?: string; kind: 'thread'; source: Thread } + | { key?: string; kind: 'memberList'; source: StreamChannel } + | { key?: string; kind: 'userList'; source: UserListEntitySource } + | { key?: string; kind: 'searchResults'; source: SearchResultsEntitySource } + | { key?: string; kind: 'pinnedMessagesList'; source: StreamChannel }; + +export type ChatViewEntityInferer = { + kind: ChatViewEntityBinding['kind']; + match: (source: unknown) => boolean; + toBinding: (source: unknown) => ChatViewEntityBinding; +}; + +export type ChatViewBuiltinLayout = 'nav-rail-entity-list-workspace'; + +type LayoutEntityByKind = Extract< + ChatViewEntityBinding, + { kind: TKind } +>; + +export type ChatViewSlotRendererProps = { + entity: LayoutEntityByKind; + slot: string; + source: LayoutEntityByKind['source']; +}; + +export type ChatViewSlotFallbackProps = { + slot: string; +}; + +export type ChatViewSlotRenderers = Partial<{ + [TKind in ChatViewEntityBinding['kind']]: ( + props: ChatViewSlotRendererProps, + ) => ReactNode; +}>; + +export type ChatViewProps = PropsWithChildren<{ + duplicateEntityPolicy?: DuplicateEntityPolicy; + entityInferers?: ChatViewEntityInferer[]; + layout?: ChatViewBuiltinLayout; + layoutController?: LayoutController; + maxSlots?: number; + minSlots?: number; + resolveDuplicateEntity?: ResolveDuplicateEntity; + resolveTargetSlot?: ResolveTargetSlot; + SlotFallback?: ComponentType; + slotNames?: string[]; + slotFallbackComponents?: Partial< + Record> + >; + slotRenderers?: ChatViewSlotRenderers; +}>; + +export type ChatViewNavigationAction = 'openChannel' | 'openThread'; + +export type ResolveViewActionTargetSlotArgs = { + action: ChatViewNavigationAction; + activeView: ChatView; + availableSlots: SlotName[]; + requestedSlot?: SlotName; + slotBindings: Record; + slotNames?: SlotName[]; +}; + +export type ResolveViewActionTargetSlot = ( + args: ResolveViewActionTargetSlotArgs, +) => SlotName | undefined; + +export type ViewActionSlotResolvers = Partial< + Record +>; + type ChatViewContextValue = { activeChatView: ChatView; - setActiveChatView: (cv: ChatView) => void; + activeView: ChatView; + entityInferers: ChatViewEntityInferer[]; + layoutController: LayoutController; + registerViewActionSlotResolvers: ( + view: ChatView, + resolvers?: ViewActionSlotResolvers, + ) => void; + resolveActionTargetSlot: ( + view: ChatView, + args: ResolveViewActionTargetSlotArgs, + ) => SlotName | undefined; + setActiveView: (cv: ChatView) => void; +}; + +const DEFAULT_MAX_SLOTS = 1; +const DEFAULT_MIN_SLOTS = 1; + +const createGeneratedSlotNames = (slotCount: number) => + Array.from({ length: Math.max(0, slotCount) }, (_, index) => `slot${index + 1}`); + +const resolveSlotTopology = ({ + maxSlots, + minSlots, + slotNames, +}: { + maxSlots?: number; + minSlots?: number; + slotNames?: string[]; +}) => { + const explicitSlotNames = slotNames?.filter(Boolean) ?? []; + const hasExplicitSlotNames = explicitSlotNames.length > 0; + const resolvedMaxSlots = hasExplicitSlotNames + ? Math.min( + Math.max(1, maxSlots ?? explicitSlotNames.length), + explicitSlotNames.length, + ) + : Math.max(1, maxSlots ?? DEFAULT_MAX_SLOTS); + const resolvedMinSlots = Math.min( + Math.max(1, minSlots ?? DEFAULT_MIN_SLOTS), + resolvedMaxSlots, + ); + const resolvedSlotNames = hasExplicitSlotNames + ? explicitSlotNames.slice(0, resolvedMaxSlots) + : createGeneratedSlotNames(resolvedMaxSlots); + + return { + initialAvailableSlots: resolvedSlotNames.slice(0, resolvedMinSlots), + resolvedMaxSlots, + resolvedMinSlots, + resolvedSlotNames, + }; }; +const defaultLayoutController = new LayoutControllerClass({ + initialState: { + activeView: 'channels', + availableSlotsByView: { + channels: createGeneratedSlotNames(DEFAULT_MAX_SLOTS), + }, + }, +}); + const ChatViewContext = createContext({ activeChatView: 'channels', - setActiveChatView: () => undefined, + activeView: 'channels', + entityInferers: [], + layoutController: defaultLayoutController, + registerViewActionSlotResolvers: () => undefined, + resolveActionTargetSlot: () => undefined, + setActiveView: () => undefined, }); export const useChatViewContext = () => useContext(ChatViewContext); -export const ChatView = ({ children }: PropsWithChildren) => { - const [activeChatView, setActiveChatView] = useState('channels'); +const activeViewSelector = ({ activeView }: ChatViewLayoutState) => ({ activeView }); +const workspaceLayoutStateSelector = (state: ChatViewLayoutState) => ({ + activeView: state.activeView, + viewState: getLayoutViewState(state), +}); + +const isChatViewEntityBinding = (value: unknown): value is ChatViewEntityBinding => { + if (!value || typeof value !== 'object') return false; + const candidate = value as Partial; + return typeof candidate.kind === 'string' && 'source' in candidate; +}; + +export const getChatViewEntityBinding = ( + binding?: LayoutSlotBinding, +): ChatViewEntityBinding | undefined => + isChatViewEntityBinding(binding?.payload) ? binding.payload : undefined; + +export const createChatViewSlotBinding = ( + entity: ChatViewEntityBinding, +): LayoutSlotBinding => ({ + key: entity.key ? `${entity.kind}:${entity.key}` : undefined, + payload: entity, +}); + +const renderSlotBinding = ( + entity: ChatViewEntityBinding | undefined, + slot: string, + slotRenderers: ChatViewSlotRenderers | undefined, +): ReactNode | null => { + if (!entity) return null; + + switch (entity.kind) { + case 'channelList': + return ( + slotRenderers?.channelList?.({ entity, slot, source: entity.source }) ?? + (entity.source.view === 'threads' ? ( + + ) : ( + + )) + ); + case 'threadList': + return ( + slotRenderers?.threadList?.({ entity, slot, source: entity.source }) ?? ( + + ) + ); + case 'channel': + return slotRenderers?.channel?.({ entity, slot, source: entity.source }) ?? null; + case 'thread': + return slotRenderers?.thread?.({ entity, slot, source: entity.source }) ?? null; + case 'memberList': + return slotRenderers?.memberList?.({ entity, slot, source: entity.source }) ?? null; + case 'userList': + return slotRenderers?.userList?.({ entity, slot, source: entity.source }) ?? null; + case 'searchResults': + return ( + slotRenderers?.searchResults?.({ entity, slot, source: entity.source }) ?? null + ); + case 'pinnedMessagesList': + return ( + slotRenderers?.pinnedMessagesList?.({ entity, slot, source: entity.source }) ?? + null + ); + default: + return null; + } +}; + +const DefaultSlotFallback = () => ( +
    + Select a channel to start messaging +
    +); +const resolveSlotFallbackComponent = ({ + slot, + SlotFallback, + slotFallbackComponents, +}: { + SlotFallback?: ComponentType; + slot: string; + slotFallbackComponents?: Partial< + Record> + >; +}) => slotFallbackComponents?.[slot] ?? SlotFallback ?? DefaultSlotFallback; + +const LIST_BINDING_KEY = 'list'; +const LIST_ENTITY_KIND: ChatViewEntityBinding['kind'] = 'channelList'; + +const registerListSlotHint = ( + layoutController: LayoutController, + view: ChatView, + slot: SlotName, +) => { + layoutController.state.next((current) => { + if (current.listSlotByView?.[view] === slot) return current; + + return { + ...current, + listSlotByView: { + ...(current.listSlotByView ?? {}), + [view]: slot, + }, + }; + }); +}; + +export const ChatView = ({ + children, + duplicateEntityPolicy, + entityInferers = [], + layout, + layoutController, + maxSlots, + minSlots, + resolveDuplicateEntity, + resolveTargetSlot, + SlotFallback, + slotFallbackComponents, + slotNames, + slotRenderers, +}: ChatViewProps) => { const { theme } = useChatContext(); + const [viewActionSlotResolvers, setViewActionSlotResolvers] = useState< + Partial> + >({}); + const { initialAvailableSlots, resolvedMaxSlots, resolvedMinSlots, resolvedSlotNames } = + useMemo( + () => + resolveSlotTopology({ + maxSlots, + minSlots, + slotNames, + }), + [maxSlots, minSlots, slotNames], + ); + + const internalLayoutController = useMemo( + () => + new LayoutControllerClass({ + duplicateEntityPolicy, + initialState: { + activeView: 'channels', + availableSlotsByView: { + channels: initialAvailableSlots, + }, + maxSlots: resolvedMaxSlots, + minSlots: resolvedMinSlots, + slotNamesByView: { + channels: resolvedSlotNames, + }, + }, + resolveDuplicateEntity, + resolveTargetSlot: resolveTargetSlot ?? resolveTargetSlotChannelDefault, + }), + [ + duplicateEntityPolicy, + initialAvailableSlots, + resolvedMaxSlots, + resolvedMinSlots, + resolvedSlotNames, + resolveDuplicateEntity, + resolveTargetSlot, + ], + ); - const value = useMemo(() => ({ activeChatView, setActiveChatView }), [activeChatView]); + const effectiveLayoutController = layoutController ?? internalLayoutController; + + const { activeView } = + useStateStore(effectiveLayoutController.state, activeViewSelector) ?? + activeViewSelector(effectiveLayoutController.state.getLatestValue()); + + const setActiveView = useCallback( + (cv: ChatView) => { + const currentState = effectiveLayoutController.state.getLatestValue(); + if (currentState.activeView !== cv) { + const currentViewState = getLayoutViewState(currentState); + currentViewState.availableSlots.forEach((slot) => { + const entity = getChatViewEntityBinding(currentViewState.slotBindings[slot]); + if (entity?.kind === 'channel' || entity?.kind === 'thread') { + effectiveLayoutController.clear(slot); + } + }); + } + effectiveLayoutController.setActiveView(cv); + }, + [effectiveLayoutController], + ); + + const registerViewActionSlotResolvers = useCallback( + (view: ChatView, resolvers?: ViewActionSlotResolvers) => { + setViewActionSlotResolvers((current) => { + const previous = current[view]; + if (previous === resolvers) return current; + + const next = { ...current }; + if (!resolvers) delete next[view]; + else next[view] = resolvers; + return next; + }); + }, + [], + ); + + const resolveActionTargetSlot = useCallback( + (view: ChatView, args: ResolveViewActionTargetSlotArgs) => + viewActionSlotResolvers[view]?.[args.action]?.(args), + [viewActionSlotResolvers], + ); + + const value = useMemo( + () => ({ + activeChatView: activeView, + activeView, + entityInferers, + layoutController: effectiveLayoutController, + registerViewActionSlotResolvers, + resolveActionTargetSlot, + setActiveView, + }), + [ + activeView, + effectiveLayoutController, + entityInferers, + registerViewActionSlotResolvers, + resolveActionTargetSlot, + setActiveView, + ], + ); + + const workspaceLayoutState = + useStateStore(effectiveLayoutController.state, workspaceLayoutStateSelector) ?? + workspaceLayoutStateSelector(effectiveLayoutController.state.getLatestValue()); + const { activeView: workspaceActiveView, viewState } = workspaceLayoutState; + + useEffect(() => { + if (layout !== 'nav-rail-entity-list-workspace') return; + + const existingListSlot = viewState.availableSlots.find( + (slot) => + getChatViewEntityBinding(viewState.slotBindings[slot])?.kind === LIST_ENTITY_KIND, + ); + + if (existingListSlot) { + registerListSlotHint( + effectiveLayoutController, + workspaceActiveView, + existingListSlot, + ); + const existingEntity = getChatViewEntityBinding( + viewState.slotBindings[existingListSlot], + ); + if ( + existingEntity?.kind === LIST_ENTITY_KIND && + existingEntity.source.view !== workspaceActiveView + ) { + effectiveLayoutController.setSlotBinding( + existingListSlot, + createChatViewSlotBinding({ + ...existingEntity, + source: { view: workspaceActiveView }, + }), + ); + } + return; + } + + const firstFreeSlot = viewState.availableSlots.find( + (slot) => !viewState.slotBindings[slot], + ); + if (!firstFreeSlot) return; + + registerListSlotHint(effectiveLayoutController, workspaceActiveView, firstFreeSlot); + effectiveLayoutController.setSlotBinding( + firstFreeSlot, + createChatViewSlotBinding({ + key: LIST_BINDING_KEY, + kind: LIST_ENTITY_KIND, + source: { view: workspaceActiveView }, + }), + ); + }, [ + effectiveLayoutController, + layout, + workspaceActiveView, + viewState.slotBindings, + viewState.availableSlots, + ]); + + const content = + layout === 'nav-rail-entity-list-workspace' + ? (() => { + const slots = viewState.availableSlots.map((slot) => { + const content = renderSlotBinding( + getChatViewEntityBinding(viewState.slotBindings[slot]), + slot, + slotRenderers, + ); + const Fallback = resolveSlotFallbackComponent({ + slot, + SlotFallback, + slotFallbackComponents, + }); + + return { + content: content ?? , + slot, + }; + }); + + return } slots={slots} />; + })() + : children; return ( -
    {children}
    + +
    {content}
    +
    ); }; -const ChannelsView = ({ children }: PropsWithChildren) => { - const { activeChatView } = useChatViewContext(); +export type ChatViewChannelsProps = PropsWithChildren<{ + slots?: SlotName[]; + targetSlotResolvers?: ViewActionSlotResolvers; +}>; - if (activeChatView !== 'channels') return null; +const normalizeViewSlots = (slots?: SlotName[]) => + (slots ?? []).filter((slot): slot is SlotName => Boolean(slot)); + +const areSlotArraysEqual = (first: SlotName[], second: SlotName[]) => { + if (first.length !== second.length) return false; + return first.every((slot, index) => second[index] === slot); +}; + +// todo: move channel list orchestrator here +const ChannelsView = ({ + children, + slots, + targetSlotResolvers, +}: ChatViewChannelsProps) => { + const { activeView, layoutController, registerViewActionSlotResolvers } = + useChatViewContext(); + const isChannelsViewActive = activeView === 'channels'; + const orderedSlots = useMemo(() => normalizeViewSlots(slots), [slots]); + + useEffect(() => { + registerViewActionSlotResolvers('channels', targetSlotResolvers); + + return () => { + registerViewActionSlotResolvers('channels', undefined); + }; + }, [registerViewActionSlotResolvers, targetSlotResolvers]); + + useEffect(() => { + if (!isChannelsViewActive || orderedSlots.length === 0) return; + const current = layoutController.state.getLatestValue(); + if (areSlotArraysEqual(getLayoutViewState(current).availableSlots, orderedSlots)) + return; + layoutController.setSlotNames(orderedSlots); + layoutController.setAvailableSlots(orderedSlots); + }, [isChannelsViewActive, layoutController, orderedSlots]); + + if (!isChannelsViewActive) return null; return
    {children}
    ; }; @@ -66,14 +585,39 @@ const ThreadsViewContext = createContext({ export const useThreadsViewContext = () => useContext(ThreadsViewContext); -const ThreadsView = ({ children }: PropsWithChildren) => { - const { activeChatView } = useChatViewContext(); +export type ChatViewThreadsProps = PropsWithChildren<{ + slots?: SlotName[]; + targetSlotResolvers?: ViewActionSlotResolvers; +}>; + +const ThreadsView = ({ children, slots, targetSlotResolvers }: ChatViewThreadsProps) => { + const { activeView, layoutController, registerViewActionSlotResolvers } = + useChatViewContext(); + const isThreadsViewActive = activeView === 'threads'; const [activeThread, setActiveThread] = useState(undefined); + const orderedSlots = useMemo(() => normalizeViewSlots(slots), [slots]); const value = useMemo(() => ({ activeThread, setActiveThread }), [activeThread]); - if (activeChatView !== 'threads') return null; + useEffect(() => { + registerViewActionSlotResolvers('threads', targetSlotResolvers); + + return () => { + registerViewActionSlotResolvers('threads', undefined); + }; + }, [registerViewActionSlotResolvers, targetSlotResolvers]); + + useEffect(() => { + if (!isThreadsViewActive || orderedSlots.length === 0) return; + const current = layoutController.state.getLatestValue(); + if (areSlotArraysEqual(getLayoutViewState(current).availableSlots, orderedSlots)) + return; + layoutController.setSlotNames(orderedSlots); + layoutController.setAvailableSlots(orderedSlots); + }, [isThreadsViewActive, layoutController, orderedSlots]); + + if (!isThreadsViewActive) return null; return ( @@ -164,14 +708,14 @@ const selector = ({ unreadThreadCount }: ThreadManagerState) => ({ }); export const ChatViewChannelsSelectorButton = () => { - const { activeChatView, setActiveChatView } = useChatViewContext(); + const { activeView, setActiveView } = useChatViewContext(); const { t } = useTranslationContext(); return ( setActiveChatView('channels')} + onPointerDown={() => setActiveView('channels')} text={t('Channels')} /> ); @@ -182,13 +726,13 @@ export const ChatViewThreadsSelectorButton = () => { const { unreadThreadCount } = useStateStore(client.threads.state, selector) ?? { unreadThreadCount: 0, }; - const { activeChatView, setActiveChatView } = useChatViewContext(); + const { activeView, setActiveView } = useChatViewContext(); const { t } = useTranslationContext(); return ( setActiveChatView('threads')} + aria-selected={activeView === 'threads'} + onPointerDown={() => setActiveView('threads')} > diff --git a/src/components/ChatView/ChatViewNavigationContext.tsx b/src/components/ChatView/ChatViewNavigationContext.tsx new file mode 100644 index 0000000000..8c9710eed1 --- /dev/null +++ b/src/components/ChatView/ChatViewNavigationContext.tsx @@ -0,0 +1,408 @@ +import React, { createContext, useContext, useMemo } from 'react'; + +import { useChatContext } from '../../context'; +import { useStateStore } from '../../store'; +import { + createChatViewSlotBinding, + getChatViewEntityBinding, + useChatViewContext, +} from './ChatView'; + +import type { PropsWithChildren } from 'react'; +import type { + LocalMessage, + Channel as StreamChannel, + Thread as StreamThread, +} from 'stream-chat'; +import { Thread as StreamThreadClass } from 'stream-chat'; +import type { + ChatView, + ChatViewEntityBinding, + ResolveViewActionTargetSlotArgs, +} from './ChatView'; +import { getLayoutViewState, useLayoutViewState } from './hooks/useLayoutViewState'; +import type { + ChatViewLayoutState, + ChatViewLayoutViewState, + LayoutSlotBinding, + OpenResult, + SlotName, +} from './layoutController/layoutControllerTypes'; + +const LIST_BINDING_KEYS: Record = { + channels: 'list', + threads: 'thread-list', +}; +const LIST_ENTITY_KIND_BY_VIEW = { + channels: 'channelList', + threads: 'threadList', +} as const satisfies Record; + +type ViewSlotRuntime = { + activeViewState: ChatViewLayoutViewState; + availableSlots: SlotName[]; + listEntityKind: (typeof LIST_ENTITY_KIND_BY_VIEW)[ChatView]; + listSlotHint?: SlotName; + orderedSlots: SlotName[]; + slotBindings: Record; +}; + +const resolveGeneratedSlots = (slotCount: number): SlotName[] => + Array.from( + { length: Math.max(0, slotCount) }, + (_, index) => `slot${index + 1}` as SlotName, + ); + +const resolveDefaultTargetSlot = ({ + action, + requestedSlot, + runtime, + view, +}: { + action: 'openChannel' | 'openThread'; + runtime: ViewSlotRuntime; + requestedSlot?: SlotName; + view: ChatView; +}): SlotName | undefined => { + if (requestedSlot && runtime.availableSlots.includes(requestedSlot)) { + return requestedSlot; + } + + const readSlotKind = (slot: SlotName) => + getChatViewEntityBinding(runtime.activeViewState.slotBindings[slot])?.kind; + const isListSlot = (slot: SlotName) => + slot === runtime.listSlotHint || readSlotKind(slot) === runtime.listEntityKind; + const findNamed = (...names: SlotName[]) => + names.find((name) => runtime.orderedSlots.includes(name)); + const firstFree = runtime.availableSlots.find((slot) => !runtime.slotBindings[slot]); + const firstFreeNonList = runtime.availableSlots.find( + (slot) => !runtime.slotBindings[slot] && !isListSlot(slot), + ); + const firstNonList = runtime.availableSlots.find((slot) => !isListSlot(slot)); + const firstThread = runtime.availableSlots.find( + (slot) => readSlotKind(slot) === 'thread', + ); + const firstChannel = runtime.availableSlots.find( + (slot) => readSlotKind(slot) === 'channel', + ); + + if (action === 'openThread') { + return ( + firstThread ?? + (view === 'channels' ? findNamed('thread') : findNamed('main-thread', 'thread')) ?? + firstFreeNonList ?? + firstFree ?? + firstChannel ?? + firstNonList ?? + runtime.availableSlots[runtime.availableSlots.length - 1] + ); + } + + return ( + firstChannel ?? + findNamed('main', 'channel') ?? + firstFreeNonList ?? + firstFree ?? + firstNonList ?? + runtime.availableSlots[runtime.availableSlots.length - 1] + ); +}; + +export type OpenThreadTarget = + | StreamThread + | { channel: StreamChannel; message: LocalMessage }; + +export type ChatViewNavigation = { + closeChannel: (options?: { slot?: SlotName }) => void; + closeThread: (options?: { slot?: SlotName }) => void; + hideChannelList: (options?: { slot?: SlotName }) => void; + openChannel: (channel: StreamChannel, options?: { slot?: SlotName }) => OpenResult; + openThread: ( + threadOrTarget: OpenThreadTarget, + options?: { slot?: SlotName }, + ) => OpenResult; + openView: (view: ChatView, options?: { slot?: SlotName }) => void; + unhideChannelList: (options?: { slot?: SlotName }) => void; +}; + +const chatViewNavigationStateSelector = (state: ChatViewLayoutState) => ({ + activeView: state.activeView, + listSlotByView: state.listSlotByView, +}); + +const ChatViewNavigationContext = createContext({ + closeChannel: () => undefined, + closeThread: () => undefined, + hideChannelList: () => undefined, + openChannel: () => ({ reason: 'no-available-slot', status: 'rejected' }), + openThread: () => ({ reason: 'no-available-slot', status: 'rejected' }), + openView: () => undefined, + unhideChannelList: () => undefined, +}); + +export const useChatViewNavigation = () => useContext(ChatViewNavigationContext); + +export const ChatViewNavigationProvider = ({ children }: PropsWithChildren) => { + const { layoutController, resolveActionTargetSlot } = useChatViewContext(); + const { client } = useChatContext(); + const { availableSlots, slotBindings } = useLayoutViewState(); + const { activeView, listSlotByView } = + useStateStore(layoutController.state, chatViewNavigationStateSelector) ?? + chatViewNavigationStateSelector(layoutController.state.getLatestValue()); + + const value = useMemo(() => { + const listBindingKey = LIST_BINDING_KEYS[activeView]; + const listEntityKind = LIST_ENTITY_KIND_BY_VIEW[activeView]; + + const findCandidateSlotsByKind = (kind: ChatViewEntityBinding['kind']) => + availableSlots.filter( + (slot) => getChatViewEntityBinding(slotBindings[slot])?.kind === kind, + ); + + const resolveListSlotHint = () => listSlotByView?.[activeView]; + const resolveDeterministicSlot = (candidates: SlotName[]) => + candidates.length === 1 ? candidates[0] : undefined; + const resolveSlot = ({ + fallbackSlots = [], + kind, + slot, + }: { + fallbackSlots?: SlotName[]; + kind?: ChatViewEntityBinding['kind']; + slot?: SlotName; + }) => { + if (slot) return availableSlots.includes(slot) ? slot : undefined; + + if (kind) { + const kindSlot = resolveDeterministicSlot(findCandidateSlotsByKind(kind)); + if (kindSlot) return kindSlot; + } + + return resolveDeterministicSlot(fallbackSlots); + }; + + const buildRuntimeForView = (view: ChatView): ViewSlotRuntime => { + const state = layoutController.state.getLatestValue(); + const viewState = getLayoutViewState(state, view); + const inferredMaxSlots = Math.max( + state.maxSlots ?? viewState.availableSlots.length, + viewState.availableSlots.length, + ); + + return { + activeViewState: viewState, + availableSlots: viewState.availableSlots, + listEntityKind: LIST_ENTITY_KIND_BY_VIEW[view], + listSlotHint: state.listSlotByView?.[view], + orderedSlots: viewState.slotNames?.length + ? viewState.slotNames + : resolveGeneratedSlots(inferredMaxSlots), + slotBindings: viewState.slotBindings, + }; + }; + const materializeTargetSlot = (runtime: ViewSlotRuntime, slot?: SlotName) => { + if (!slot) return undefined; + if (runtime.availableSlots.includes(slot)) return slot; + if (!runtime.orderedSlots.includes(slot)) return undefined; + + const nextAvailableSlots = runtime.orderedSlots.filter( + (candidate) => runtime.availableSlots.includes(candidate) || candidate === slot, + ); + layoutController.setAvailableSlots(nextAvailableSlots); + return slot; + }; + const resolveActionSlot = ({ + action, + requestedSlot, + runtime, + view, + }: { + action: 'openChannel' | 'openThread'; + requestedSlot?: SlotName; + runtime: ViewSlotRuntime; + view: ChatView; + }) => { + const args: ResolveViewActionTargetSlotArgs = { + action, + activeView: view, + availableSlots: runtime.availableSlots, + requestedSlot, + slotBindings: runtime.slotBindings, + slotNames: runtime.orderedSlots, + }; + + const customTargetSlot = resolveActionTargetSlot(view, args); + if (customTargetSlot) { + const materializedCustomSlot = materializeTargetSlot(runtime, customTargetSlot); + if (materializedCustomSlot) return materializedCustomSlot; + } + + return materializeTargetSlot( + runtime, + resolveDefaultTargetSlot({ + action, + requestedSlot, + runtime, + view, + }), + ); + }; + + const openView: ChatViewNavigation['openView'] = (view, options) => { + layoutController.openView(view, { slot: options?.slot }); + }; + + const clearThreadSlots = ({ slot }: { slot?: SlotName } = {}) => { + const explicitSlot = slot && availableSlots.includes(slot) ? slot : undefined; + const threadSlots = findCandidateSlotsByKind('thread'); + + if (explicitSlot) { + layoutController.clear(explicitSlot); + return; + } + + if (threadSlots.length === 1) { + layoutController.clear(threadSlots[0]); + return; + } + + threadSlots.forEach((threadSlot) => layoutController.clear(threadSlot)); + }; + + const closeThread: ChatViewNavigation['closeThread'] = (options) => { + clearThreadSlots({ slot: options?.slot }); + }; + + const openChannel: ChatViewNavigation['openChannel'] = (channel, options) => { + closeThread(); + openView('channels', options); + const runtime = buildRuntimeForView('channels'); + const targetSlot = resolveActionSlot({ + action: 'openChannel', + requestedSlot: options?.slot, + runtime, + view: 'channels', + }); + + return layoutController.openInLayout( + createChatViewSlotBinding({ + key: channel.cid ?? undefined, + kind: 'channel', + source: channel, + }), + { + targetSlot, + }, + ); + }; + + const closeChannel: ChatViewNavigation['closeChannel'] = (options) => { + const targetSlot = resolveSlot({ kind: 'channel', slot: options?.slot }); + if (!targetSlot) return; + layoutController.goBack(targetSlot); + }; + + const openThreadInSlot = ( + thread: StreamThread, + options?: { slot?: SlotName }, + ): OpenResult => + layoutController.openInLayout( + createChatViewSlotBinding({ + key: thread.id ?? undefined, + kind: 'thread', + source: thread, + }), + { + targetSlot: options?.slot, + }, + ); + + const openThread: ChatViewNavigation['openThread'] = (threadOrTarget, options) => { + const runtime = buildRuntimeForView(activeView); + const targetSlot = resolveActionSlot({ + action: 'openThread', + requestedSlot: options?.slot, + runtime, + view: activeView, + }); + + if ('channel' in threadOrTarget && 'message' in threadOrTarget) { + const existingThread = client.threads.threadsById[threadOrTarget.message.id]; + const thread = + existingThread ?? + new StreamThreadClass({ + channel: threadOrTarget.channel, + client, + parentMessage: threadOrTarget.message, + }); + + return openThreadInSlot(thread, { slot: targetSlot }); + } + + return openThreadInSlot(threadOrTarget, { slot: targetSlot }); + }; + + const hideChannelList: ChatViewNavigation['hideChannelList'] = (options) => { + const targetSlot = + options?.slot ?? + resolveListSlotHint() ?? + resolveSlot({ + fallbackSlots: availableSlots, + kind: listEntityKind, + slot: options?.slot, + }); + if (targetSlot) { + layoutController.hide(targetSlot); + } + }; + + const unhideChannelList: ChatViewNavigation['unhideChannelList'] = (options) => { + const existingListSlot = resolveSlot({ kind: listEntityKind }); + const targetSlot = + options?.slot ?? + resolveListSlotHint() ?? + resolveSlot({ fallbackSlots: availableSlots, slot: options?.slot }) ?? + existingListSlot ?? + resolveDeterministicSlot(availableSlots); + + if (targetSlot) { + if (!existingListSlot) { + layoutController.setSlotBinding( + targetSlot, + createChatViewSlotBinding({ + key: listBindingKey, + kind: listEntityKind, + source: { view: activeView }, + }), + ); + } + + layoutController.unhide(targetSlot); + } + }; + + return { + closeChannel, + closeThread, + hideChannelList, + openChannel, + openThread, + openView, + unhideChannelList, + }; + }, [ + activeView, + availableSlots, + client, + listSlotByView, + layoutController, + resolveActionTargetSlot, + slotBindings, + ]); + + return ( + + {children} + + ); +}; diff --git a/src/components/ChatView/__tests__/ChatView.test.tsx b/src/components/ChatView/__tests__/ChatView.test.tsx new file mode 100644 index 0000000000..22f173373c --- /dev/null +++ b/src/components/ChatView/__tests__/ChatView.test.tsx @@ -0,0 +1,493 @@ +import React, { useEffect, useState } from 'react'; +import { StateStore } from 'stream-chat'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +jest.mock('../../ChannelList', () => ({ + ChannelList: () =>
    ChannelList
    , + ChannelListSlot: ({ children }: { children?: React.ReactNode }) => <>{children}, +})); + +jest.mock('../../Threads/ThreadList', () => ({ + ThreadList: () =>
    ThreadList
    , + ThreadListSlot: ({ children }: { children?: React.ReactNode }) => <>{children}, +})); + +import { + ChatView, + createChatViewSlotBinding, + getChatViewEntityBinding, + useChatViewContext, +} from '../ChatView'; + +import { ChatProvider } from '../../../context/ChatContext'; +import { TranslationProvider } from '../../../context/TranslationContext'; +import { ChannelListSlot } from '../../ChannelList'; +import { ThreadListSlot } from '../../Threads/ThreadList'; +import { LayoutController } from '../layoutController/LayoutController'; + +import type { Channel as StreamChannel, Thread as StreamThread } from 'stream-chat'; +import type { ChatContextValue } from '../../../context/ChatContext'; +import type { LayoutController } from '../layoutController/layoutControllerTypes'; + +const makeChannel = (cid: string) => ({ cid }) as unknown as StreamChannel; + +const createChatContextValue = (): ChatContextValue => + ({ + channelsQueryState: { + error: null, + queryInProgress: null, + setError: jest.fn(), + setQueryInProgress: jest.fn(), + }, + client: { + threads: { + state: new StateStore({ + unreadThreadCount: 0, + }), + }, + }, + getAppSettings: jest.fn(() => null), + latestMessageDatesByChannels: {}, + openMobileNav: jest.fn(), + searchController: {}, + theme: 'str-chat__theme-light', + useImageFlagEmojisOnWindows: false, + }) as unknown as ChatContextValue; + +const renderWithProviders = (ui: React.ReactNode) => + render( + + key, userLanguage: 'en' }}> + {ui} + + , + ); + +describe('ChatView', () => { + it('switches from threads to channels and opens channel in slot via layoutController', () => { + const channel = makeChannel('messaging:target'); + let capturedController: LayoutController | undefined; + + const ViewReplyInChannelAction = () => { + const { activeView, layoutController } = useChatViewContext(); + capturedController = layoutController; + + return ( + <> +
    {activeView}
    + + + + ); + }; + + renderWithProviders( + + + , + ); + + fireEvent.click(screen.getByText('enter-threads')); + expect(screen.getByTestId('active-view')).toHaveTextContent('threads'); + + fireEvent.click(screen.getByText('view-in-channel')); + expect(screen.getByTestId('active-view')).toHaveTextContent('channels'); + expect( + getChatViewEntityBinding( + capturedController?.state.getLatestValue().slotBindings.slot1, + )?.kind, + ).toBe('channel'); + }); + + it('clears channel/thread bindings in the view being left when switching view', () => { + const channel = makeChannel('messaging:leave-view'); + const threadBinding = createChatViewSlotBinding({ + key: 'thread-1', + kind: 'thread', + source: { id: 'thread-1' } as unknown as StreamThread, + }); + const layoutController = new LayoutController({ + initialState: { + activeView: 'channels', + availableSlots: ['slot1', 'slot2'], + slotBindings: { + slot1: createChatViewSlotBinding({ + key: channel.cid ?? undefined, + kind: 'channel', + source: channel, + }), + slot2: threadBinding, + }, + }, + }); + + const Harness = () => { + const { setActiveView } = useChatViewContext(); + return ( + + ); + }; + + renderWithProviders( + + + , + ); + + fireEvent.click(screen.getByText('switch-to-threads')); + + const state = layoutController.state.getLatestValue(); + expect(state.slotBindingsByView?.channels?.slot1).toBeUndefined(); + expect(state.slotBindingsByView?.channels?.slot2).toBeUndefined(); + }); + + it('renders built-in workspace layout with slotRenderers', () => { + const channel = makeChannel('messaging:workspace'); + const layoutController = new LayoutController({ + initialState: { + availableSlots: ['slot1', 'slot2'], + }, + }); + layoutController.openInLayout( + createChatViewSlotBinding({ + key: channel.cid ?? undefined, + kind: 'channel', + source: channel, + }), + ); + + renderWithProviders( +
    {source.cid}
    , + }} + />, + ); + + expect(screen.getByTestId('channel-list')).toBeInTheDocument(); + expect(screen.getByTestId('channel-slot')).toHaveTextContent('messaging:workspace'); + }); + + it('renders fallback workspace content when minSlots reserves an empty slot', () => { + renderWithProviders( + , + ); + + expect(screen.getByTestId('channel-list')).toBeInTheDocument(); + expect(screen.getByText('Select a channel to start messaging')).toBeInTheDocument(); + }); + + it('keeps channelList slot mounted when hidden/unhidden', () => { + const layoutController = new LayoutController({ + initialState: { + availableSlots: ['slot1', 'slot2'], + }, + }); + + const StatefulChannelList = () => { + const [count, setCount] = useState(0); + + return ( + + ); + }; + + const { container } = renderWithProviders( + , + }} + />, + ); + + fireEvent.click(screen.getByTestId('channel-list-counter')); + expect(screen.getByTestId('channel-list-counter')).toHaveTextContent('1'); + + act(() => { + layoutController.hide('slot1'); + }); + expect( + container.querySelector('[data-slot="slot1"].str-chat__chat-view__slot--hidden'), + ).toBeInTheDocument(); + + act(() => { + layoutController.unhide('slot1'); + }); + expect(screen.getByTestId('channel-list-counter')).toHaveTextContent('1'); + }); + + it('preserves custom children layout when built-in layout is not set', () => { + const Child = () => { + const { layoutController } = useChatViewContext(); + + useEffect(() => { + const channel = makeChannel('messaging:custom'); + layoutController.openInLayout( + createChatViewSlotBinding({ + key: channel.cid ?? undefined, + kind: 'channel', + source: channel, + }), + ); + }, [layoutController]); + + return
    custom-layout
    ; + }; + + renderWithProviders( + + + , + ); + + expect(screen.getByTestId('custom-layout')).toBeInTheDocument(); + }); + + it('binds list slot in ChannelListSlot and renders content in that slot', () => { + const layoutController = new LayoutController({ + initialState: { + availableSlots: ['slot1', 'slot2'], + }, + }); + + const { container } = renderWithProviders( + + + +
    list
    +
    +
    workspace
    +
    +
    , + ); + + expect( + getChatViewEntityBinding( + layoutController.state.getLatestValue().slotBindings.slot1, + ), + ).toMatchObject({ + kind: 'channelList', + source: { view: 'channels' }, + }); + expect( + container.querySelector('[data-slot="slot1"] [data-testid="channels-list-pane"]'), + ).toBeInTheDocument(); + expect(screen.getByTestId('channels-workspace')).toBeInTheDocument(); + }); + + it('initializes available slots from slotNames and minSlots', () => { + let capturedController: LayoutController | undefined; + + const Harness = () => { + const { layoutController } = useChatViewContext(); + capturedController = layoutController; + return null; + }; + + renderWithProviders( + + + , + ); + + expect(capturedController?.state.getLatestValue()).toMatchObject({ + availableSlots: ['list', 'main'], + maxSlots: 3, + minSlots: 2, + slotNames: ['list', 'main', 'thread'], + }); + }); + + it('uses ChatView.Channels slots order when channels view is active', () => { + let capturedController: LayoutController | undefined; + + const Harness = () => { + const { layoutController } = useChatViewContext(); + capturedController = layoutController; + return null; + }; + + renderWithProviders( + + + +
    + + , + ); + + expect(capturedController?.state.getLatestValue().availableSlots).toEqual([ + 'slot3', + 'slot1', + 'slot2', + ]); + }); + + it('switches availableSlots order according to active view slots props', () => { + let capturedController: LayoutController | undefined; + + const Harness = () => { + const { layoutController, setActiveView } = useChatViewContext(); + capturedController = layoutController; + + return ( + + ); + }; + + renderWithProviders( + + + +
    + + +
    + + , + ); + + expect(capturedController?.state.getLatestValue().availableSlots).toEqual([ + 'slot1', + 'slot2', + 'slot3', + ]); + + fireEvent.click(screen.getByText('show-threads')); + + expect(capturedController?.state.getLatestValue().availableSlots).toEqual([ + 'slot3', + 'slot2', + 'slot1', + ]); + }); + + it('claims requested slot when ChannelListSlot slot prop is provided', () => { + const layoutController = new LayoutController({ + initialState: { + availableSlots: ['slot1', 'slot2'], + }, + }); + + const { container } = renderWithProviders( + + + +
    list
    +
    +
    workspace
    +
    +
    , + ); + + expect( + getChatViewEntityBinding( + layoutController.state.getLatestValue().slotBindings.slot2, + ), + ).toMatchObject({ + kind: 'channelList', + source: { view: 'channels' }, + }); + expect( + container.querySelector('[data-slot="slot2"] [data-testid="channels-list-pane"]'), + ).toBeInTheDocument(); + }); + + it('falls back to first available slot when ChannelListSlot slot is not visible', () => { + const layoutController = new LayoutController({ + initialState: { + availableSlots: ['slot1'], + }, + }); + + const { container } = renderWithProviders( + + + +
    list
    +
    +
    workspace
    +
    +
    , + ); + + expect( + getChatViewEntityBinding( + layoutController.state.getLatestValue().slotBindings.slot1, + ), + ).toMatchObject({ + kind: 'channelList', + source: { view: 'channels' }, + }); + expect( + container.querySelector('[data-slot="slot1"] [data-testid="channels-list-pane"]'), + ).toBeInTheDocument(); + }); + + it('binds list slot in ThreadListSlot inside ChatView.Threads', () => { + const layoutController = new LayoutController({ + initialState: { + activeView: 'threads', + availableSlots: ['slot1', 'slot2'], + }, + }); + + const { container } = renderWithProviders( + + + +
    list
    +
    +
    workspace
    +
    +
    , + ); + + expect( + getChatViewEntityBinding( + layoutController.state.getLatestValue().slotBindings.slot1, + ), + ).toMatchObject({ + kind: 'threadList', + source: { view: 'threads' }, + }); + expect( + container.querySelector('[data-slot="slot1"] [data-testid="threads-list-pane"]'), + ).toBeInTheDocument(); + expect(screen.getByTestId('threads-workspace')).toBeInTheDocument(); + }); +}); diff --git a/src/components/ChatView/__tests__/ChatViewNavigation.test.tsx b/src/components/ChatView/__tests__/ChatViewNavigation.test.tsx new file mode 100644 index 0000000000..64767c41f7 --- /dev/null +++ b/src/components/ChatView/__tests__/ChatViewNavigation.test.tsx @@ -0,0 +1,321 @@ +import React from 'react'; +import { StateStore } from 'stream-chat'; +import { fireEvent, render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { ChatView, useChatViewContext } from '../ChatView'; +import { getChatViewEntityBinding } from '../ChatView'; +import { useChatViewNavigation } from '../ChatViewNavigationContext'; + +import { ChatProvider } from '../../../context/ChatContext'; +import { TranslationProvider } from '../../../context/TranslationContext'; + +import type { Channel as StreamChannel, Thread as StreamThread } from 'stream-chat'; +import type { ChatContextValue } from '../../../context/ChatContext'; +import type { LayoutController } from '../layoutController/layoutControllerTypes'; + +const makeChannel = (cid: string) => ({ cid }) as unknown as StreamChannel; +const makeThread = (id: string) => ({ id }) as unknown as StreamThread; + +const createChatContextValue = (): ChatContextValue => + ({ + channelsQueryState: { + error: null, + queryInProgress: null, + setError: jest.fn(), + setQueryInProgress: jest.fn(), + }, + client: { + threads: { + state: new StateStore({ + unreadThreadCount: 0, + }), + }, + }, + getAppSettings: jest.fn(() => null), + latestMessageDatesByChannels: {}, + openMobileNav: jest.fn(), + searchController: {}, + theme: 'str-chat__theme-light', + useImageFlagEmojisOnWindows: false, + }) as unknown as ChatContextValue; + +const renderWithProviders = (ui: React.ReactNode) => + render( + + key, userLanguage: 'en' }}> + {ui} + + , + ); + +describe('useChatViewNavigation', () => { + it('supports open/close thread flow where close clears thread slot state', () => { + const channel = makeChannel('messaging:navigation'); + const thread = makeThread('thread-navigation'); + let capturedController: LayoutController | undefined; + + const Harness = () => { + const navigation = useChatViewNavigation(); + const { layoutController } = useChatViewContext(); + capturedController = layoutController; + + return ( + <> + + + + + ); + }; + + renderWithProviders( + + + , + ); + + fireEvent.click(screen.getByText('open-channel')); + const openChannelState = capturedController?.state.getLatestValue(); + expect(openChannelState?.activeView).toBe('channels'); + expect(getChatViewEntityBinding(openChannelState?.slotBindings.slot1)?.kind).toBe( + 'channel', + ); + + fireEvent.click(screen.getByText('open-thread')); + const openThreadState = capturedController?.state.getLatestValue(); + expect(openThreadState?.activeView).toBe('channels'); + expect(getChatViewEntityBinding(openThreadState?.slotBindings.slot1)?.kind).toBe( + 'thread', + ); + expect(getChatViewEntityBinding(openThreadState?.slotHistory.slot1?.[0])?.kind).toBe( + 'channel', + ); + + fireEvent.click(screen.getByText('close-thread')); + const closeThreadState = capturedController?.state.getLatestValue(); + expect(closeThreadState?.activeView).toBe('channels'); + expect(closeThreadState?.slotBindings.slot1).toBeUndefined(); + expect(closeThreadState?.slotHistory).toEqual({}); + expect(closeThreadState?.slotForwardHistory).toEqual({}); + }); + + it('closes thread when opening a new channel', () => { + const channelA = makeChannel('messaging:channel-a'); + const channelB = makeChannel('messaging:channel-b'); + const thread = makeThread('thread-navigation-switch'); + let capturedController: LayoutController | undefined; + + const Harness = () => { + const navigation = useChatViewNavigation(); + const { layoutController } = useChatViewContext(); + capturedController = layoutController; + + return ( + <> + + + + + ); + }; + + renderWithProviders( + + + , + ); + + fireEvent.click(screen.getByText('open-channel-a')); + fireEvent.click(screen.getByText('open-thread')); + expect( + getChatViewEntityBinding( + capturedController?.state.getLatestValue()?.slotBindings.slot1, + )?.kind, + ).toBe('thread'); + + fireEvent.click(screen.getByText('open-channel-b')); + const stateAfterSecondChannelOpen = capturedController?.state.getLatestValue(); + expect( + getChatViewEntityBinding(stateAfterSecondChannelOpen?.slotBindings.slot1)?.kind, + ).toBe('channel'); + expect( + getChatViewEntityBinding(stateAfterSecondChannelOpen?.slotBindings.slot1)?.source, + ).toBe(channelB); + expect(stateAfterSecondChannelOpen?.slotHistory).toEqual({}); + expect(stateAfterSecondChannelOpen?.slotForwardHistory).toEqual({}); + }); + + it('hides and unhides channelList slot without requiring existing binding', () => { + let capturedController: LayoutController | undefined; + + const Harness = () => { + const navigation = useChatViewNavigation(); + const { layoutController } = useChatViewContext(); + capturedController = layoutController; + + return ( + <> + + + + ); + }; + + renderWithProviders( + + + , + ); + + fireEvent.click(screen.getByText('hide-list')); + expect(capturedController?.state.getLatestValue()).toMatchObject({ + hiddenSlots: { + slot1: true, + }, + }); + + fireEvent.click(screen.getByText('unhide-list')); + const unhiddenState = capturedController?.state.getLatestValue(); + expect(unhiddenState?.hiddenSlots.slot1).toBe(false); + expect(getChatViewEntityBinding(unhiddenState?.slotBindings.slot1)).toMatchObject({ + key: 'channel-list', + kind: 'channelList', + source: { view: 'channels' }, + }); + }); + + it('openView updates activeView', () => { + let capturedController: LayoutController | undefined; + + const Harness = () => { + const navigation = useChatViewNavigation(); + const { layoutController } = useChatViewContext(); + capturedController = layoutController; + + return ( + + ); + }; + + renderWithProviders( + + + , + ); + + fireEvent.click(screen.getByText('open-threads-slot2')); + expect(capturedController?.state.getLatestValue()).toMatchObject({ + activeView: 'threads', + }); + }); + + it('openThread expands available slots up to maxSlots before replacing occupied slot', () => { + const channel = makeChannel('messaging:expand-channel'); + const thread = makeThread('thread-expand'); + let capturedController: LayoutController | undefined; + + const Harness = () => { + const navigation = useChatViewNavigation(); + const { layoutController } = useChatViewContext(); + capturedController = layoutController; + + return ( + <> + + + + ); + }; + + renderWithProviders( + + + , + ); + + fireEvent.click(screen.getByText('open-channel')); + fireEvent.click(screen.getByText('open-thread')); + + const openThreadState = capturedController?.state.getLatestValue(); + expect(openThreadState?.availableSlots).toEqual(['slot1', 'slot2', 'slot3']); + expect(getChatViewEntityBinding(openThreadState?.slotBindings.slot2)?.kind).toBe( + 'channel', + ); + expect(getChatViewEntityBinding(openThreadState?.slotBindings.slot3)?.kind).toBe( + 'thread', + ); + }); + + it('openThread uses configured slotNames for expansion', () => { + const channel = makeChannel('messaging:expand-named'); + const thread = makeThread('thread-expand-named'); + let capturedController: LayoutController | undefined; + + const Harness = () => { + const navigation = useChatViewNavigation(); + const { layoutController } = useChatViewContext(); + capturedController = layoutController; + + return ( + <> + + + + ); + }; + + renderWithProviders( + + + , + ); + + fireEvent.click(screen.getByText('open-channel')); + fireEvent.click(screen.getByText('open-thread')); + + const openThreadState = capturedController?.state.getLatestValue(); + expect(openThreadState?.availableSlots).toEqual(['list', 'main', 'thread']); + expect(getChatViewEntityBinding(openThreadState?.slotBindings.main)?.kind).toBe( + 'channel', + ); + expect(getChatViewEntityBinding(openThreadState?.slotBindings.thread)?.kind).toBe( + 'thread', + ); + }); +}); diff --git a/src/components/ChatView/__tests__/layoutController.test.ts b/src/components/ChatView/__tests__/layoutController.test.ts new file mode 100644 index 0000000000..acbb8e1fc3 --- /dev/null +++ b/src/components/ChatView/__tests__/layoutController.test.ts @@ -0,0 +1,382 @@ +import { LayoutController } from '../layoutController/LayoutController'; +import { + restoreLayoutControllerState, + serializeLayoutControllerState, +} from '../layoutController/serialization'; +import { resolveTargetSlotChannelDefault } from '../layoutSlotResolvers'; + +import type { Channel as StreamChannel, Thread as StreamThread } from 'stream-chat'; +import type { ChatViewLayoutState } from '../layoutController/layoutControllerTypes'; + +const makeChannel = (cid: string) => ({ cid }) as unknown as StreamChannel; +const makeThread = (id: string) => ({ id }) as unknown as StreamThread; +const makeBinding = (kind: string, source: unknown, key?: string) => ({ + key, + payload: { key, kind, source }, +}); + +describe('layoutController', () => { + it('returns opened, replaced, and rejected outcomes from open()', () => { + const controller = new LayoutController({ + initialState: { + availableSlots: ['slot1'], + }, + resolveTargetSlot: () => 'slot1', + }); + + const firstOpen = controller.openInLayout( + makeBinding('channel', makeChannel('messaging:one'), 'messaging:one'), + ); + const secondOpen = controller.openInLayout( + makeBinding('channel', makeChannel('messaging:two'), 'messaging:two'), + ); + controller.clear('slot1'); + const rejectedOpen = controller.openInLayout( + makeBinding('channel', makeChannel('messaging:three'), 'messaging:three'), + ); + + expect(firstOpen).toMatchObject({ slot: 'slot1', status: 'opened' }); + expect(secondOpen).toMatchObject({ slot: 'slot1', status: 'replaced' }); + expect(rejectedOpen).toMatchObject({ + reason: 'no-available-slot', + status: 'rejected', + }); + }); + + it('tracks occupiedAt when slot becomes occupied and clears it on clear()', () => { + const controller = new LayoutController({ + initialState: { + availableSlots: ['slot1'], + }, + }); + + controller.openInLayout( + makeBinding('channel', makeChannel('messaging:one'), 'messaging:one'), + ); + const occupiedAt = controller.state.getLatestValue().slotMeta.slot1?.occupiedAt; + controller.clear('slot1'); + + expect(typeof occupiedAt).toBe('number'); + expect(controller.state.getLatestValue().slotMeta.slot1).toBeUndefined(); + }); + + it('prefers replacing same-kind slot over binding into a free slot', () => { + const firstThread = makeBinding('thread', makeThread('thread-1'), 'thread-1'); + const secondThread = makeBinding('thread', makeThread('thread-2'), 'thread-2'); + const controller = new LayoutController({ + initialState: { + availableSlots: ['slot1', 'slot2'], + slotBindings: { + slot1: firstThread, + }, + }, + }); + + const result = controller.openInLayout(secondThread); + const state = controller.state.getLatestValue(); + + expect(result).toMatchObject({ slot: 'slot1', status: 'replaced' }); + expect(state.slotBindings.slot2).toBeUndefined(); + expect(state.slotHistory?.slot1).toEqual([firstThread]); + }); + + it('supports duplicateEntityPolicy reject and move', () => { + const rejectController = new LayoutController({ + duplicateEntityPolicy: 'reject', + initialState: { availableSlots: ['slot1', 'slot2'] }, + resolveTargetSlot: () => 'slot2', + }); + const duplicateChannel = makeChannel('messaging:duplicate'); + + rejectController.openInLayout( + makeBinding('channel', duplicateChannel, duplicateChannel.cid), + { + targetSlot: 'slot1', + }, + ); + const rejectResult = rejectController.openInLayout( + makeBinding('channel', duplicateChannel, duplicateChannel.cid), + { + targetSlot: 'slot2', + }, + ); + + expect(rejectResult).toMatchObject({ + reason: 'duplicate-binding', + status: 'rejected', + }); + + const moveController = new LayoutController({ + duplicateEntityPolicy: 'move', + initialState: { availableSlots: ['slot1', 'slot2'] }, + }); + + moveController.openInLayout( + makeBinding('channel', makeChannel('messaging:one'), 'messaging:one'), + { + targetSlot: 'slot1', + }, + ); + moveController.openInLayout( + makeBinding('channel', makeChannel('messaging:two'), 'messaging:two'), + { + targetSlot: 'slot2', + }, + ); + moveController.openInLayout( + makeBinding('channel', makeChannel('messaging:one'), 'messaging:one'), + { + targetSlot: 'slot2', + }, + ); + + const movedState = moveController.state.getLatestValue(); + expect(movedState.slotBindings.slot1).toBeUndefined(); + expect((movedState.slotBindings.slot2?.payload as { kind: string }).kind).toBe( + 'channel', + ); + expect( + ( + (movedState.slotBindings.slot2?.payload as { source: StreamChannel }) + .source as StreamChannel + ).cid, + ).toBe('messaging:one'); + }); + + it('openView updates activeView', () => { + const controller = new LayoutController({ + initialState: { + activeView: 'channels', + availableSlots: ['slot1', 'slot2'], + }, + }); + + controller.openView('threads'); + expect(controller.state.getLatestValue()).toMatchObject({ + activeView: 'threads', + }); + + controller.openView('channels', { slot: 'slot2' }); + expect(controller.state.getLatestValue()).toMatchObject({ + activeView: 'channels', + }); + }); + + it('serializes and restores hidden slots and serializable bindings', () => { + const sourceController = new LayoutController({ + initialState: { + availableSlots: ['slot1', 'slot2'], + hiddenSlots: { slot1: true }, + slotBindings: { + slot1: makeBinding('channelList', { view: 'threads' }, 'channel-list'), + slot2: makeBinding('channel', makeChannel('messaging:one'), 'channel-1'), + }, + slotHistory: { + slot2: [ + makeBinding('searchResults', { query: 'abc' }, 'search:abc'), + makeBinding('channel', makeChannel('messaging:fallback'), 'channel-fallback'), + ], + }, + }, + }); + + const snapshot = serializeLayoutControllerState(sourceController); + expect((snapshot.slotBindings.slot1?.payload as { kind: string }).kind).toBe( + 'channelList', + ); + expect((snapshot.slotBindings.slot2?.payload as { kind: string }).kind).toBe( + 'channel', + ); + expect( + snapshot.slotHistory.slot2?.map( + (entry) => (entry.payload as { kind: string }).kind, + ), + ).toEqual(['searchResults', 'channel']); + + const restoreController = new LayoutController({ + initialState: { availableSlots: ['slot1', 'slot2'] }, + }); + restoreLayoutControllerState(restoreController, snapshot); + + expect(restoreController.state.getLatestValue()).toMatchObject({ + hiddenSlots: { slot1: true }, + slotBindings: { + slot1: makeBinding('channelList', { view: 'threads' }, 'channel-list'), + slot2: makeBinding('channel', makeChannel('messaging:one'), 'channel-1'), + }, + slotHistory: { + slot2: [ + makeBinding('searchResults', { query: 'abc' }, 'search:abc'), + makeBinding('channel', makeChannel('messaging:fallback'), 'channel-fallback'), + ], + }, + }); + }); + + it('goBack and goForward navigate independently per slot', () => { + const controller = new LayoutController({ + initialState: { + availableSlots: ['slot1'], + }, + }); + const first = makeBinding( + 'channel', + makeChannel('messaging:first'), + 'messaging:first', + ); + const second = makeBinding( + 'channel', + makeChannel('messaging:second'), + 'messaging:second', + ); + + controller.openInLayout(first, { targetSlot: 'slot1' }); + controller.openInLayout(second, { targetSlot: 'slot1' }); + controller.goBack('slot1'); + + expect(controller.state.getLatestValue().slotBindings.slot1).toEqual(first); + expect(controller.state.getLatestValue().slotForwardHistory?.slot1).toEqual([second]); + + controller.goForward('slot1'); + expect(controller.state.getLatestValue().slotBindings.slot1).toEqual(second); + }); + + it('does not duplicate history when replacing slot and top history already equals current', () => { + const currentBinding = makeBinding( + 'channel', + makeChannel('messaging:current'), + 'messaging:current', + ); + + const controller = new LayoutController({ + initialState: { + availableSlots: ['slot1'], + slotBindings: { + slot1: currentBinding, + }, + slotHistory: { + slot1: [currentBinding], + }, + }, + resolveTargetSlot: () => 'slot1', + }); + + controller.openInLayout( + makeBinding('channel', makeChannel('messaging:next'), 'messaging:next'), + { targetSlot: 'slot1' }, + ); + + expect(controller.state.getLatestValue().slotHistory?.slot1).toEqual([ + currentBinding, + ]); + }); + + it('clears forward history on write after going back', () => { + const controller = new LayoutController({ + initialState: { + availableSlots: ['slot1'], + }, + }); + const first = makeBinding( + 'channel', + makeChannel('messaging:first'), + 'messaging:first', + ); + const second = makeBinding( + 'channel', + makeChannel('messaging:second'), + 'messaging:second', + ); + const third = makeBinding( + 'channel', + makeChannel('messaging:third'), + 'messaging:third', + ); + + controller.openInLayout(first, { targetSlot: 'slot1' }); + controller.openInLayout(second, { targetSlot: 'slot1' }); + controller.goBack('slot1'); + controller.openInLayout(third, { targetSlot: 'slot1' }); + + expect(controller.state.getLatestValue().slotForwardHistory?.slot1).toBeUndefined(); + }); +}); + +describe('resolveTargetSlotChannelDefault', () => { + const makeState = (overrides: Partial): ChatViewLayoutState => ({ + activeView: 'channels', + availableSlots: ['slot1', 'slot2'], + slotBindings: {}, + slotForwardHistory: {}, + slotHistory: {}, + slotMeta: {}, + ...overrides, + }); + + it('prefers requestedSlot when provided', () => { + const slot = resolveTargetSlotChannelDefault({ + binding: makeBinding('channel', makeChannel('messaging:one')), + requestedSlot: 'slot2', + state: makeState({}), + }); + + expect(slot).toBe('slot2'); + }); + + it('replaces thread slot first when opening a channel into a full workspace', () => { + const state = makeState({ + slotBindings: { + slot1: makeBinding('channel', makeChannel('messaging:one')), + slot2: makeBinding('thread', makeThread('thread-1')), + }, + }); + + const slot = resolveTargetSlotChannelDefault({ + binding: makeBinding('channel', makeChannel('messaging:two')), + state, + }); + + expect(slot).toBe('slot2'); + }); + + it('prefers existing channel slot over channelList slot for channel opens', () => { + const state = makeState({ + availableSlots: ['list', 'channel'], + slotBindings: { + channel: makeBinding('channel', makeChannel('messaging:one')), + list: makeBinding('channelList', { view: 'channels' }), + }, + slotMeta: { + channel: { occupiedAt: 20 }, + list: { occupiedAt: 10 }, + }, + }); + + const slot = resolveTargetSlotChannelDefault({ + binding: makeBinding('channel', makeChannel('messaging:two')), + state, + }); + + expect(slot).toBe('channel'); + }); + + it('falls back to earliest occupied slot when only channels are present', () => { + const state = makeState({ + slotBindings: { + slot1: makeBinding('channel', makeChannel('messaging:one')), + slot2: makeBinding('channel', makeChannel('messaging:two')), + }, + slotMeta: { + slot1: { occupiedAt: 10 }, + slot2: { occupiedAt: 20 }, + }, + }); + + const slot = resolveTargetSlotChannelDefault({ + binding: makeBinding('channel', makeChannel('messaging:three')), + state, + }); + + expect(slot).toBe('slot1'); + }); +}); diff --git a/src/components/ChatView/__tests__/useSlotEntity.test.tsx b/src/components/ChatView/__tests__/useSlotEntity.test.tsx new file mode 100644 index 0000000000..201cea87bc --- /dev/null +++ b/src/components/ChatView/__tests__/useSlotEntity.test.tsx @@ -0,0 +1,315 @@ +import React from 'react'; +import { StateStore } from 'stream-chat'; +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { ChannelSlot } from '../../Channel'; +import { ChatView, getChatViewEntityBinding } from '../ChatView'; +import { ThreadSlot } from '../../Thread'; +import { useSlotChannel, useSlotEntity, useSlotThread } from '../hooks'; + +import { ChatProvider } from '../../../context/ChatContext'; +import { TranslationProvider } from '../../../context/TranslationContext'; +import { LayoutController } from '../layoutController/LayoutController'; + +import type { Channel as StreamChannel, Thread as StreamThread } from 'stream-chat'; +import type { ChatContextValue } from '../../../context/ChatContext'; + +jest.mock('../../Channel/Channel', () => ({ + Channel: ({ + channel, + children, + }: { + channel: StreamChannel; + children?: React.ReactNode; + }) => ( +
    + {channel.cid} + {children} +
    + ), +})); + +jest.mock('../../Threads', () => ({ + ThreadProvider: ({ children }: { children?: React.ReactNode }) => ( + <>{children ?? null} + ), +})); + +jest.mock('../../Thread/Thread', () => ({ + Thread: () =>
    thread-component
    , +})); + +const makeChannel = (cid: string) => ({ cid }) as unknown as StreamChannel; +const makeThread = (id: string) => ({ id }) as unknown as StreamThread; + +const createChatContextValue = (): ChatContextValue => + ({ + channelsQueryState: { + error: null, + queryInProgress: null, + setError: jest.fn(), + setQueryInProgress: jest.fn(), + }, + client: { + threads: { + state: new StateStore({ + unreadThreadCount: 0, + }), + }, + }, + getAppSettings: jest.fn(() => null), + latestMessageDatesByChannels: {}, + openMobileNav: jest.fn(), + searchController: {}, + theme: 'str-chat__theme-light', + useImageFlagEmojisOnWindows: false, + }) as unknown as ChatContextValue; + +const renderWithProviders = (ui: React.ReactNode) => + render( + + key, userLanguage: 'en' }}> + {ui} + + , + ); + +describe('useSlotEntity hooks', () => { + it('resolves first matching entity from availableSlots and supports explicit slot', () => { + const channelA = makeChannel('messaging:active'); + const channelB = makeChannel('messaging:secondary'); + const thread = makeThread('thread-1'); + + const layoutController = new LayoutController({ + initialState: { + availableSlots: ['slot1', 'slot2', 'slot3'], + }, + }); + + layoutController.setSlotBinding('slot1', { + key: `channel:${channelB.cid}`, + payload: { key: channelB.cid, kind: 'channel', source: channelB }, + }); + layoutController.setSlotBinding('slot2', { + key: `channel:${channelA.cid}`, + payload: { key: channelA.cid, kind: 'channel', source: channelA }, + }); + layoutController.setSlotBinding('slot3', { + key: `thread:${thread.id}`, + payload: { key: thread.id, kind: 'thread', source: thread }, + }); + + const Harness = () => { + const channel = useSlotEntity({ kind: 'channel' }); + const channelInSlot1 = useSlotChannel({ slot: 'slot1' }); + const resolvedThread = useSlotThread(); + + return ( + <> +
    {channel?.cid}
    +
    {channelInSlot1?.cid}
    +
    {resolvedThread?.id}
    + + ); + }; + + renderWithProviders( + + + , + ); + + expect(screen.getByTestId('resolved-channel')).toHaveTextContent( + 'messaging:secondary', + ); + expect(screen.getByTestId('resolved-channel-slot1')).toHaveTextContent( + 'messaging:secondary', + ); + expect(screen.getByTestId('resolved-thread')).toHaveTextContent('thread-1'); + }); +}); + +describe('slot adapters', () => { + it('renders ChannelSlot fallback when no channel entity is bound', () => { + const layoutController = new LayoutController({ + initialState: { + availableSlots: ['slot1'], + }, + }); + + renderWithProviders( + + no-channel
    } /> + , + ); + + expect(screen.getByTestId('channel-fallback')).toBeInTheDocument(); + }); + + it('renders ChannelSlot with the channel bound to explicit slot', () => { + const channel = makeChannel('messaging:slot2'); + const layoutController = new LayoutController({ + initialState: { + availableSlots: ['slot1', 'slot2'], + }, + }); + + layoutController.setSlotBinding('slot2', { + key: `channel:${channel.cid}`, + payload: { key: channel.cid, kind: 'channel', source: channel }, + }); + + renderWithProviders( + + +
    child
    +
    +
    , + ); + + expect(screen.getByTestId('mocked-channel')).toHaveTextContent('messaging:slot2'); + expect(screen.getByTestId('channel-child')).toBeInTheDocument(); + }); + + it('claims explicit slot for ChannelSlot by moving an existing channel binding', () => { + const channel = makeChannel('messaging:move-channel'); + const layoutController = new LayoutController({ + initialState: { + availableSlots: ['slot1', 'slot2'], + }, + }); + + layoutController.setSlotBinding('slot2', { + key: `channel:${channel.cid}`, + payload: { key: channel.cid, kind: 'channel', source: channel }, + }); + + renderWithProviders( + + + , + ); + + expect( + getChatViewEntityBinding( + layoutController.state.getLatestValue().slotBindings.slot1, + ), + ).toMatchObject({ + kind: 'channel', + source: channel, + }); + }); + + it('renders ThreadSlot fallback when no thread entity is bound', () => { + const layoutController = new LayoutController({ + initialState: { + availableSlots: ['slot1'], + }, + }); + + renderWithProviders( + + no-thread
    } /> +
    , + ); + + expect(screen.getByTestId('thread-fallback')).toBeInTheDocument(); + }); + + it('renders ThreadSlot with bound thread using default Thread component', () => { + const thread = makeThread('thread-slot'); + const layoutController = new LayoutController({ + initialState: { + availableSlots: ['slot1'], + }, + }); + + layoutController.setSlotBinding('slot1', { + key: `thread:${thread.id}`, + payload: { key: thread.id, kind: 'thread', source: thread }, + }); + + renderWithProviders( + + + , + ); + + expect(screen.getByTestId('mocked-thread-component')).toBeInTheDocument(); + }); + + it('claims explicit slot for ThreadSlot by moving an existing thread binding', () => { + const thread = makeThread('thread-move'); + const layoutController = new LayoutController({ + duplicateEntityPolicy: 'move', + initialState: { + availableSlots: ['slot1', 'slot2'], + }, + }); + + layoutController.setSlotBinding('slot2', { + key: `thread:${thread.id}`, + payload: { key: thread.id, kind: 'thread', source: thread }, + }); + + renderWithProviders( + + + , + ); + + expect( + getChatViewEntityBinding( + layoutController.state.getLatestValue().slotBindings.slot1, + ), + ).toMatchObject({ + kind: 'thread', + source: thread, + }); + }); + + it('hides explicit slot when ThreadSlot has no thread', async () => { + const layoutController = new LayoutController({ + initialState: { + availableSlots: ['slot1'], + hiddenSlots: { slot1: false }, + }, + }); + + renderWithProviders( + + + , + ); + + await waitFor(() => { + expect(layoutController.state.getLatestValue().hiddenSlots.slot1).toBe(true); + }); + }); + + it('unhides explicit slot when ThreadSlot has a bound thread', async () => { + const thread = makeThread('thread-visible'); + const layoutController = new LayoutController({ + initialState: { + availableSlots: ['slot1'], + hiddenSlots: { slot1: true }, + }, + }); + + layoutController.setSlotBinding('slot1', { + key: `thread:${thread.id}`, + payload: { key: thread.id, kind: 'thread', source: thread }, + }); + + renderWithProviders( + + + , + ); + + await waitFor(() => { + expect(layoutController.state.getLatestValue().hiddenSlots.slot1).toBe(false); + }); + }); +}); diff --git a/src/components/ChatView/hooks/index.ts b/src/components/ChatView/hooks/index.ts new file mode 100644 index 0000000000..eaf99cbdb2 --- /dev/null +++ b/src/components/ChatView/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useLayoutViewState'; +export * from './useSlotEntity'; diff --git a/src/components/ChatView/hooks/useLayoutViewState.ts b/src/components/ChatView/hooks/useLayoutViewState.ts new file mode 100644 index 0000000000..06d3cc9c4b --- /dev/null +++ b/src/components/ChatView/hooks/useLayoutViewState.ts @@ -0,0 +1,37 @@ +import { useMemo } from 'react'; + +import { useStateStore } from '../../../store'; +import type { ChatView } from '../ChatView'; +import { useChatViewContext } from '../ChatView'; +import type { + ChatViewLayoutState, + ChatViewLayoutViewState, +} from '../layoutController/layoutControllerTypes'; + +export const getLayoutViewState = ( + state: ChatViewLayoutState, + view = state.activeView, +): ChatViewLayoutViewState => ({ + availableSlots: [...(state.availableSlotsByView?.[view] ?? [])], + hiddenSlots: { ...(state.hiddenSlotsByView?.[view] ?? {}) }, + slotBindings: { ...(state.slotBindingsByView?.[view] ?? {}) }, + slotForwardHistory: { ...(state.slotForwardHistoryByView?.[view] ?? {}) }, + slotHistory: { ...(state.slotHistoryByView?.[view] ?? {}) }, + slotMeta: { ...(state.slotMetaByView?.[view] ?? {}) }, + slotNames: state.slotNamesByView?.[view] + ? [...(state.slotNamesByView?.[view] ?? [])] + : undefined, +}); +export const useLayoutViewState = (view?: ChatView) => { + const { layoutController } = useChatViewContext(); + const selector = useMemo( + () => (state: Parameters[0]) => + getLayoutViewState(state, view ?? state.activeView), + [view], + ); + + return ( + useStateStore(layoutController.state, selector) ?? + selector(layoutController.state.getLatestValue()) + ); +}; diff --git a/src/components/ChatView/hooks/useSlotEntity.ts b/src/components/ChatView/hooks/useSlotEntity.ts new file mode 100644 index 0000000000..904747f4d9 --- /dev/null +++ b/src/components/ChatView/hooks/useSlotEntity.ts @@ -0,0 +1,114 @@ +import { getChatViewEntityBinding } from '../ChatView'; +import { useLayoutViewState } from './useLayoutViewState'; + +import type { ChatViewEntityBinding } from '../ChatView'; +import type { SlotName } from '../layoutController/layoutControllerTypes'; + +type SlotEntityKind = ChatViewEntityBinding['kind']; + +type SlotEntitySourceMap = { + [TKind in SlotEntityKind]: Extract['source']; +}; + +type SlotEntitySourceByKind = SlotEntitySourceMap[TKind]; + +type UseSlotEntityOptions = { + /** + * Entity kind to resolve from ChatView slot bindings. + */ + kind: TKind; + /** + * Optional explicit slot to inspect. + * + * - when provided: only that slot is checked + * - when omitted: slots are checked in active view order (`availableSlots`) + */ + slot?: SlotName; +}; + +const resolveCandidateSlots = ( + slot: SlotName | undefined, + availableSlots: SlotName[], +) => { + if (slot) return [slot]; + return availableSlots; +}; + +const isEntityOfKind = ( + entity: ChatViewEntityBinding | undefined, + kind: TKind, +): entity is Extract => entity?.kind === kind; + +/** + * Resolves the `source` of the first matching ChatView entity binding. + * + * Behavior: + * 1. Read active-view slot topology/bindings via `useLayoutViewState()`. + * 2. Build candidate slots: + * - `[slot]` when explicit `slot` is provided + * - `availableSlots` order when `slot` is omitted + * 3. Return `entity.source` for the first binding whose `kind` matches. + * 4. Return `undefined` when no match exists. + * + * Why this exists: + * - Keeps slot-to-entity lookup consistent and centralized for ChatView-aware + * components (e.g. `ChannelSlot`, `ThreadSlot`). + * - Preserves type safety by narrowing returned `source` based on `kind`. + * + * @example + * ```tsx + * const channel = useSlotEntity({ kind: 'channel' }); + * // channel is Stream Channel | undefined + * ``` + * + * @example + * ```tsx + * const thread = useSlotEntity({ kind: 'thread', slot: 'main-thread' }); + * // thread is Thread | undefined from that slot only + * ``` + * + * @example + * ```tsx + * const list = useSlotEntity({ kind: 'channelList', slot: 'list' }); + * // list is { view?: ChatView } | undefined + * ``` + */ +export const useSlotEntity = ({ + kind, + slot, +}: UseSlotEntityOptions): SlotEntitySourceByKind | undefined => { + const { availableSlots, slotBindings } = useLayoutViewState(); + + const candidateSlots = resolveCandidateSlots(slot, availableSlots); + for (const candidateSlot of candidateSlots) { + const entity = getChatViewEntityBinding(slotBindings[candidateSlot]); + if (isEntityOfKind(entity, kind)) { + // Safe due to discriminant check on `kind` above. + return entity.source as SlotEntitySourceByKind; + } + } + + return undefined; +}; + +/** + * Convenience wrapper for `useSlotEntity({ kind: 'channel' })`. + * + * @example + * ```tsx + * const channel = useSlotChannel({ slot: 'main' }); + * ``` + */ +export const useSlotChannel = (options?: { slot?: SlotName }) => + useSlotEntity({ kind: 'channel', slot: options?.slot }); + +/** + * Convenience wrapper for `useSlotEntity({ kind: 'thread' })`. + * + * @example + * ```tsx + * const thread = useSlotThread(); + * ``` + */ +export const useSlotThread = (options?: { slot?: SlotName }) => + useSlotEntity({ kind: 'thread', slot: options?.slot }); diff --git a/src/components/ChatView/index.tsx b/src/components/ChatView/index.tsx index c9582c20c1..e6db5c2671 100644 --- a/src/components/ChatView/index.tsx +++ b/src/components/ChatView/index.tsx @@ -1 +1,6 @@ export * from './ChatView'; +export * from './ChatViewNavigationContext'; +export * from './layoutController/layoutControllerTypes'; +export * from './layoutController/serialization'; +export * from './layoutSlotResolvers'; +export * from './hooks'; diff --git a/src/components/ChatView/layout/Slot.tsx b/src/components/ChatView/layout/Slot.tsx new file mode 100644 index 0000000000..ce73f2b1d3 --- /dev/null +++ b/src/components/ChatView/layout/Slot.tsx @@ -0,0 +1,33 @@ +import clsx from 'clsx'; +import React from 'react'; + +import { useLayoutViewState } from '../hooks/useLayoutViewState'; + +import type { ReactNode } from 'react'; + +export type SlotProps = { + children?: ReactNode; + className?: string; + slot: string; +}; + +export const Slot = ({ children, className, slot }: SlotProps) => { + const { hiddenSlots } = useLayoutViewState(); + const hidden = !!hiddenSlots?.[slot]; + + return ( +
    + {children} +
    + ); +}; diff --git a/src/components/ChatView/layout/WorkspaceLayout.tsx b/src/components/ChatView/layout/WorkspaceLayout.tsx new file mode 100644 index 0000000000..95038dbdeb --- /dev/null +++ b/src/components/ChatView/layout/WorkspaceLayout.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import clsx from 'clsx'; +import { Slot } from './Slot'; + +import type { ReactNode } from 'react'; + +export type WorkspaceLayoutSlot = { + content?: ReactNode; + slot: string; +}; + +export type WorkspaceLayoutProps = { + navRail?: ReactNode; + slots: WorkspaceLayoutSlot[]; +}; + +export const WorkspaceLayout = ({ navRail, slots }: WorkspaceLayoutProps) => ( +
    + {navRail ? ( +
    {navRail}
    + ) : null} +
    + {slots.map(({ content, slot }) => ( + + {content} + + ))} +
    +
    +); diff --git a/src/components/ChatView/layoutController/LayoutController.ts b/src/components/ChatView/layoutController/LayoutController.ts new file mode 100644 index 0000000000..f2ec5d82fd --- /dev/null +++ b/src/components/ChatView/layoutController/LayoutController.ts @@ -0,0 +1,604 @@ +import { StateStore } from 'stream-chat'; + +import type { + ChatViewLayoutState, + ChatViewLayoutViewState, + CreateLayoutControllerOptions, + DuplicateEntityPolicy, + LayoutController as LayoutControllerApi, + LayoutSlotBinding, + OpenOptions, + OpenResult, + SlotName, +} from './layoutControllerTypes'; + +const DEFAULT_LAYOUT_STATE: ChatViewLayoutState = { + activeView: 'channels', + availableSlotsByView: {}, + hiddenSlotsByView: {}, + listSlotByView: {}, + slotBindingsByView: {}, + slotForwardHistoryByView: {}, + slotHistoryByView: {}, + slotMetaByView: {}, + slotNamesByView: {}, +}; + +const DEFAULT_DUPLICATE_ENTITY_POLICY: DuplicateEntityPolicy = 'allow'; + +/** + * Slot binding means: + * + * - `slot name` -> `what should be shown in that slot`. + * + * Example: + * - `main` -> `{ kind: 'channel', source: channelA }` + * - `main-thread` -> `{ kind: 'thread', source: threadX }` + * + * In LayoutController: + * - `payload` is the thing to render in the slot. + * - `key` (optional) is used to identify duplicates. + * - changing a binding means \"show different data in this slot now\". + */ +const resolveBindingKey = (binding: LayoutSlotBinding) => binding.key; + +const toViewState = ( + state: ChatViewLayoutState, + view = state.activeView, +): ChatViewLayoutViewState => ({ + availableSlots: [...(state.availableSlotsByView?.[view] ?? [])], + hiddenSlots: { ...(state.hiddenSlotsByView?.[view] ?? {}) }, + slotBindings: { ...(state.slotBindingsByView?.[view] ?? {}) }, + slotForwardHistory: { ...(state.slotForwardHistoryByView?.[view] ?? {}) }, + slotHistory: { ...(state.slotHistoryByView?.[view] ?? {}) }, + slotMeta: { ...(state.slotMetaByView?.[view] ?? {}) }, + slotNames: state.slotNamesByView?.[view] + ? [...(state.slotNamesByView?.[view] ?? [])] + : undefined, +}); + +const mergeViewState = ( + state: ChatViewLayoutState, + nextViewState: ChatViewLayoutViewState, + view = state.activeView, +): ChatViewLayoutState => ({ + ...state, + availableSlotsByView: { + ...(state.availableSlotsByView ?? {}), + [view]: [...nextViewState.availableSlots], + }, + hiddenSlotsByView: { + ...(state.hiddenSlotsByView ?? {}), + [view]: { ...nextViewState.hiddenSlots }, + }, + slotBindingsByView: { + ...(state.slotBindingsByView ?? {}), + [view]: { ...nextViewState.slotBindings }, + }, + slotForwardHistoryByView: { + ...(state.slotForwardHistoryByView ?? {}), + [view]: { ...nextViewState.slotForwardHistory }, + }, + slotHistoryByView: { + ...(state.slotHistoryByView ?? {}), + [view]: { ...nextViewState.slotHistory }, + }, + slotMetaByView: { + ...(state.slotMetaByView ?? {}), + [view]: { ...nextViewState.slotMeta }, + }, + slotNamesByView: { + ...(state.slotNamesByView ?? {}), + [view]: nextViewState.slotNames ? [...nextViewState.slotNames] : undefined, + }, +}); + +const buildInitialState = ( + partialState?: CreateLayoutControllerOptions['initialState'], +): ChatViewLayoutState => { + const activeView = partialState?.activeView ?? DEFAULT_LAYOUT_STATE.activeView; + + const normalized: ChatViewLayoutState = { + ...DEFAULT_LAYOUT_STATE, + ...partialState, + activeView, + availableSlotsByView: { + ...DEFAULT_LAYOUT_STATE.availableSlotsByView, + ...(partialState?.availableSlotsByView ?? {}), + }, + hiddenSlotsByView: { + ...DEFAULT_LAYOUT_STATE.hiddenSlotsByView, + ...(partialState?.hiddenSlotsByView ?? {}), + }, + listSlotByView: { + ...DEFAULT_LAYOUT_STATE.listSlotByView, + ...(partialState?.listSlotByView ?? {}), + }, + slotBindingsByView: { + ...DEFAULT_LAYOUT_STATE.slotBindingsByView, + ...(partialState?.slotBindingsByView ?? {}), + }, + slotForwardHistoryByView: { + ...DEFAULT_LAYOUT_STATE.slotForwardHistoryByView, + ...(partialState?.slotForwardHistoryByView ?? {}), + }, + slotHistoryByView: { + ...DEFAULT_LAYOUT_STATE.slotHistoryByView, + ...(partialState?.slotHistoryByView ?? {}), + }, + slotMetaByView: { + ...DEFAULT_LAYOUT_STATE.slotMetaByView, + ...(partialState?.slotMetaByView ?? {}), + }, + slotNamesByView: { + ...DEFAULT_LAYOUT_STATE.slotNamesByView, + ...(partialState?.slotNamesByView ?? {}), + }, + }; + + const initialViewState = toViewState(normalized, activeView); + const next = mergeViewState(normalized, initialViewState, activeView); + + if ( + !next.availableSlotsByView?.[activeView]?.length && + initialViewState.slotNames?.length + ) { + const minSlots = Math.max(1, next.minSlots ?? 1); + const availableSlots = initialViewState.slotNames.slice( + 0, + Math.min(minSlots, initialViewState.slotNames.length), + ); + + return mergeViewState( + next, + { + ...initialViewState, + availableSlots, + }, + activeView, + ); + } + + return next; +}; + +const isSameSlotBinding = ( + first: LayoutSlotBinding, + second: LayoutSlotBinding, +): boolean => { + const firstKey = resolveBindingKey(first); + const secondKey = resolveBindingKey(second); + + if (firstKey && secondKey) return firstKey === secondKey; + + return first === second; +}; + +const findBindingSlot = ( + viewState: ChatViewLayoutViewState, + binding: LayoutSlotBinding, +): SlotName | undefined => { + const identityKey = resolveBindingKey(binding); + if (!identityKey) return; + + return viewState.availableSlots.find((slot) => { + const boundBinding = viewState.slotBindings[slot]; + if (!boundBinding) return false; + return resolveBindingKey(boundBinding) === identityKey; + }); +}; + +const pushSlotHistory = ( + viewState: ChatViewLayoutViewState, + slot: SlotName, + binding: LayoutSlotBinding, +): ChatViewLayoutViewState => { + const slotHistory = viewState.slotHistory?.[slot] ?? []; + const previousBinding = slotHistory[slotHistory.length - 1]; + if (previousBinding && isSameSlotBinding(previousBinding, binding)) { + return viewState; + } + + return { + ...viewState, + slotHistory: { + ...(viewState.slotHistory ?? {}), + [slot]: [...slotHistory, binding], + }, + }; +}; + +const popSlotHistory = ( + viewState: ChatViewLayoutViewState, + slot: SlotName, +): { popped?: LayoutSlotBinding; state: ChatViewLayoutViewState } => { + const slotHistory = viewState.slotHistory?.[slot]; + if (!slotHistory?.length) return { state: viewState }; + + const popped = slotHistory[slotHistory.length - 1]; + const nextSlotHistory = { ...(viewState.slotHistory ?? {}) }; + const remainingHistory = slotHistory.slice(0, -1); + + if (remainingHistory.length) { + nextSlotHistory[slot] = remainingHistory; + } else { + delete nextSlotHistory[slot]; + } + + return { + popped, + state: { + ...viewState, + slotHistory: nextSlotHistory, + }, + }; +}; + +const pushSlotForwardHistory = ( + viewState: ChatViewLayoutViewState, + slot: SlotName, + binding: LayoutSlotBinding, +): ChatViewLayoutViewState => { + const slotForwardHistory = viewState.slotForwardHistory?.[slot] ?? []; + const previousBinding = slotForwardHistory[slotForwardHistory.length - 1]; + if (previousBinding && isSameSlotBinding(previousBinding, binding)) { + return viewState; + } + + return { + ...viewState, + slotForwardHistory: { + ...(viewState.slotForwardHistory ?? {}), + [slot]: [...slotForwardHistory, binding], + }, + }; +}; + +const popSlotForwardHistory = ( + viewState: ChatViewLayoutViewState, + slot: SlotName, +): { popped?: LayoutSlotBinding; state: ChatViewLayoutViewState } => { + const slotForwardHistory = viewState.slotForwardHistory?.[slot]; + if (!slotForwardHistory?.length) return { state: viewState }; + + const popped = slotForwardHistory[slotForwardHistory.length - 1]; + const nextSlotForwardHistory = { ...(viewState.slotForwardHistory ?? {}) }; + const remainingHistory = slotForwardHistory.slice(0, -1); + + if (remainingHistory.length) { + nextSlotForwardHistory[slot] = remainingHistory; + } else { + delete nextSlotForwardHistory[slot]; + } + + return { + popped, + state: { + ...viewState, + slotForwardHistory: nextSlotForwardHistory, + }, + }; +}; + +const clearSlotForwardHistory = ( + viewState: ChatViewLayoutViewState, + slot: SlotName, +): ChatViewLayoutViewState => { + if (!viewState.slotForwardHistory?.[slot]) return viewState; + + const nextSlotForwardHistory = { ...(viewState.slotForwardHistory ?? {}) }; + delete nextSlotForwardHistory[slot]; + + return { + ...viewState, + slotForwardHistory: nextSlotForwardHistory, + }; +}; + +const upsertSlotBinding = ( + viewState: ChatViewLayoutViewState, + slot: SlotName, + binding: LayoutSlotBinding, +): ChatViewLayoutViewState => { + const nextSlotBindings: ChatViewLayoutViewState['slotBindings'] = { + ...viewState.slotBindings, + [slot]: binding, + }; + + const wasOccupied = !!viewState.slotBindings[slot]; + const hasOccupiedAt = typeof viewState.slotMeta[slot]?.occupiedAt === 'number'; + const shouldSetOccupiedAt = !wasOccupied || !hasOccupiedAt; + + const nextSlotMeta = shouldSetOccupiedAt + ? { + ...viewState.slotMeta, + [slot]: { + occupiedAt: Date.now(), + }, + } + : viewState.slotMeta; + + return { + ...viewState, + slotBindings: nextSlotBindings, + slotMeta: nextSlotMeta, + }; +}; + +const clearSlotBinding = ( + viewState: ChatViewLayoutViewState, + slot: SlotName, +): ChatViewLayoutViewState => { + if ( + !viewState.slotBindings[slot] && + !viewState.slotMeta[slot] && + !viewState.slotHistory?.[slot] && + !viewState.slotForwardHistory?.[slot] + ) { + return viewState; + } + + const nextSlotBindings = { ...viewState.slotBindings }; + delete nextSlotBindings[slot]; + + const nextSlotMeta = { ...viewState.slotMeta }; + delete nextSlotMeta[slot]; + + const nextSlotHistory = { ...(viewState.slotHistory ?? {}) }; + delete nextSlotHistory[slot]; + + const nextSlotForwardHistory = { ...(viewState.slotForwardHistory ?? {}) }; + delete nextSlotForwardHistory[slot]; + + return { + ...viewState, + slotBindings: nextSlotBindings, + slotForwardHistory: nextSlotForwardHistory, + slotHistory: nextSlotHistory, + slotMeta: nextSlotMeta, + }; +}; + +const resolveOpenTargetSlot = ( + state: ChatViewLayoutState, + activeViewState: ChatViewLayoutViewState, + binding: LayoutSlotBinding, + options: OpenOptions | undefined, + resolveTargetSlot: CreateLayoutControllerOptions['resolveTargetSlot'], +): SlotName | undefined => { + const requestedSlot = options?.targetSlot; + if (requestedSlot) { + if (!activeViewState.availableSlots.includes(requestedSlot)) return; + return requestedSlot; + } + + const firstFreeSlot = activeViewState.availableSlots.find( + (slot) => !activeViewState.slotBindings[slot], + ); + if (firstFreeSlot) return firstFreeSlot; + + const resolvedSlot = resolveTargetSlot?.({ + activeViewState, + binding, + requestedSlot, + state, + }); + + if (!resolvedSlot) return; + if (!activeViewState.availableSlots.includes(resolvedSlot)) return; + + return resolvedSlot; +}; + +export class LayoutController implements LayoutControllerApi { + private readonly duplicateEntityPolicy: DuplicateEntityPolicy; + private readonly resolveDuplicateEntity?: CreateLayoutControllerOptions['resolveDuplicateEntity']; + private readonly resolveTargetSlot?: CreateLayoutControllerOptions['resolveTargetSlot']; + state: StateStore; + + constructor(options: CreateLayoutControllerOptions = {}) { + const { + duplicateEntityPolicy = DEFAULT_DUPLICATE_ENTITY_POLICY, + initialState, + resolveDuplicateEntity, + resolveTargetSlot, + state = new StateStore(buildInitialState(initialState)), + } = options; + + this.duplicateEntityPolicy = duplicateEntityPolicy; + this.resolveDuplicateEntity = resolveDuplicateEntity; + this.resolveTargetSlot = resolveTargetSlot; + this.state = state; + } + + setActiveView: LayoutControllerApi['setActiveView'] = (next) => { + this.state.next((current) => ({ ...current, activeView: next })); + }; + + openView: LayoutControllerApi['openView'] = (view, options) => { + void options; + this.state.next((current) => ({ ...current, activeView: view })); + }; + + setAvailableSlots: LayoutControllerApi['setAvailableSlots'] = (slots) => { + this.state.next((current) => { + const activeViewState = toViewState(current); + return mergeViewState(current, { + ...activeViewState, + availableSlots: [...slots], + }); + }); + }; + + setSlotNames: LayoutControllerApi['setSlotNames'] = (slots) => { + this.state.next((current) => { + const activeViewState = toViewState(current); + return mergeViewState(current, { + ...activeViewState, + slotNames: slots?.length ? [...slots] : undefined, + }); + }); + }; + + hide: LayoutControllerApi['hide'] = (slot) => { + this.state.next((current) => { + const activeViewState = toViewState(current); + return mergeViewState(current, { + ...activeViewState, + hiddenSlots: { + ...activeViewState.hiddenSlots, + [slot]: true, + }, + }); + }); + }; + + unhide: LayoutControllerApi['unhide'] = (slot) => { + this.state.next((current) => { + const activeViewState = toViewState(current); + return mergeViewState(current, { + ...activeViewState, + hiddenSlots: { + ...activeViewState.hiddenSlots, + [slot]: false, + }, + }); + }); + }; + + setSlotBinding: LayoutControllerApi['setSlotBinding'] = (slot, binding) => { + this.state.next((current) => { + const activeViewState = toViewState(current); + const nextViewState = !binding + ? clearSlotBinding(activeViewState, slot) + : clearSlotForwardHistory( + upsertSlotBinding(activeViewState, slot, binding), + slot, + ); + + const nextListSlotByView = { ...(current.listSlotByView ?? {}) }; + if (!binding && nextListSlotByView[current.activeView] === slot) { + delete nextListSlotByView[current.activeView]; + } + + return { + ...mergeViewState(current, nextViewState), + listSlotByView: nextListSlotByView, + }; + }); + }; + + clear: LayoutControllerApi['clear'] = (slot) => { + this.setSlotBinding(slot, undefined); + }; + + goBack: LayoutControllerApi['goBack'] = (slot) => { + this.state.next((current) => { + const activeViewState = toViewState(current); + const { popped, state: nextState } = popSlotHistory(activeViewState, slot); + if (!popped) return mergeViewState(current, nextState); + + const currentBinding = nextState.slotBindings[slot]; + const withForward = currentBinding + ? pushSlotForwardHistory(nextState, slot, currentBinding) + : nextState; + + return mergeViewState(current, upsertSlotBinding(withForward, slot, popped)); + }); + }; + + goForward: LayoutControllerApi['goForward'] = (slot) => { + this.state.next((current) => { + const activeViewState = toViewState(current); + const { popped, state: nextState } = popSlotForwardHistory(activeViewState, slot); + if (!popped) return mergeViewState(current, nextState); + + const currentBinding = nextState.slotBindings[slot]; + const withBack = currentBinding + ? pushSlotHistory(nextState, slot, currentBinding) + : nextState; + + return mergeViewState(current, upsertSlotBinding(withBack, slot, popped)); + }); + }; + + openInLayout: LayoutControllerApi['openInLayout'] = (binding, openOptions) => { + const current = this.state.getLatestValue(); + const activeViewState = toViewState(current); + const targetSlot = resolveOpenTargetSlot( + current, + activeViewState, + binding, + openOptions, + this.resolveTargetSlot, + ); + + if (!targetSlot) { + return { + reason: 'no-available-slot', + status: 'rejected', + } satisfies OpenResult; + } + + const existingBindingSlot = findBindingSlot(activeViewState, binding); + + if (existingBindingSlot) { + const duplicatePolicy = + this.resolveDuplicateEntity?.({ + activeViewState, + binding, + existingSlot: existingBindingSlot, + requestedSlot: openOptions?.targetSlot, + state: current, + }) ?? this.duplicateEntityPolicy; + + if (duplicatePolicy === 'reject') { + return { + reason: 'duplicate-binding', + status: 'rejected', + } satisfies OpenResult; + } + + if (duplicatePolicy === 'move' && existingBindingSlot !== targetSlot) { + this.state.next((nextState) => + mergeViewState( + nextState, + clearSlotBinding(toViewState(nextState), existingBindingSlot), + ), + ); + } + } + + const replacedBinding = toViewState(this.state.getLatestValue()).slotBindings[ + targetSlot + ]; + + this.state.next((nextState) => { + const currentViewState = toViewState(nextState); + const currentSlotBinding = currentViewState.slotBindings[targetSlot]; + const withHistory = + currentSlotBinding && !isSameSlotBinding(currentSlotBinding, binding) + ? pushSlotHistory(currentViewState, targetSlot, currentSlotBinding) + : currentViewState; + const nextViewState = clearSlotForwardHistory( + upsertSlotBinding(withHistory, targetSlot, binding), + targetSlot, + ); + + return mergeViewState(nextState, nextViewState); + }); + + if (replacedBinding && replacedBinding !== binding) { + return { + replaced: replacedBinding, + slot: targetSlot, + status: 'replaced', + } satisfies OpenResult; + } + + return { + slot: targetSlot, + status: 'opened', + } satisfies OpenResult; + }; +} diff --git a/src/components/ChatView/layoutController/layoutControllerTypes.ts b/src/components/ChatView/layoutController/layoutControllerTypes.ts new file mode 100644 index 0000000000..b82adf5b05 --- /dev/null +++ b/src/components/ChatView/layoutController/layoutControllerTypes.ts @@ -0,0 +1,145 @@ +import type { StateStore } from 'stream-chat'; + +import type { ChatView } from '../ChatView'; + +export type SlotName = string; +export type LayoutView = ChatView; + +export type LayoutSlotBinding = { + key?: string; + payload: unknown; +}; + +export type LayoutSlotMeta = { + occupiedAt?: number; +}; + +export type ChatViewLayoutState = { + activeView: ChatView; + maxSlots?: number; + minSlots?: number; + listSlotByView?: Partial>; + hiddenSlotsByView?: Partial>>; + slotBindingsByView?: Partial< + Record> + >; + slotHistoryByView?: Partial< + Record> + >; + slotForwardHistoryByView?: Partial< + Record> + >; + slotMetaByView?: Partial< + Record> + >; + slotNamesByView?: Partial>; + availableSlotsByView?: Partial>; +}; + +export type ChatViewLayoutViewState = { + availableSlots: SlotName[]; + hiddenSlots: Record; + slotBindings: Record; + slotHistory: Record; + slotForwardHistory: Record; + slotMeta: Record; + slotNames?: SlotName[]; +}; + +export type ResolveTargetSlotArgs = { + activeViewState: ChatViewLayoutViewState; + binding: LayoutSlotBinding; + requestedSlot?: SlotName; + state: ChatViewLayoutState; +}; + +export type DuplicateEntityPolicy = 'allow' | 'move' | 'reject'; + +export type ResolveDuplicateEntityArgs = { + activeViewState: ChatViewLayoutViewState; + binding: LayoutSlotBinding; + existingSlot: SlotName; + requestedSlot?: SlotName; + state: ChatViewLayoutState; +}; + +export type ResolveDuplicateEntity = ( + args: ResolveDuplicateEntityArgs, +) => DuplicateEntityPolicy; + +export type ResolveTargetSlot = (args: ResolveTargetSlotArgs) => SlotName | null; + +export type OpenResult = + | { slot: SlotName; status: 'opened' } + | { replaced: LayoutSlotBinding; slot: SlotName; status: 'replaced' } + | { reason: 'duplicate-binding' | 'no-available-slot'; status: 'rejected' }; + +export type OpenOptions = { + targetSlot?: SlotName; +}; + +export type OpenViewOptions = { + slot?: SlotName; +}; + +export type SerializedLayoutSlotBinding = { + key?: string; + payload: unknown; +}; + +export type ChatViewLayoutSnapshot = { + activeView: LayoutView; + availableSlotsByView: Partial>; + hiddenSlotsByView: Partial>>; + listSlotByView?: Partial>; + slotBindingsByView: Partial< + Record> + >; + slotHistoryByView: Partial< + Record> + >; + slotForwardHistoryByView: Partial< + Record> + >; + slotMetaByView: Partial>>; + slotNamesByView: Partial>; +}; + +export type SerializeLayoutEntityBinding = ( + binding: LayoutSlotBinding, +) => SerializedLayoutSlotBinding | undefined; + +export type DeserializeLayoutEntityBinding = ( + binding: SerializedLayoutSlotBinding, +) => LayoutSlotBinding | undefined; + +export type SerializeLayoutStateOptions = { + serializeEntityBinding?: SerializeLayoutEntityBinding; +}; + +export type RestoreLayoutStateOptions = { + deserializeEntityBinding?: DeserializeLayoutEntityBinding; +}; + +export type CreateLayoutControllerOptions = { + duplicateEntityPolicy?: DuplicateEntityPolicy; + initialState?: Partial; + resolveDuplicateEntity?: ResolveDuplicateEntity; + resolveTargetSlot?: ResolveTargetSlot; + state?: StateStore; +}; + +export type LayoutController = { + setSlotBinding: (slot: SlotName, binding?: LayoutSlotBinding) => void; + setAvailableSlots: (slots: SlotName[]) => void; + setSlotNames: (slots?: SlotName[]) => void; + clear: (slot: SlotName) => void; + goBack: (slot: SlotName) => void; + goForward: (slot: SlotName) => void; + hide: (slot: SlotName) => void; + openInLayout: (binding: LayoutSlotBinding, options?: OpenOptions) => OpenResult; + openView: (view: LayoutView, options?: OpenViewOptions) => void; + setActiveView: (next: ChatView) => void; + state: StateStore; + unhide: (slot: SlotName) => void; +}; diff --git a/src/components/ChatView/layoutController/serialization.ts b/src/components/ChatView/layoutController/serialization.ts new file mode 100644 index 0000000000..5b8da8fe50 --- /dev/null +++ b/src/components/ChatView/layoutController/serialization.ts @@ -0,0 +1,218 @@ +import type { + ChatViewLayoutSnapshot, + ChatViewLayoutState, + DeserializeLayoutEntityBinding, + RestoreLayoutStateOptions, + SerializeLayoutEntityBinding, + SerializeLayoutStateOptions, +} from './layoutControllerTypes'; +import type { + LayoutController, + SerializedLayoutSlotBinding, +} from './layoutControllerTypes'; +import type { ChatView } from '../ChatView'; + +type SlotBindingsByViewState = NonNullable; +type SlotHistoryByViewState = NonNullable; +type SlotForwardHistoryByViewState = NonNullable< + ChatViewLayoutState['slotForwardHistoryByView'] +>; + +const defaultSerializeEntityBinding: SerializeLayoutEntityBinding = (binding) => ({ + key: binding.key, + payload: binding.payload, +}); + +const defaultDeserializeEntityBinding: DeserializeLayoutEntityBinding = (binding) => + binding; + +const serializeBindingsByView = ({ + bindingsByView, + serializeEntityBinding, +}: { + bindingsByView: ChatViewLayoutState['slotBindingsByView']; + serializeEntityBinding: SerializeLayoutEntityBinding; +}) => + Object.entries(bindingsByView ?? {}).reduce< + ChatViewLayoutSnapshot['slotBindingsByView'] + >((next, [view, bindings]) => { + next[view as ChatView] = Object.entries(bindings ?? {}).reduce< + Record + >((acc, [slot, binding]) => { + acc[slot] = binding ? serializeEntityBinding(binding) : undefined; + return acc; + }, {}); + return next; + }, {}); + +const deserializeBindingsByView = ({ + bindingsByView, + deserializeEntityBinding, +}: { + bindingsByView: ChatViewLayoutSnapshot['slotBindingsByView']; + deserializeEntityBinding: DeserializeLayoutEntityBinding; +}) => + Object.entries(bindingsByView ?? {}).reduce( + (next, [view, bindings]) => { + next[view as ChatView] = Object.entries(bindings ?? {}).reduce< + Record | undefined> + >((acc, [slot, binding]) => { + acc[slot] = binding ? deserializeEntityBinding(binding) : undefined; + return acc; + }, {}); + return next; + }, + {}, + ); + +const serializeHistoryByView = ({ + historyByView, + serializeEntityBinding, +}: { + historyByView: + | ChatViewLayoutState['slotHistoryByView'] + | ChatViewLayoutState['slotForwardHistoryByView']; + serializeEntityBinding: SerializeLayoutEntityBinding; +}) => + Object.entries(historyByView ?? {}).reduce( + (next, [view, history]) => { + next[view as ChatView] = Object.entries(history ?? {}).reduce< + Record + >((acc, [slot, entities]) => { + if (!entities?.length) { + acc[slot] = undefined; + return acc; + } + const serializedEntities = entities + .map(serializeEntityBinding) + .filter((binding): binding is SerializedLayoutSlotBinding => !!binding); + acc[slot] = serializedEntities.length ? serializedEntities : undefined; + return acc; + }, {}); + return next; + }, + {}, + ); + +const deserializeHistoryByView = ({ + deserializeEntityBinding, + historyByView, +}: { + historyByView: + | ChatViewLayoutSnapshot['slotHistoryByView'] + | ChatViewLayoutSnapshot['slotForwardHistoryByView']; + deserializeEntityBinding: DeserializeLayoutEntityBinding; +}) => + Object.entries(historyByView ?? {}).reduce( + (next, [view, history]) => { + next[view as ChatView] = Object.entries(history ?? {}).reduce< + Record< + string, + NonNullable>[] | undefined + > + >((acc, [slot, entities]) => { + if (!entities?.length) { + acc[slot] = undefined; + return acc; + } + const deserializedEntities = entities + .map(deserializeEntityBinding) + .filter((binding): binding is NonNullable => !!binding); + acc[slot] = deserializedEntities.length ? deserializedEntities : undefined; + return acc; + }, {}); + return next; + }, + {}, + ); + +export const serializeLayoutState = ( + state: ChatViewLayoutState, + options: SerializeLayoutStateOptions = {}, +): ChatViewLayoutSnapshot => { + const serializeEntityBinding = + options.serializeEntityBinding ?? defaultSerializeEntityBinding; + + return { + activeView: state.activeView, + availableSlotsByView: { + ...(state.availableSlotsByView ?? {}), + }, + hiddenSlotsByView: { + ...(state.hiddenSlotsByView ?? {}), + }, + listSlotByView: { + ...(state.listSlotByView ?? {}), + }, + slotBindingsByView: serializeBindingsByView({ + bindingsByView: state.slotBindingsByView, + serializeEntityBinding, + }), + slotForwardHistoryByView: serializeHistoryByView({ + historyByView: state.slotForwardHistoryByView, + serializeEntityBinding, + }), + slotHistoryByView: serializeHistoryByView({ + historyByView: state.slotHistoryByView, + serializeEntityBinding, + }), + slotMetaByView: { + ...(state.slotMetaByView ?? {}), + }, + slotNamesByView: { + ...(state.slotNamesByView ?? {}), + }, + }; +}; + +export const restoreLayoutState = ( + snapshot: ChatViewLayoutSnapshot, + options: RestoreLayoutStateOptions = {}, +): ChatViewLayoutState => { + const deserializeEntityBinding = + options.deserializeEntityBinding ?? defaultDeserializeEntityBinding; + + return { + activeView: snapshot.activeView, + availableSlotsByView: { + ...(snapshot.availableSlotsByView ?? {}), + }, + hiddenSlotsByView: { + ...(snapshot.hiddenSlotsByView ?? {}), + }, + listSlotByView: { + ...(snapshot.listSlotByView ?? {}), + }, + slotBindingsByView: deserializeBindingsByView({ + bindingsByView: snapshot.slotBindingsByView, + deserializeEntityBinding, + }), + slotForwardHistoryByView: deserializeHistoryByView({ + deserializeEntityBinding, + historyByView: snapshot.slotForwardHistoryByView, + }) as SlotForwardHistoryByViewState, + slotHistoryByView: deserializeHistoryByView({ + deserializeEntityBinding, + historyByView: snapshot.slotHistoryByView, + }) as SlotHistoryByViewState, + slotMetaByView: { + ...(snapshot.slotMetaByView ?? {}), + }, + slotNamesByView: { + ...(snapshot.slotNamesByView ?? {}), + }, + }; +}; + +export const serializeLayoutControllerState = ( + controller: LayoutController, + options?: SerializeLayoutStateOptions, +) => serializeLayoutState(controller.state.getLatestValue(), options); + +export const restoreLayoutControllerState = ( + controller: LayoutController, + snapshot: ChatViewLayoutSnapshot, + options?: RestoreLayoutStateOptions, +) => { + controller.state.next(() => restoreLayoutState(snapshot, options)); +}; diff --git a/src/components/ChatView/layoutSlotResolvers.ts b/src/components/ChatView/layoutSlotResolvers.ts new file mode 100644 index 0000000000..185f0b216b --- /dev/null +++ b/src/components/ChatView/layoutSlotResolvers.ts @@ -0,0 +1,117 @@ +import type { + ResolveTargetSlot, + ResolveTargetSlotArgs, + SlotName, +} from './layoutController/layoutControllerTypes'; + +export type SlotResolver = (args: ResolveTargetSlotArgs) => SlotName | null; + +export const requestedSlotResolver: SlotResolver = ({ requestedSlot }) => + requestedSlot ?? null; + +export const firstFree: SlotResolver = ({ activeViewState }) => + activeViewState.availableSlots.find((slot) => !activeViewState.slotBindings[slot]) ?? + null; + +const readBindingKind = (binding: ResolveTargetSlotArgs['binding'] | undefined) => { + const payload = binding?.payload as { kind?: unknown } | undefined; + return typeof payload?.kind === 'string' ? payload.kind : undefined; +}; + +export const existingThreadSlotForThread: SlotResolver = ({ + activeViewState, + binding, +}) => { + if (readBindingKind(binding) !== 'thread') return null; + + return ( + activeViewState.availableSlots.find( + (slot) => readBindingKind(activeViewState.slotBindings[slot]) === 'thread', + ) ?? null + ); +}; + +export const existingThreadSlotForChannel: SlotResolver = ({ + activeViewState, + binding, +}) => { + if (readBindingKind(binding) !== 'channel') return null; + + return ( + activeViewState.availableSlots.find( + (slot) => readBindingKind(activeViewState.slotBindings[slot]) === 'thread', + ) ?? null + ); +}; + +export const existingChannelSlotForChannel: SlotResolver = ({ + activeViewState, + binding, +}) => { + if (readBindingKind(binding) !== 'channel') return null; + + return ( + activeViewState.availableSlots.find( + (slot) => readBindingKind(activeViewState.slotBindings[slot]) === 'channel', + ) ?? null + ); +}; + +export const earliestOccupied: SlotResolver = ({ activeViewState }) => { + const occupiedSlots = activeViewState.availableSlots + .map((slot) => ({ + occupiedAt: activeViewState.slotMeta[slot]?.occupiedAt ?? Number.POSITIVE_INFINITY, + slot, + })) + .filter(({ occupiedAt }) => occupiedAt !== Number.POSITIVE_INFINITY) + .sort((first, second) => first.occupiedAt - second.occupiedAt); + + return occupiedSlots[0]?.slot ?? null; +}; + +export const activeOrLast: SlotResolver = ({ activeViewState }) => + activeViewState.availableSlots[activeViewState.availableSlots.length - 1] ?? null; + +export const replaceActive: SlotResolver = ({ activeViewState }) => + activeViewState.availableSlots[activeViewState.availableSlots.length - 1] ?? null; + +export const replaceLast: SlotResolver = ({ activeViewState }) => + activeViewState.availableSlots[activeViewState.availableSlots.length - 1] ?? null; + +export const rejectWhenFull: SlotResolver = () => null; + +export const composeResolvers = + (...resolvers: SlotResolver[]): ResolveTargetSlot => + (args) => { + for (const resolver of resolvers) { + const slot = resolver(args); + if (slot) return slot; + } + + return null; + }; + +export const resolveTargetSlotChannelDefault = composeResolvers( + requestedSlotResolver, + firstFree, + existingThreadSlotForThread, + existingThreadSlotForChannel, + existingChannelSlotForChannel, + earliestOccupied, + activeOrLast, +); + +export const layoutSlotResolvers = { + activeOrLast, + composeResolvers, + earliestOccupied, + existingChannelSlotForChannel, + existingThreadSlotForChannel, + existingThreadSlotForThread, + firstFree, + rejectWhenFull, + replaceActive, + replaceLast, + requestedSlotResolver, + resolveTargetSlotChannelDefault, +} as const; diff --git a/src/components/ChatView/styling/ChatView.scss b/src/components/ChatView/styling/ChatView.scss index 3cfd31ef93..a16cd40151 100644 --- a/src/components/ChatView/styling/ChatView.scss +++ b/src/components/ChatView/styling/ChatView.scss @@ -56,10 +56,56 @@ .str-chat__chat-view__channels { display: flex; flex-grow: 1; + min-height: 0; } .str-chat__chat-view__threads { display: flex; flex-grow: 1; + min-height: 0; } } + +.str-chat__chat-view__workspace-layout { + display: flex; + width: 100%; + height: 100%; + min-width: 0; +} + +.str-chat__chat-view__workspace-layout-nav-rail { + display: flex; + flex-shrink: 0; +} + +.str-chat__chat-view__workspace-layout-entity-list-pane { + display: flex; + min-width: 0; +} + +.str-chat__chat-view__workspace-layout-slots { + display: flex; + flex: 1 1 auto; + min-width: 0; +} + +.str-chat__chat-view__workspace-layout-slots--empty { + justify-content: center; +} + +.str-chat__chat-view__workspace-layout-slot { + display: flex; + flex: 1 1 0; + min-width: 0; +} + +.str-chat__chat-view__slot { + display: flex; + height: 100%; + min-height: 0; + min-width: 0; +} + +.str-chat__chat-view__slot--hidden { + display: none; +} diff --git a/src/components/Gallery/__tests__/Image.test.js b/src/components/Gallery/__tests__/Image.test.js index fd1d7e2f4e..0260dea632 100644 --- a/src/components/Gallery/__tests__/Image.test.js +++ b/src/components/Gallery/__tests__/Image.test.js @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'; @@ -8,8 +8,9 @@ import { ImageComponent } from '../../Attachment/Image'; import { Chat } from '../../Chat'; import { Channel } from '../../Channel'; -import { useChatContext, WithComponents } from '../../../context'; +import { WithComponents } from '../../../context'; import { ComponentProvider } from '../../../context/ComponentContext'; +import { ModalDialogManagerProvider } from '../../../context/DialogManagerContext'; import { initClientWithChannels } from '../../../mock-builders'; @@ -19,12 +20,14 @@ describe('Image', () => { afterEach(cleanup); it('should render component with default props', () => { - const { container } = render( - - - , + const { getByTestId } = render( + + + + + , ); - expect(container).toMatchSnapshot(); + expect(getByTestId('str-chat__base-image')).toBeInTheDocument(); }); describe('it should prevent unsafe image uri protocols in the rendered image src', () => { @@ -32,65 +35,75 @@ describe('Image', () => { // eslint-disable-next-line no-script-url const xssJavascriptUri = 'javascript:alert("p0wn3d")'; const { getByTestId } = render( - - - , + + + + + , + ); + expect(getByTestId('str-chat__base-image')).not.toHaveAttribute( + 'src', + xssJavascriptUri, ); - expect(getByTestId('image-test')).not.toHaveAttribute('src', xssJavascriptUri); }); it('should prevent javascript protocol in thumbnail src', () => { // eslint-disable-next-line no-script-url const xssJavascriptUri = 'javascript:alert("p0wn3d")'; const { getByTestId } = render( - - - , + + + + + , + ); + expect(getByTestId('str-chat__base-image')).not.toHaveAttribute( + 'src', + xssJavascriptUri, ); - expect(getByTestId('image-test')).not.toHaveAttribute('src', xssJavascriptUri); }); it('should prevent dataUris in image src', () => { const xssDataUri = 'data:image/svg+xml;base64,DANGEROUSENCODEDSVG'; const { getByTestId } = render( - - - , + + + + + , ); - expect(getByTestId('image-test')).not.toHaveAttribute('src', xssDataUri); + expect(getByTestId('str-chat__base-image')).not.toHaveAttribute('src', xssDataUri); }); it('should prevent dataUris in thumb src', () => { const xssDataUri = 'data:image/svg+xml;base64,DANGEROUSENCODEDSVG'; const { getByTestId } = render( - - - , + + + + + , ); - expect(getByTestId('image-test')).not.toHaveAttribute('src', xssDataUri); + expect(getByTestId('str-chat__base-image')).not.toHaveAttribute('src', xssDataUri); }); }); it('should open modal on image click', async () => { jest.spyOn(console, 'warn').mockImplementation(() => null); - const { getByTestId, getByTitle } = render( - - - , + const { container, getByRole } = render( + + + + + , ); - fireEvent.click(getByTestId('image-test')); + fireEvent.click(getByRole('button', { name: 'Open image in gallery' })); await waitFor(() => { - expect(getByTitle('Close')).toBeInTheDocument(); + expect( + container.querySelector('.str-chat__modal__overlay__close-button'), + ).toBeInTheDocument(); }); }); it('should render custom BaseImage component', async () => { - const ActiveChannelSetter = ({ activeChannel }) => { - const { setActiveChannel } = useChatContext(); - useEffect(() => { - setActiveChannel(activeChannel); - }, [activeChannel]); // eslint-disable-line - return null; - }; - const { channels: [channel], client, @@ -103,8 +116,7 @@ describe('Image', () => { result = render( - - + { , ); }); - expect(result.container).toMatchInlineSnapshot(` -
    -
    -
    - fallback -
    -
    -
    - `); + expect(result.getByTestId('str-chat__base-image')).toBeInTheDocument(); }); }); diff --git a/src/components/Gallery/__tests__/__snapshots__/Image.test.js.snap b/src/components/Gallery/__tests__/__snapshots__/Image.test.js.snap deleted file mode 100644 index cadf9a20e6..0000000000 --- a/src/components/Gallery/__tests__/__snapshots__/Image.test.js.snap +++ /dev/null @@ -1,12 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Image should render component with default props 1`] = ` -
    - -
    -`; diff --git a/src/components/Icons/BaseIcon.tsx b/src/components/Icons/BaseIcon.tsx index daeebfd574..4b116a46e1 100644 --- a/src/components/Icons/BaseIcon.tsx +++ b/src/components/Icons/BaseIcon.tsx @@ -1,4 +1,4 @@ -import type { ComponentProps } from 'react'; +import React, { type ComponentProps } from 'react'; import clsx from 'clsx'; export const BaseIcon = ({ className, ...props }: ComponentProps<'svg'>) => ( diff --git a/src/components/Icons/createIcon.tsx b/src/components/Icons/createIcon.tsx index 3fd7067b82..3f7eaa1fba 100644 --- a/src/components/Icons/createIcon.tsx +++ b/src/components/Icons/createIcon.tsx @@ -1,4 +1,4 @@ -import { type ComponentProps, type ReactNode } from 'react'; +import React, { type ComponentProps, type ReactNode } from 'react'; import clsx from 'clsx'; import { BaseIcon } from './BaseIcon'; diff --git a/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx b/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx index 7457a1b69c..4118d5469f 100644 --- a/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx +++ b/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx @@ -1,6 +1,7 @@ import clsx from 'clsx'; import debounce from 'lodash.debounce'; import type { PropsWithChildren } from 'react'; +import { forwardRef } from 'react'; import React, { useEffect, useMemo, useRef } from 'react'; import { DEFAULT_LOAD_PAGE_SCROLL_THRESHOLD } from '../../constants/limits'; @@ -14,7 +15,8 @@ const mousewheelListener = (event: Event) => { } }; -export type InfiniteScrollPaginatorProps = React.ComponentProps<'div'> & { +type InfiniteScrollPaginatorOwnProps = { + element?: React.ElementType; listenToScroll?: ( distanceFromBottom: number, distanceFromTop: number, @@ -28,12 +30,39 @@ export type InfiniteScrollPaginatorProps = React.ComponentProps<'div'> & { useCapture?: boolean; }; -export const InfiniteScrollPaginator = ( - props: PropsWithChildren, -) => { +// helper: get the right ref type for a given element/component +type PolymorphicRef = React.ComponentPropsWithRef['ref']; + +// polymorphic props, defaulting to 'div' +export type InfiniteScrollPaginatorProps = + PropsWithChildren< + InfiniteScrollPaginatorOwnProps & { + element?: C; + } & Omit< + React.ComponentPropsWithRef, + keyof InfiniteScrollPaginatorOwnProps | 'element' + > + >; + +type InfiniteScrollPaginatorComponent = ( + props: InfiniteScrollPaginatorProps & { + ref?: PolymorphicRef; + }, +) => React.ReactNode; + +const renderPolymorphic = ( + Comp: C, + props: React.ComponentPropsWithRef & { ref?: PolymorphicRef }, + children?: React.ReactNode, +) => React.createElement(Comp, props, children); + +export const InfiniteScrollPaginator = forwardRef(function InfiniteScrollPaginator< + E extends React.ElementType = 'div', +>(props: InfiniteScrollPaginatorProps, ref: React.ForwardedRef) { const { children, className, + element: Component = 'div' as E, listenToScroll, loadNextDebounceMs = 500, loadNextOnScrollToBottom, @@ -43,7 +72,7 @@ export const InfiniteScrollPaginator = ( ...componentProps } = props; - const rootRef = useRef(null); + const rootRef = useRef(null); const childRef = useRef(null); const scrollListener = useMemo( @@ -114,15 +143,24 @@ export const InfiniteScrollPaginator = ( }; }, [useCapture]); - return ( -
    -
    - {children} -
    -
    + return renderPolymorphic( + Component as E, + { + ...(componentProps as React.ComponentPropsWithRef), + className: clsx('str-chat__infinite-scroll-paginator', className), + ref: (node: React.ComponentRef | null) => { + if (typeof ref === 'function') { + ref(node); + } else if (ref && 'current' in ref) { + (ref as React.RefObject | null>).current = node; + } + rootRef.current = node && node instanceof HTMLElement ? node : null; + }, + }, + React.createElement( + 'div', + { className: 'str-chat__infinite-scroll-paginator__content', ref: childRef }, + children, + ), ); -}; +}) as InfiniteScrollPaginatorComponent; diff --git a/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecorder.test.js b/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecorder.test.js index 31e2540b21..d542a5c40a 100644 --- a/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecorder.test.js +++ b/src/components/MediaRecorder/AudioRecorder/__tests__/AudioRecorder.test.js @@ -5,8 +5,7 @@ import * as transcoder from '../../transcode'; import { MessageInput, MessageInputFlat } from '../../../MessageInput'; import { - ChannelActionProvider, - ChannelStateProvider, + ChannelInstanceProvider, ChatProvider, ComponentProvider, MessageInputContextProvider, @@ -46,9 +45,6 @@ const AUDIO_RECORDER_TEST_ID = 'audio-recorder'; const AUDIO_RECORDER_COMPLETE_BTN_TEST_ID = 'audio-recorder-complete-button'; const DEFAULT_RENDER_PARAMS = { - channelActionCtx: { - addNotification: jest.fn(), - }, channelStateCtx: { channelCapabilities: [], }, @@ -66,16 +62,31 @@ jest .mockReturnValue({ width: 120 }); const renderComponent = async ({ - channelActionCtx, channelStateCtx, chatCtx, componentCtx, props, } = {}) => { - const { - channels: [channel], - client, - } = await initClientWithChannels(); + const { channel: providedChannel, channelCapabilities } = channelStateCtx ?? {}; + let channel; + let client; + if (providedChannel && chatCtx?.client) { + channel = providedChannel; + client = chatCtx.client; + } else { + const initResult = await initClientWithChannels(); + channel = providedChannel ?? initResult.channels[0]; + client = chatCtx?.client ?? initResult.client; + } + const ownCapabilities = Array.isArray(channelCapabilities) + ? channelCapabilities + : Object.entries(channelCapabilities ?? {}) + .filter(([, isAllowed]) => isAllowed) + .map(([capability]) => capability); + if (ownCapabilities) { + channel.data.own_capabilities = ownCapabilities; + channel.state.ownCapabilitiesStore?.next?.({ ownCapabilities }); + } let result; await act(async () => { result = await render( @@ -89,19 +100,9 @@ const renderComponent = async ({ - - - - - + + + , ); @@ -424,17 +425,15 @@ const DEFAULT_RECORDING_CONTROLLER = { const renderAudioRecorder = (controller = {}) => render( - - - - - - - , + + + + + , ); describe('AudioRecorder', () => { diff --git a/src/components/MediaRecorder/hooks/useMediaRecorder.ts b/src/components/MediaRecorder/hooks/useMediaRecorder.ts index 6438dc2b35..fd85714b78 100644 --- a/src/components/MediaRecorder/hooks/useMediaRecorder.ts +++ b/src/components/MediaRecorder/hooks/useMediaRecorder.ts @@ -6,6 +6,7 @@ import { useMessageComposer } from '../../MessageInput'; import type { LocalVoiceRecordingAttachment } from 'stream-chat'; import type { CustomAudioRecordingConfig, MediaRecordingState } from '../classes'; import type { MessageInputContextValue } from '../../../context'; +import { useSendMessageFn } from '../../MessageInput/hooks/useSendMessageFn'; export type RecordingController = { completeRecording: () => void; @@ -17,7 +18,7 @@ export type RecordingController = { type UseMediaRecorderParams = Pick< MessageInputContextValue, - 'asyncMessagesMultiSendEnabled' | 'handleSubmit' + 'asyncMessagesMultiSendEnabled' > & { enabled: boolean; generateRecordingTitle?: (mimeType: string) => string; @@ -28,15 +29,15 @@ export const useMediaRecorder = ({ asyncMessagesMultiSendEnabled, enabled, generateRecordingTitle, - handleSubmit, recordingConfig, }: UseMediaRecorderParams): RecordingController => { const { t } = useTranslationContext('useMediaRecorder'); const messageComposer = useMessageComposer(); + const sendMessageFn = useSendMessageFn(); const [recording, setRecording] = useState(); const [recordingState, setRecordingState] = useState(); const [permissionState, setPermissionState] = useState(); - const [isScheduledForSubmit, scheduleForSubmit] = useState(false); + // const [isScheduledForSubmit, scheduleForSubmit] = useState(false); const recorder = useMemo( () => @@ -56,17 +57,18 @@ export const useMediaRecorder = ({ if (!recording) return; await messageComposer.attachmentManager.uploadAttachment(recording); if (!asyncMessagesMultiSendEnabled) { - // FIXME: cannot call handleSubmit() directly as the function has stale reference to attachments - scheduleForSubmit(true); + await sendMessageFn(); + // // FIXME: cannot call handleSubmit() directly as the function has stale reference to attachments + // scheduleForSubmit(true); } recorder.cleanUp(); - }, [asyncMessagesMultiSendEnabled, messageComposer, recorder]); + }, [asyncMessagesMultiSendEnabled, messageComposer, recorder, sendMessageFn]); - useEffect(() => { - if (!isScheduledForSubmit) return; - handleSubmit(); - scheduleForSubmit(false); - }, [handleSubmit, isScheduledForSubmit]); + // useEffect(() => { + // if (!isScheduledForSubmit) return; + // handleSubmit(); + // scheduleForSubmit(false); + // }, [handleSubmit, isScheduledForSubmit]); useEffect(() => { if (!recorder) return; diff --git a/src/components/Message/Message.tsx b/src/components/Message/Message.tsx index 515d1c0309..986bc59c19 100644 --- a/src/components/Message/Message.tsx +++ b/src/components/Message/Message.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback } from 'react'; import { useActionHandler, @@ -7,11 +7,9 @@ import { useMarkUnreadHandler, useMentionsHandler, useMuteHandler, - useOpenThreadHandler, usePinHandler, useReactionHandler, useReactionsFetcher, - useRetryHandler, useUserHandler, useUserRole, } from './hooks'; @@ -20,22 +18,18 @@ import { areMessagePropsEqual, getMessageActions, MESSAGE_ACTIONS } from './util import type { MessageContextValue } from '../../context'; import { MessageProvider, - useChannelActionContext, - useChannelStateContext, + useChannel, useChatContext, useComponentContext, useMessageTranslationViewContext, } from '../../context'; +import { useChannelConfig } from '../Channel/hooks/useChannelConfig'; import { MessageSimple as DefaultMessage } from './MessageSimple'; import type { MessageProps } from './types'; -type MessagePropsToOmit = - | 'onMentionsClick' - | 'onMentionsHover' - | 'openThread' - | 'retrySendMessage'; +type MessagePropsToOmit = 'onMentionsClick' | 'onMentionsHover' | 'retrySendMessage'; type MessageContextPropsToPick = | 'handleAction' @@ -44,11 +38,8 @@ type MessageContextPropsToPick = | 'handleFlag' | 'handleMarkUnread' | 'handleMute' - | 'handleOpenThread' | 'handlePin' | 'handleReaction' - | 'handleRetry' - | 'mutes' | 'onMentionsClickMessage' | 'onMentionsHoverMessage' | 'reactionDetailsSort' @@ -57,13 +48,11 @@ type MessageContextPropsToPick = type MessageWithContextProps = Omit & Pick & { - canPin: boolean; userRoles: ReturnType; }; const MessageWithContext = (props: MessageWithContextProps) => { const { - canPin, Message: propMessage, message, messageActions = Object.keys(MESSAGE_ACTIONS), @@ -72,8 +61,9 @@ const MessageWithContext = (props: MessageWithContextProps) => { userRoles, } = props; - const { client, isMessageAIGenerated } = useChatContext('Message'); - const { channelConfig, read } = useChannelStateContext('Message'); + const channel = useChannel(); + const { isMessageAIGenerated } = useChatContext('Message'); + const channelConfig = useChannelConfig({ cid: channel.cid }); const { Message: contextMessage } = useComponentContext('Message'); const { getTranslationView, setTranslationView: setTranslationViewInContext } = useMessageTranslationViewContext(); @@ -98,26 +88,13 @@ const MessageWithContext = (props: MessageWithContextProps) => { canFlag, canMarkUnread, canMute, + canPin, canQuote, canReact, canReply, isMyMessage, } = userRoles; - const messageIsUnread = useMemo( - () => - !!( - !isMyMessage && - client.user?.id && - read && - (!read[client.user.id] || - (message?.created_at && - new Date(message.created_at).getTime() > - read[client.user.id].last_read.getTime())) - ), - [client, isMyMessage, message.created_at, read], - ); - const messageActionsHandler = useCallback( () => getMessageActions( @@ -152,7 +129,6 @@ const MessageWithContext = (props: MessageWithContextProps) => { ); const { - canPin: canPinPropToNotPass, // eslint-disable-line @typescript-eslint/no-unused-vars messageActions: messageActionsPropToNotPass, // eslint-disable-line @typescript-eslint/no-unused-vars onlySenderCanEdit: onlySenderCanEditPropToNotPass, // eslint-disable-line @typescript-eslint/no-unused-vars onUserClick: onUserClickPropToNotPass, // eslint-disable-line @typescript-eslint/no-unused-vars @@ -167,7 +143,6 @@ const MessageWithContext = (props: MessageWithContextProps) => { getMessageActions: messageActionsHandler, isMessageAIGenerated, isMyMessage: () => isMyMessage, - messageIsUnread, onUserClick, onUserHover, setTranslationView, @@ -207,49 +182,58 @@ export const Message = (props: MessageProps) => { onlySenderCanEdit = false, onMentionsClick: propOnMentionsClick, onMentionsHover: propOnMentionsHover, - openThread: propOpenThread, - pinPermissions, reactionDetailsSort, - retrySendMessage: propRetrySendMessage, sortReactionDetails, sortReactions, } = props; - const { addNotification } = useChannelActionContext('Message'); - const { highlightedMessageId, mutes } = useChannelStateContext('Message'); - + const channel = useChannel(); + const notify = useCallback( + (text: string, type: 'success' | 'error') => { + if (type === 'error') { + channel.getClient().notifications.addError({ + message: text, + origin: { emitter: 'Message' }, + }); + } else { + channel.getClient().notifications.addSuccess({ + message: text, + origin: { emitter: 'Message' }, + }); + } + }, + [channel], + ); const handleAction = useActionHandler(message); - const handleOpenThread = useOpenThreadHandler(message, propOpenThread); const handleReaction = useReactionHandler(message); - const handleRetry = useRetryHandler(propRetrySendMessage); const userRoles = useUserRole(message, onlySenderCanEdit, disableQuotedMessages); const handleFetchReactions = useReactionsFetcher(message, { getErrorNotification: getFetchReactionsErrorNotification, - notify: addNotification, + notify, }); const handleDelete = useDeleteHandler(message, { getErrorNotification: getDeleteMessageErrorNotification, - notify: addNotification, + notify, }); const handleFlag = useFlagHandler(message, { getErrorNotification: getFlagMessageErrorNotification, getSuccessNotification: getFlagMessageSuccessNotification, - notify: addNotification, + notify, }); const handleMarkUnread = useMarkUnreadHandler(message, { getErrorNotification: getMarkMessageUnreadErrorNotification, getSuccessNotification: getMarkMessageUnreadSuccessNotification, - notify: addNotification, + notify, }); const handleMute = useMuteHandler(message, { getErrorNotification: getMuteUserErrorNotification, getSuccessNotification: getMuteUserSuccessNotification, - notify: addNotification, + notify, }); const { onMentionsClick, onMentionsHover } = useMentionsHandler(message, { @@ -257,18 +241,15 @@ export const Message = (props: MessageProps) => { onMentionsHover: propOnMentionsHover, }); - const { canPin, handlePin } = usePinHandler(message, pinPermissions, { + const { handlePin } = usePinHandler(message, { getErrorNotification: getPinMessageErrorNotification, - notify: addNotification, + notify, }); - const highlighted = highlightedMessageId === message.id; - return ( { handleFlag={handleFlag} handleMarkUnread={handleMarkUnread} handleMute={handleMute} - handleOpenThread={handleOpenThread} handlePin={handlePin} handleReaction={handleReaction} - handleRetry={handleRetry} - highlighted={highlighted} + highlighted={props.highlighted} initialMessage={props.initialMessage} lastOwnMessage={props.lastOwnMessage} lastReceivedId={props.lastReceivedId} @@ -292,19 +271,16 @@ export const Message = (props: MessageProps) => { Message={props.Message} messageActions={props.messageActions} messageListRect={props.messageListRect} - mutes={mutes} onMentionsClickMessage={onMentionsClick} onMentionsHoverMessage={onMentionsHover} onUserClick={props.onUserClick} onUserHover={props.onUserHover} - pinPermissions={props.pinPermissions} reactionDetailsSort={reactionDetailsSort} readBy={props.readBy} renderText={props.renderText} returnAllReadData={props.returnAllReadData} sortReactionDetails={sortReactionDetails} sortReactions={sortReactions} - threadList={props.threadList} unsafeHTML={props.unsafeHTML} userRoles={userRoles} /> diff --git a/src/components/Message/MessageAlsoSentInChannelIndicator.tsx b/src/components/Message/MessageAlsoSentInChannelIndicator.tsx index f3f8643e19..973026209b 100644 --- a/src/components/Message/MessageAlsoSentInChannelIndicator.tsx +++ b/src/components/Message/MessageAlsoSentInChannelIndicator.tsx @@ -1,14 +1,17 @@ -import React, { useEffect, useRef } from 'react'; +import React from 'react'; import { IconArrowRightUp } from '../Icons'; import { - useChannelActionContext, - useChannelStateContext, + useChannel, useChatContext, useMessageContext, useTranslationContext, } from '../../context'; -import { formatMessage, type LocalMessage } from 'stream-chat'; +import { useStateStore } from '../../store'; +import { useChatViewContext, useChatViewNavigation } from '../ChatView'; +import { useThreadContext } from '../Threads'; + +const activeViewSelector = ({ activeView }: { activeView: string }) => ({ activeView }); /** * Indicator shown when the message was also sent to the main channel (show_in_channel === true). @@ -16,73 +19,64 @@ import { formatMessage, type LocalMessage } from 'stream-chat'; export const MessageAlsoSentInChannelIndicator = () => { const { client } = useChatContext(); const { t } = useTranslationContext(); - const { channel } = useChannelStateContext(); - const { jumpToMessage, openThread } = useChannelActionContext(); - const { message, threadList } = useMessageContext('MessageAlsoSentInChannelIndicator'); - const targetMessageRef = useRef(undefined); + const channel = useChannel(); + const { openChannel, openThread } = useChatViewNavigation(); + const { layoutController } = useChatViewContext(); + const thread = useThreadContext(); + const { message } = useMessageContext('MessageAlsoSentInChannelIndicator'); + const { activeView } = useStateStore(layoutController.state, activeViewSelector) ?? { + activeView: layoutController.state.getLatestValue().activeView, + }; - const queryParent = () => - channel - .getClient() - .search({ cid: channel.cid }, { id: message.parent_id }) - .then(({ results }) => { - if (!results.length) { - throw new Error('Thread has not been found'); - } - targetMessageRef.current = formatMessage(results[0].message); - }) - .catch((error: Error) => { - client.notifications.addError({ - message: t('Thread has not been found'), - options: { - originalError: error, - type: 'api:message:search:not-found', - }, - origin: { - context: { threadReply: message }, - emitter: 'MessageIsThreadReplyInChannelButtonIndicator', - }, - }); - }); + const addThreadNotFoundNotification = (error: Error) => { + client.notifications.addError({ + message: t('Thread has not been found'), + options: { + originalError: error, + type: 'api:message:search:not-found', + }, + origin: { + context: { threadReply: message }, + emitter: 'MessageIsThreadReplyInChannelButtonIndicator', + }, + }); + }; - // todo: it is not possible to jump to a message in thread const jumpToReplyInChannelMessages = async (id: string) => { - await jumpToMessage(id); - // todo: we do not have API to control, whether thread of channel message list is show - on mobile devices important + if (activeView === 'threads') { + // todo: switching views to a specific channel will work only with ChannelListOrchestrator because it will not force us to reload the whole channel list + openChannel(channel); + // TODO: Use ChannelListOrchestrator to check whether this channel is already loaded before querying. + await channel.query({ messages: { limit: 0 } }); + } + + await channel.messagePaginator.jumpToMessage(id); }; - useEffect(() => { - if ( - targetMessageRef.current || - targetMessageRef.current === null || - !message.parent_id - ) - return; - const localMessage = channel.state.findMessage(message.parent_id); - if (localMessage) { - targetMessageRef.current = localMessage; - return; - } - }, [channel, message]); + const jumpToReplyInThread = async (replyId: string, parentId: string) => { + let targetThread = client.threads.threadsById[parentId]; - const handleClickViewReference = async () => { - if (!targetMessageRef.current) { - // search query is performed here in order to prevent multiple search queries in useEffect - // due to the message list 3x remounting its items - if (threadList) { - await jumpToReplyInChannelMessages(message.id); // we are in thread, and we want to jump to this reply in the main message list + if (!targetThread) { + try { + targetThread = await client.getThread(parentId, { watch: true }); + } catch (error) { + addThreadNotFoundNotification(error as Error); return; - } else await queryParent(); // we are in the main list and need to query the thread + } } - const target = targetMessageRef.current; - if (!target) { - // prevent further search queries if the message is not found in the DB - targetMessageRef.current = null; + + openThread(targetThread); + await targetThread.messagePaginator.jumpToMessage(replyId); + }; + + const handleClickViewReference = async () => { + if (thread) { + await jumpToReplyInChannelMessages(message.id); return; } - if (threadList) await jumpToReplyInChannelMessages(message.id); - else openThread(target); + if (!message.parent_id) return; + await jumpToReplyInThread(message.id, message.parent_id); }; if (!message?.show_in_channel) return null; @@ -90,7 +84,7 @@ export const MessageAlsoSentInChannelIndicator = () => { return (
    - {threadList ? t('Also sent in channel') : t('Replied to a thread')} + {thread ? t('Also sent in channel') : t('Replied to a thread')} · -
    diff --git a/src/components/MessageList/UnreadMessagesSeparator.tsx b/src/components/MessageList/UnreadMessagesSeparator.tsx index 5dcc158d46..ae8a2b2d71 100644 --- a/src/components/MessageList/UnreadMessagesSeparator.tsx +++ b/src/components/MessageList/UnreadMessagesSeparator.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import { useChannelActionContext, useTranslationContext } from '../../context'; +import { useChannel, useChatContext, useTranslationContext } from '../../context'; import { Button } from '../Button'; import { IconCrossMedium } from '../Icons'; +import { useMessagePaginator } from '../../hooks'; export const UNREAD_MESSAGE_SEPARATOR_CLASS = 'str-chat__unread-messages-separator'; @@ -21,7 +22,9 @@ export const UnreadMessagesSeparator = ({ unreadCount, }: UnreadMessagesSeparatorProps) => { const { t } = useTranslationContext('UnreadMessagesSeparator'); - const { markRead } = useChannelActionContext(); + const channel = useChannel(); + const { client } = useChatContext('UnreadMessagesSeparator'); + const messagePaginator = useMessagePaginator(); return (
    markRead()} + onClick={() => { + client.messageDeliveryReporter.throttledMarkRead(channel); + messagePaginator.clearUnreadSnapshot(); + }} size='sm' variant='secondary' > diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index 201b13c16e..cb898f9c73 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -49,29 +49,27 @@ import { import { DateSeparator as DefaultDateSeparator } from '../DateSeparator'; import { EventComponent as DefaultMessageSystem } from '../EventComponent'; -import { DialogManagerProvider } from '../../context'; -import type { ChannelActionContextValue } from '../../context/ChannelActionContext'; -import { useChannelActionContext } from '../../context/ChannelActionContext'; -import type { - ChannelNotifications, - ChannelStateContextValue, -} from '../../context/ChannelStateContext'; -import { useChannelStateContext } from '../../context/ChannelStateContext'; +import { DialogManagerProvider, useChannel } from '../../context'; import type { ChatContextValue } from '../../context/ChatContext'; import { useChatContext } from '../../context/ChatContext'; import type { ComponentContextValue } from '../../context/ComponentContext'; import { useComponentContext } from '../../context/ComponentContext'; import { MessageTranslationViewProvider } from '../../context/MessageTranslationViewContext'; import { VirtualizedMessageListContextProvider } from '../../context/VirtualizedMessageListContext'; +import { useStateStore } from '../../store'; +import { useThreadContext } from '../Threads'; +import { useMessagePaginator } from '../../hooks'; import type { Channel, LocalMessage, + MessageFocusSignalState, + MessagePaginatorState, ChannelState as StreamChannelState, + UnreadSnapshotState, UserResponse, } from 'stream-chat'; import type { UnknownType } from '../../types/types'; -import { DEFAULT_NEXT_CHANNEL_PAGE_SIZE } from '../../constants/limits'; import { useStableId } from '../UtilityComponents/useStableId'; import { useLastDeliveredData } from './hooks/useLastDeliveredData'; import { useLastOwnMessage } from './hooks/useLastOwnMessage'; @@ -80,7 +78,6 @@ type PropsDrilledToMessage = | 'additionalMessageInputProps' | 'formatDate' | 'messageActions' - | 'openThread' | 'reactionDetailsSort' | 'renderText' | 'showAvatar' @@ -92,11 +89,10 @@ type VirtualizedMessageListPropsForContext = | 'closeReactionSelectorOnClick' | 'customMessageRenderer' | 'head' - | 'loadingMore' + // | 'loadingMore' | 'Message' | 'returnAllReadData' - | 'shouldGroupByUser' - | 'threadList'; + | 'shouldGroupByUser'; /** * Context object provided to some Virtuoso props that are functions (components rendered by Virtuoso and other functions) @@ -109,8 +105,10 @@ export type VirtuosoContext = Required< > & Pick & Pick & { + channel: Channel; /** Latest received message id in the current channel */ lastReceivedMessageId: string | null | undefined; + loadingMore: boolean; /** Object mapping between the message ID and a string representing the position in the group of a sequence of messages posted by the same user. */ messageGroupStyles: Record; /** Number of messages prepended before the first page of messages. This is needed to calculate the virtual position in the virtual list. */ @@ -126,28 +124,33 @@ export type VirtuosoContext = Required< /** Latest own message in currently displayed message set. */ lastOwnMessage?: LocalMessage; /** Message id which was marked as unread. ALl the messages following this message are considered unrea. */ - firstUnreadMessageId?: string; - lastReadDate?: Date; + firstUnreadMessageId: string | null; + lastReadDate: Date | null; /** * The ID of the last message considered read by the current user in the current channel. * All the messages following this message are considered unread. */ - lastReadMessageId?: string; + lastReadMessageId: string | null; /** The number of unread messages in the current channel. */ - unreadMessageCount?: number; + unreadMessageCount: number; + /** Message id currently targeted by paginator focus signal. */ + focusedMessageId?: string | null; }; type VirtualizedMessageListWithContextProps = VirtualizedMessageListProps & { channel: Channel; - hasMore: boolean; - hasMoreNewer: boolean; - jumpToLatestMessage: () => Promise; - loadingMore: boolean; - loadingMoreNewer: boolean; - notifications: ChannelNotifications; + // hasMore: boolean; + // hasMoreNewer: boolean; + // jumpToLatestMessage: () => Promise; + // loadingMore: boolean; + // loadingMoreNewer: boolean; read?: StreamChannelState['read']; }; +const channelReadSelector = (nextValue: { read: StreamChannelState['read'] }) => ({ + read: nextValue.read, +}); + function captureResizeObserverExceededError(e: ErrorEvent) { if ( e.message === 'ResizeObserver loop completed with undelivered notifications.' || @@ -176,10 +179,10 @@ function findMessageIndex(messages: RenderedMessage[], id: string) { function calculateInitialTopMostItemIndex( messages: RenderedMessage[], - highlightedMessageId: string | undefined, + focusedMessageId: string | undefined, ) { - if (highlightedMessageId) { - const index = findMessageIndex(messages, highlightedMessageId); + if (focusedMessageId) { + const index = findMessageIndex(messages, focusedMessageId); if (index !== -1) { return { align: 'center', index } as const; } @@ -187,6 +190,17 @@ function calculateInitialTopMostItemIndex( return messages.length - 1; } +const unreadStateSnapshotSelector = (state: UnreadSnapshotState) => state; +const messageFocusSignalSelector = (state: MessageFocusSignalState) => ({ + messageFocusSignal: state.signal, +}); + +const messagePaginatorStateSelector = (state: MessagePaginatorState) => ({ + hasMoreNewer: state.hasMoreHead, + isLoading: state.isLoading, + messages: state.items ?? [], +}); + const VirtualizedMessageListWithContext = ( props: VirtualizedMessageListWithContextProps, ) => { @@ -194,29 +208,26 @@ const VirtualizedMessageListWithContext = ( additionalMessageInputProps, additionalVirtuosoProps = {}, channel, - channelUnreadUiState, + // channelUnreadUiState, closeReactionSelectorOnClick, customMessageRenderer, defaultItemHeight, disableDateSeparator = true, formatDate, groupStyles, - hasMoreNewer, + // hasMoreNewer, head, hideDeletedMessages = false, hideNewMessageSeparator = false, - highlightedMessageId, - jumpToLatestMessage, - loadingMore, - loadMore, - loadMoreNewer, + // jumpToLatestMessage, + // loadingMore, + // loadMore, + // loadMoreNewer, maxTimeBetweenGroupedMessages, Message: MessageUIComponentFromProps, messageActions, - messageLimit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE, - messages, - notifications, - openThread, + // messageLimit = DEFAULT_NEXT_CHANNEL_PAGE_SIZE, + // messages, // TODO: refactor to scrollSeekPlaceHolderConfiguration and components.ScrollSeekPlaceholder, like the Virtuoso Component overscan = 0, reactionDetailsSort, @@ -232,9 +243,15 @@ const VirtualizedMessageListWithContext = ( sortReactionDetails, sortReactions, stickToBottomScrollBehavior = 'smooth', - suppressAutoscroll, - threadList, + suppressAutoscroll: suppressAutoscrollFromProps = false, } = props; + const thread = useThreadContext(); + const isThreadList = !!thread; + const [suppressAutoscrollWhileLoadingOlder, setSuppressAutoscrollWhileLoadingOlder] = + React.useState(false); + const suppressAutoscroll = + suppressAutoscrollFromProps || suppressAutoscrollWhileLoadingOlder; + const loadingOlderRef = useRef(false); const { components: virtuosoComponentsFromProps, ...overridingVirtuosoProps } = additionalVirtuosoProps; @@ -258,6 +275,22 @@ const VirtualizedMessageListWithContext = ( const MessageUIComponent = MessageUIComponentFromProps || MessageUIComponentFromContext; const { client, customClasses } = useChatContext('VirtualizedMessageList'); + const messagePaginator = useMessagePaginator(); + + const { hasMoreNewer, isLoading, messages } = useStateStore( + messagePaginator.state, + messagePaginatorStateSelector, + ); + const { messageFocusSignal } = useStateStore( + messagePaginator.messageFocusSignal, + messageFocusSignalSelector, + ); + + const channelUnreadUiState = useStateStore( + messagePaginator.unreadStateSnapshot, + unreadStateSnapshotSelector, + ); + const focusedMessageId = messageFocusSignal?.messageId; const virtuoso = useRef(null); @@ -265,9 +298,7 @@ const VirtualizedMessageListWithContext = ( const { show: showUnreadMessagesNotification, toggleShowUnreadMessagesNotification } = useUnreadMessagesNotificationVirtualized({ - lastRead: channelUnreadUiState?.last_read, showAlways: !!showUnreadNotificationAlways, - unreadCount: channelUnreadUiState?.unread_messages ?? 0, }); const { giphyPreviewMessage, setGiphyPreviewMessage } = @@ -370,34 +401,30 @@ const VirtualizedMessageListWithContext = ( newMessagesNotification, setIsMessageListScrolledToBottom, setNewMessagesNotification, - } = useNewMessageNotification(processedMessages, client.userID, hasMoreNewer); + } = useNewMessageNotification(processedMessages, client.userID); useMarkRead({ isMessageListScrolledToBottom, - messageListIsThread: !!threadList, - wasMarkedUnread: !!channelUnreadUiState?.first_unread_message_id, + messageListIsThread: isThreadList, }); const scrollToBottom = useCallback(async () => { - if (hasMoreNewer) { - await jumpToLatestMessage(); - return; - } - - if (virtuoso.current) { + if (messagePaginator.hasMoreHead) { + await messagePaginator.jumpToTheLatestMessage(); + } else if (virtuoso.current) { virtuoso.current.scrollToIndex(processedMessages.length - 1); } setNewMessagesNotification(false); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ + messagePaginator, virtuoso, processedMessages, setNewMessagesNotification, // processedMessages were incorrectly rebuilt with a new object identity at some point, hence the .length usage - processedMessages.length, - hasMoreNewer, - jumpToLatestMessage, + // processedMessages.length, + // hasMoreNewer, + // jumpToLatestMessage, ]); useScrollToBottomOnNewMessage({ @@ -430,7 +457,7 @@ const VirtualizedMessageListWithContext = ( [processedMessages, toggleShowUnreadMessagesNotification], ); const followOutput = (isAtBottom: boolean) => { - if (hasMoreNewer || suppressAutoscroll) { + if (messagePaginator.hasMoreHead || suppressAutoscroll) { return false; } @@ -452,20 +479,27 @@ const VirtualizedMessageListWithContext = ( setIsMessageListScrolledToBottom(isAtBottom); if (isAtBottom) { - loadMoreNewer?.(messageLimit); + messagePaginator.toHead(); + // loadMoreNewer?.(messageLimit); setNewMessagesNotification?.(false); } }; const atTopStateChange = (isAtTop: boolean) => { if (isAtTop) { - loadMore?.(messageLimit); + if (loadingOlderRef.current) return; + loadingOlderRef.current = true; + setSuppressAutoscrollWhileLoadingOlder(true); + void messagePaginator.toTail().finally(() => { + loadingOlderRef.current = false; + setSuppressAutoscrollWhileLoadingOlder(false); + }); } }; useEffect(() => { let scrollTimeout: ReturnType; - if (highlightedMessageId) { - const index = findMessageIndex(processedMessages, highlightedMessageId); + if (focusedMessageId) { + const index = findMessageIndex(processedMessages, focusedMessageId); if (index !== -1) { scrollTimeout = setTimeout(() => { virtuoso.current?.scrollToIndex({ align: 'center', index }); @@ -475,13 +509,13 @@ const VirtualizedMessageListWithContext = ( return () => { clearTimeout(scrollTimeout); }; - }, [highlightedMessageId, processedMessages]); + }, [focusedMessageId, processedMessages]); const id = useStableId(); if (!processedMessages) return null; - const dialogManagerId = threadList + const dialogManagerId = isThreadList ? `virtualized-message-list-dialog-manager-thread-${id}` : `virtualized-message-list-dialog-manager-${id}`; @@ -490,9 +524,9 @@ const VirtualizedMessageListWithContext = ( - {!threadList && showUnreadMessagesNotification && ( + {!isThreadList && showUnreadMessagesNotification && ( )}
    0} onClick={scrollToBottom} - threadList={threadList} />
    {TypingIndicator && }
    - + {giphyPreviewMessage && }
    @@ -598,7 +634,6 @@ export type VirtualizedMessageListProps = Partial< > & { /** Additional props to be passed the underlying [`react-virtuoso` virtualized list dependency](https://virtuoso.dev/virtuoso-api-reference/) */ additionalVirtuosoProps?: VirtuosoProps; - channelUnreadUiState?: ChannelStateContextValue['channelUnreadUiState']; /** If true, picking a reaction from the `ReactionSelector` component will close the selector */ closeReactionSelectorOnClick?: boolean; /** Custom render function, if passed, certain UI props are ignored */ @@ -620,10 +655,10 @@ export type VirtualizedMessageListProps = Partial< noGroupByUser: boolean, maxTimeBetweenGroupedMessages?: number, ) => GroupStyle; - /** Whether or not the list has more items to load */ - hasMore?: boolean; - /** Whether or not the list has newer items to load */ - hasMoreNewer?: boolean; + // /** Whether or not the list has more items to load */ + // hasMore?: boolean; + // /** Whether or not the list has newer items to load */ + // hasMoreNewer?: boolean; /** * @deprecated Use additionalVirtuosoProps.components.Header to override default component rendered above the list ove messages. * Element to be rendered at the top of the thread message list. By default, these are the Message and ThreadStart components @@ -633,23 +668,21 @@ export type VirtualizedMessageListProps = Partial< hideDeletedMessages?: boolean; /** Hides the `DateSeparator` component when new messages are received in a channel that's watched but not active, defaults to false */ hideNewMessageSeparator?: boolean; - /** The id of the message to highlight and center */ - highlightedMessageId?: string; - /** Whether or not the list is currently loading more items */ - loadingMore?: boolean; - /** Whether or not the list is currently loading newer items */ - loadingMoreNewer?: boolean; - /** Function called when more messages are to be loaded, defaults to function stored in [ChannelActionContext](https://getstream.io/chat/docs/sdk/react/contexts/channel_action_context/) */ - loadMore?: ChannelActionContextValue['loadMore'] | (() => Promise); - /** Function called when new messages are to be loaded, defaults to function stored in [ChannelActionContext](https://getstream.io/chat/docs/sdk/react/contexts/channel_action_context/) */ - loadMoreNewer?: ChannelActionContextValue['loadMore'] | (() => Promise); + // /** Whether or not the list is currently loading more items */ + // loadingMore?: boolean; + // /** Whether or not the list is currently loading newer items */ + // loadingMoreNewer?: boolean; + /** Function called when more messages are to be loaded. */ + // loadMore?: () => Promise; + /** Function called when new messages are to be loaded. */ + // loadMoreNewer?: () => Promise; /** Maximum time in milliseconds that should occur between messages to still consider them grouped together */ maxTimeBetweenGroupedMessages?: number; /** Custom UI component to display a message, defaults to and accepts same props as [MessageSimple](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/MessageSimple.tsx) */ Message?: React.ComponentType; - /** The limit to use when paginating messages */ - messageLimit?: number; - /** Optional prop to override the messages available from [ChannelStateContext](https://getstream.io/chat/docs/sdk/react/contexts/channel_state_context/) */ + // /** The limit to use when paginating messages */ + // messageLimit?: number; + /** Optional prop to override the messages available from the active message paginator. */ messages?: LocalMessage[]; /** * @deprecated Use additionalVirtuosoProps.overscan instead. Will be removed with next major release - `v11.0.0`. @@ -693,10 +726,8 @@ export type VirtualizedMessageListProps = Partial< showUnreadNotificationAlways?: boolean; /** The scrollTo behavior when new messages appear. Use `"smooth"` for regular chat channels, and `"auto"` (which results in instant scroll to bottom) if you expect high throughput. */ stickToBottomScrollBehavior?: 'smooth' | 'auto'; - /** stops the list from autoscrolling when new messages are loaded */ + /** If true, prevents autoscroll-to-bottom behavior on new messages. */ suppressAutoscroll?: boolean; - /** If true, indicates the message list is a thread */ - threadList?: boolean; }; /** @@ -704,41 +735,25 @@ export type VirtualizedMessageListProps = Partial< * It is a consumer of the React contexts set in [Channel](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Channel/Channel.tsx). */ export function VirtualizedMessageList(props: VirtualizedMessageListProps) { - const { jumpToLatestMessage, loadMore, loadMoreNewer } = useChannelActionContext( - 'VirtualizedMessageList', - ); - const { - channel, - channelUnreadUiState, - hasMore, - hasMoreNewer, - highlightedMessageId, - loadingMore, - loadingMoreNewer, - messages: contextMessages, - notifications, - read, - suppressAutoscroll, - } = useChannelStateContext('VirtualizedMessageList'); - - const messages = props.messages || contextMessages; + const channel = useChannel(); + + const { read } = useStateStore(channel?.state.readStore, channelReadSelector) ?? {}; + + const messages = props.messages; // || contextMessages; return ( ); diff --git a/src/components/MessageList/VirtualizedMessageListComponents.tsx b/src/components/MessageList/VirtualizedMessageListComponents.tsx index 5c98f4cda7..c2afbb2044 100644 --- a/src/components/MessageList/VirtualizedMessageListComponents.tsx +++ b/src/components/MessageList/VirtualizedMessageListComponents.tsx @@ -12,6 +12,7 @@ import type { GroupStyle, RenderedMessage } from './utils'; import { getIsFirstUnreadMessage, isDateSeparatorMessage, isIntroMessage } from './utils'; import type { VirtuosoContext } from './VirtualizedMessageList'; import type { UnknownType } from '../../types/types'; +import { useThreadContext } from '../Threads'; const PREPEND_OFFSET = 10 ** 7; @@ -82,6 +83,8 @@ export const Header = ({ context }: CommonVirtuosoComponentProps) => { ); }; export const EmptyPlaceholder = ({ context }: CommonVirtuosoComponentProps) => { + const thread = useThreadContext(); + const isThreadList = !!thread; const { EmptyStateIndicator = DefaultEmptyStateIndicator } = useComponentContext( 'VirtualizedMessageList', ); @@ -95,7 +98,7 @@ export const EmptyPlaceholder = ({ context }: CommonVirtuosoComponentProps) => { return ( <> {EmptyStateIndicator && ( - + )} ); @@ -108,10 +111,12 @@ export const messageRenderer = ( ) => { const { additionalMessageInputProps, + channel, closeReactionSelectorOnClick, customMessageRenderer, DateSeparator, firstUnreadMessageId, + focusedMessageId, formatDate, lastOwnMessage, lastReadDate, @@ -122,7 +127,6 @@ export const messageRenderer = ( messageGroupStyles, MessageSystem, numItemsPrepended, - openThread, ownMessagesDeliveredToOthers, ownMessagesReadByOthers, processedMessages: messageList, @@ -132,7 +136,6 @@ export const messageRenderer = ( showAvatar, sortReactionDetails, sortReactions, - threadList, unreadMessageCount = 0, UnreadMessagesSeparator, virtuosoRef, @@ -159,13 +162,14 @@ export const messageRenderer = ( } const isFirstUnreadMessage = getIsFirstUnreadMessage({ + channel, firstUnreadMessageId, isFirstMessage: streamMessageIndex === 0, - lastReadDate, + lastReadAt: lastReadDate, lastReadMessageId, message, previousMessage: streamMessageIndex ? messageList[streamMessageIndex - 1] : undefined, - unreadMessageCount, + unreadCount: unreadMessageCount, }); return ( @@ -182,12 +186,12 @@ export const messageRenderer = ( deliveredTo={ownMessagesDeliveredToOthers[message.id] || []} formatDate={formatDate} groupStyles={[messageGroupStyles[message.id] ?? '']} + highlighted={focusedMessageId === message.id} lastOwnMessage={lastOwnMessage} lastReceivedId={lastReceivedMessageId} message={message} Message={MessageUIComponent} messageActions={messageActions} - openThread={openThread} reactionDetailsSort={reactionDetailsSort} readBy={ownMessagesReadByOthers[message.id] || []} renderText={renderText} @@ -195,7 +199,6 @@ export const messageRenderer = ( showAvatar={showAvatar} sortReactionDetails={sortReactionDetails} sortReactions={sortReactions} - threadList={threadList} /> ); diff --git a/src/components/MessageList/__tests__/MessageList.test.js b/src/components/MessageList/__tests__/MessageList.test.js index bc3a81b754..92505967ba 100644 --- a/src/components/MessageList/__tests__/MessageList.test.js +++ b/src/components/MessageList/__tests__/MessageList.test.js @@ -20,14 +20,11 @@ import { import { Chat } from '../../Chat'; import { MessageList } from '../MessageList'; import { Channel } from '../../Channel'; -import { - useChannelActionContext, - useMessageContext, - WithComponents, -} from '../../../context'; +import { useChannel, useMessageContext, WithComponents } from '../../../context'; import { EmptyStateIndicator as EmptyStateIndicatorMock } from '../../EmptyStateIndicator'; import { mockedApiResponse } from '../../../mock-builders/api/utils'; import { nanoid } from 'nanoid'; +import { ThreadProvider } from '../../Threads'; expect.extend(toHaveNoViolations); @@ -49,12 +46,32 @@ const mockedChannelData = generateChannel({ const Avatar = () =>
    Avatar
    ; -const renderComponent = ({ channelProps, chatClient, components = {}, msgListProps }) => +const makeThread = (parentMessage) => ({ + state: { + getLatestValue: () => ({ parentMessage }), + subscribeWithSelector: () => () => null, + }, +}); + +const renderComponent = ({ + channelProps, + chatClient, + components = {}, + inThread = false, + msgListProps, + threadParentMessage, +}) => render( - + {inThread ? ( + + + + ) : ( + + )} , @@ -108,11 +125,12 @@ describe('MessageList', () => { renderComponent({ channelProps: { channel }, chatClient, + inThread: true, msgListProps: { head: , messages: [reply1, reply2], - threadList: true, }, + threadParentMessage: message1, }); }); @@ -128,7 +146,9 @@ describe('MessageList', () => { renderComponent({ channelProps: { channel }, chatClient, - msgListProps: { messages: [reply1, reply2], thread: message1, threadList: true }, + inThread: true, + msgListProps: { messages: [reply1, reply2], thread: message1 }, + threadParentMessage: message1, }); }); @@ -158,7 +178,8 @@ describe('MessageList', () => { renderComponent({ channelProps: { channel }, chatClient, - msgListProps: { messages: [], threadList: true }, + inThread: true, + msgListProps: { messages: [] }, }); await waitFor(() => { @@ -367,9 +388,14 @@ describe('MessageList', () => { jest.useFakeTimers(); const markReadBtnTestId = 'test-mark-read'; const MarkReadButton = () => { - const { markRead } = useChannelActionContext(); + const channel = useChannel(); return ( - ); @@ -556,11 +582,12 @@ describe('MessageList', () => { renderComponent({ channelProps: { channel }, chatClient: client, + inThread: true, msgListProps: { disableDateSeparator: true, messages: replies, - threadList: true, }, + threadParentMessage: parentMsg, }); }); @@ -649,7 +676,9 @@ describe('MessageList', () => { components = {}, dispatchMarkUnreadPayload = {}, entries, + inThread = false, msgListProps = {}, + threadParentMessage, }) => { const { channels: [channel], @@ -661,7 +690,9 @@ describe('MessageList', () => { channelProps: { channel, ...channelProps }, chatClient: client, components, + inThread, msgListProps: { messages, ...msgListProps }, + threadParentMessage, }); }); @@ -785,7 +816,8 @@ describe('MessageList', () => { it('should not display unread messages notification in thread', async () => { await setupTest({ entries: observerEntriesScrolledBelowSeparator, - msgListProps: { threadList: true }, + inThread: true, + msgListProps: {}, }); expect( screen.queryByTestId(UNREAD_MESSAGES_NOTIFICATION_TEST_ID), @@ -840,7 +872,9 @@ describe('MessageList', () => { channel, }, chatClient: client, - msgListProps: { messages, threadList: true }, + inThread: true, + msgListProps: { messages }, + threadParentMessage: messages[0], }); }); diff --git a/src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.js b/src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.js index bbf0005b67..975b9e124d 100644 --- a/src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.js +++ b/src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.js @@ -3,7 +3,8 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom'; import { ScrollToLatestMessageButton } from '../ScrollToLatestMessageButton'; -import { ChannelStateProvider, ChatProvider } from '../../../context'; +import { ChatProvider } from '../../../context'; +import { ThreadProvider } from '../../Threads/ThreadContext'; import { createClientWithChannel, dispatchMessageNewEvent, @@ -22,11 +23,17 @@ let channel; let users; let containerIsThread; let anotherUser; -let channelStateContext; let parentMsg; const onClick = jest.fn(); +const makeThread = (parentMessage) => ({ + state: { + getLatestValue: () => ({ parentMessage }), + subscribeWithSelector: () => () => null, + }, +}); + const dispatchMessageEvents = ({ channel, client, newMessage, parentMsg, user }) => { if (containerIsThread) { dispatchMessageUpdatedEvent( @@ -53,9 +60,6 @@ describe.each([ (u) => u.id !== client.user.id, ); parentMsg = { ...Object.values(channel.state.messages)[0], reply_count: 0 }; - channelStateContext = { - thread: containerIsThread ? parentMsg : null, - }; }); afterEach(jest.clearAllMocks); @@ -63,9 +67,9 @@ describe.each([ it(`is not rendered if ${containerMsgList} scrolled to the bottom`, () => { const { container } = render( - + - + , ); expect(container).toBeEmptyDOMElement(); @@ -74,12 +78,12 @@ describe.each([ it('is rendered if scrolled above the threshold', () => { render( - + - + , ); expect(screen.queryByTestId(BUTTON_TEST_ID)).toBeInTheDocument(); @@ -88,12 +92,12 @@ describe.each([ it('calls the onclick callback', async () => { render( - + - + , ); @@ -110,9 +114,9 @@ describe.each([ const newMessage = generateMessage({ user: anotherUser }); render( - + - + , ); @@ -136,12 +140,12 @@ describe.each([ const newMessage = generateMessage({ user: anotherUser }); render( - + - + , ); @@ -166,12 +170,12 @@ describe.each([ const newMessage = generateMessage({ user: client.user }); render( - + - + , ); @@ -198,12 +202,12 @@ describe.each([ }); render( - + - + , ); @@ -231,12 +235,12 @@ describe.each([ render( - + - + , ); @@ -258,12 +262,12 @@ describe.each([ it('increases the count unread with each new message arrival', async () => { render( - + - + , ); @@ -299,21 +303,22 @@ describe.each([ const { container } = render( - -
    +
    + -
    -
    + +
    +
    + -
    - + +
    , ); diff --git a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js index ad5bddcf5e..122d0a0994 100644 --- a/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js +++ b/src/components/MessageList/__tests__/VirtualizedMessageListComponents.test.js @@ -15,8 +15,7 @@ import { initClientWithChannels, } from '../../../mock-builders'; import { - ChannelActionProvider, - ChannelStateProvider, + ChannelInstanceProvider, ChatProvider, ComponentProvider, DialogManagerProvider, @@ -25,6 +24,7 @@ import { } from '../../../context'; import { MessageSimple } from '../../Message'; import { UnreadMessagesSeparator } from '../UnreadMessagesSeparator'; +import { ThreadProvider } from '../../Threads'; const prependOffset = 0; const user1 = generateUser(); @@ -36,20 +36,26 @@ const PREPEND_OFFSET = 10 ** 7; const Wrapper = ({ children, componentContext = {} }) => ( - - - - - {children} - - - - + + + + {children} + + + ); -const renderElements = (children, componentContext) => - render({children}); +const renderElements = (children, componentContext, inThread = false) => + render( + inThread ? ( + + {children} + + ) : ( + {children} + ), + ); describe('VirtualizedMessageComponents', () => { describe('Item', function () { @@ -199,9 +205,7 @@ describe('VirtualizedMessageComponents', () => { }); it('should render empty for thread by default', () => { - const { container } = renderElements( - , - ); + const { container } = renderElements(, undefined, true); expect(container).toBeEmptyDOMElement(); }); it('should render custom EmptyStateIndicator for main message list', () => { @@ -210,10 +214,7 @@ describe('VirtualizedMessageComponents', () => { }); it('should render custom EmptyStateIndicator for thread', () => { - const { container } = renderElements( - , - componentContext, - ); + const { container } = renderElements(, componentContext, true); expect(container).toMatchSnapshot(); }); @@ -225,10 +226,7 @@ describe('VirtualizedMessageComponents', () => { it('should render empty in thread if EmptyStateIndicator nullified', () => { const componentContext = { EmptyStateIndicator: NullEmptyStateIndicator }; - const { container } = renderElements( - , - componentContext, - ); + const { container } = renderElements(, componentContext, true); expect(container).toBeEmptyDOMElement(); }); }); @@ -383,15 +381,13 @@ describe('VirtualizedMessageComponents', () => { v }}> - - - {messageRenderer( - virtuosoIndex ?? PREPEND_OFFSET, - undefined, - virtuosoContext, - )} - - + + {messageRenderer( + virtuosoIndex ?? PREPEND_OFFSET, + undefined, + virtuosoContext, + )} + , diff --git a/src/components/MessageList/__tests__/utils.test.ts b/src/components/MessageList/__tests__/utils.test.ts new file mode 100644 index 0000000000..c95d762784 --- /dev/null +++ b/src/components/MessageList/__tests__/utils.test.ts @@ -0,0 +1,55 @@ +import { generateMessage } from '../../../mock-builders'; +import { getIsFirstUnreadMessage } from '../utils'; +import type { Channel } from 'stream-chat'; + +describe('getIsFirstUnreadMessage', () => { + it('uses unread snapshot boundary and does not depend on live tracker state', () => { + const message = generateMessage({ created_at: new Date('2026-03-06T10:05:00.000Z') }); + const channel = { + getClient: () => ({ user: { id: 'current-user' } }), + }; + + const result = getIsFirstUnreadMessage({ + channel: channel as unknown as Channel, + isFirstMessage: true, + lastReadAt: new Date('2026-03-06T10:00:00.000Z'), + message, + unreadCount: 1, + }); + + expect(result).toBe(true); + }); + + it('does not render separator when snapshot unreadCount is 0', () => { + const message = generateMessage({ created_at: new Date('2026-03-06T10:05:00.000Z') }); + const channel = { + getClient: () => ({ user: { id: 'current-user' } }), + }; + + const result = getIsFirstUnreadMessage({ + channel: channel as unknown as Channel, + isFirstMessage: true, + lastReadAt: new Date('2026-03-06T10:00:00.000Z'), + message, + unreadCount: 0, + }); + + expect(result).toBe(false); + }); + + it('does not show separator for thread messages', () => { + const message = generateMessage({ parent_id: 'parent-id' }); + const channel = { + getClient: () => ({ user: { id: 'current-user' } }), + }; + + const result = getIsFirstUnreadMessage({ + channel: channel as unknown as Channel, + isFirstMessage: true, + message, + unreadCount: 1, + }); + + expect(result).toBe(false); + }); +}); diff --git a/src/components/MessageList/hooks/MessageList/useFloatingDateSeparatorMessageList.ts b/src/components/MessageList/hooks/MessageList/useFloatingDateSeparatorMessageList.ts index fafc5430a8..95a5f1cc0a 100644 --- a/src/components/MessageList/hooks/MessageList/useFloatingDateSeparatorMessageList.ts +++ b/src/components/MessageList/hooks/MessageList/useFloatingDateSeparatorMessageList.ts @@ -82,12 +82,13 @@ export const useFloatingDateSeparatorMessageList = ({ throttled(); listElement.addEventListener('scroll', throttled); - const resizeObserver = new ResizeObserver(throttled); - resizeObserver.observe(listElement); + const resizeObserver = + typeof ResizeObserver !== 'undefined' ? new ResizeObserver(throttled) : undefined; + resizeObserver?.observe(listElement); return () => { listElement.removeEventListener('scroll', throttled); - resizeObserver.disconnect(); + resizeObserver?.disconnect(); throttled.cancel(); }; }, [listElement, update]); diff --git a/src/components/MessageList/hooks/MessageList/useMessageListElements.tsx b/src/components/MessageList/hooks/MessageList/useMessageListElements.tsx index 431c846a21..6ed0b4595c 100644 --- a/src/components/MessageList/hooks/MessageList/useMessageListElements.tsx +++ b/src/components/MessageList/hooks/MessageList/useMessageListElements.tsx @@ -8,41 +8,44 @@ import { getLastReceived } from '../../utils'; import { useChatContext } from '../../../../context/ChatContext'; import { useComponentContext } from '../../../../context/ComponentContext'; -import type { LocalMessage } from 'stream-chat'; -import type { ChannelUnreadUiState } from '../../../../types/types'; +import type { LocalMessage, UnreadSnapshotState } from 'stream-chat'; import type { MessageRenderer, SharedMessageProps } from '../../renderMessages'; -import { useChannelStateContext } from '../../../../context'; +import { useChannel } from '../../../../context'; import { useLastDeliveredData } from '../useLastDeliveredData'; +import { useStateStore } from '../../../../store'; type UseMessageListElementsProps = { messages: LocalMessage[]; enrichedMessages: RenderedMessage[]; + focusedMessageId?: string | null; internalMessageProps: SharedMessageProps; messageGroupStyles: Record; renderMessages: MessageRenderer; returnAllReadData: boolean; - threadList: boolean; - channelUnreadUiState?: ChannelUnreadUiState; lastOwnMessage?: LocalMessage; }; +const unreadStateSnapshotSelector = (state: UnreadSnapshotState) => state; + export const useMessageListElements = (props: UseMessageListElementsProps) => { const { - channelUnreadUiState, enrichedMessages, + focusedMessageId, internalMessageProps, lastOwnMessage, messageGroupStyles, messages, renderMessages, returnAllReadData, - threadList, } = props; const { customClasses } = useChatContext('useMessageListElements'); - const { channel } = useChannelStateContext(); + const channel = useChannel(); const components = useComponentContext('useMessageListElements'); - + const channelUnreadUiState = useStateStore( + channel.messagePaginator.unreadStateSnapshot, + unreadStateSnapshotSelector, + ); // get the readData, but only for messages submitted by the user themselves const readData = useLastReadData({ channel, @@ -66,29 +69,34 @@ export const useMessageListElements = (props: UseMessageListElementsProps) => { const elements: React.ReactNode[] = useMemo( () => renderMessages({ + channel, channelUnreadUiState, components, customClasses, + focusedMessageId, lastOwnMessage, lastReceivedMessageId, messageGroupStyles, messages: enrichedMessages, ownMessagesDeliveredToOthers, readData, - sharedMessageProps: { ...internalMessageProps, returnAllReadData, threadList }, + sharedMessageProps: { ...internalMessageProps, returnAllReadData }, }), - // eslint-disable-next-line react-hooks/exhaustive-deps [ + channel, + channelUnreadUiState, + components, + customClasses, enrichedMessages, + focusedMessageId, internalMessageProps, lastOwnMessage, lastReceivedMessageId, messageGroupStyles, - channelUnreadUiState, + ownMessagesDeliveredToOthers, readData, renderMessages, returnAllReadData, - threadList, ], ); diff --git a/src/components/MessageList/hooks/MessageList/useMessageListScrollManager.ts b/src/components/MessageList/hooks/MessageList/useMessageListScrollManager.ts index f5823adacc..8d3506b030 100644 --- a/src/components/MessageList/hooks/MessageList/useMessageListScrollManager.ts +++ b/src/components/MessageList/hooks/MessageList/useMessageListScrollManager.ts @@ -16,6 +16,7 @@ export type UseMessageListScrollManagerParams = { scrolledUpThreshold: number; scrollToBottom: () => void; showNewMessages: () => void; + suppressAutoscroll?: boolean; }; // FIXME: change this generic name to something like useAdjustScrollPositionToListSize @@ -27,6 +28,7 @@ export function useMessageListScrollManager(params: UseMessageListScrollManagerP scrolledUpThreshold, scrollToBottom, showNewMessages, + suppressAutoscroll = false, } = params; const { client } = useChatContext('useMessageListScrollManager'); @@ -63,7 +65,7 @@ export function useMessageListScrollManager(params: UseMessageListScrollManagerP else { const lastMessageIsFromCurrentUser = lastNewMessage.user?.id === client.userID; - if (lastMessageIsFromCurrentUser || wasAtBottom) { + if (!suppressAutoscroll && (lastMessageIsFromCurrentUser || wasAtBottom)) { scrollToBottom(); } else { showNewMessages(); @@ -86,7 +88,7 @@ export function useMessageListScrollManager(params: UseMessageListScrollManagerP messages.current = newMessages; measures.current = newMeasures; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [measures, messages, params.messages]); + }, [measures, messages, params.messages, suppressAutoscroll]); return (scrollTopValue: number) => { scrollTop.current = scrollTopValue; diff --git a/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx b/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx index 17ca06f99e..d3bd392a34 100644 --- a/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx +++ b/src/components/MessageList/hooks/MessageList/useScrollLocationLogic.tsx @@ -6,9 +6,9 @@ import type { LocalMessage } from 'stream-chat'; export type UseScrollLocationLogicParams = { hasMoreNewer: boolean; - listElement: HTMLDivElement | null; + listElement: HTMLElement | null; loadMoreScrollThreshold: number; - suppressAutoscroll: boolean; + suppressAutoscroll?: boolean; messages?: LocalMessage[]; scrolledUpThreshold?: number; }; @@ -20,7 +20,7 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) => loadMoreScrollThreshold, messages = [], scrolledUpThreshold = 200, - suppressAutoscroll, + suppressAutoscroll = false, } = params; const [hasNewMessages, setHasNewMessages] = useState(false); @@ -30,6 +30,7 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) => useState(true); const closeToBottom = useRef(false); const closeToTop = useRef(false); + const initialDataAutoscrollDoneRef = useRef(false); const scrollToBottom = useCallback(() => { if (!listElement?.scrollTo || hasMoreNewer || suppressAutoscroll) { @@ -50,6 +51,23 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) => // eslint-disable-next-line react-hooks/exhaustive-deps }, [listElement, hasMoreNewer]); + useLayoutEffect(() => { + if (messages.length === 0) { + initialDataAutoscrollDoneRef.current = false; + return; + } + + if ( + listElement?.scrollTo && + !initialDataAutoscrollDoneRef.current && + !suppressAutoscroll + ) { + listElement.scrollTo({ top: listElement.scrollHeight }); + setHasNewMessages(false); + initialDataAutoscrollDoneRef.current = true; + } + }, [listElement, messages.length, suppressAutoscroll]); + const updateScrollTop = useMessageListScrollManager({ loadMoreScrollThreshold, messages, @@ -64,11 +82,12 @@ export const useScrollLocationLogic = (params: UseScrollLocationLogicParams) => scrolledUpThreshold, scrollToBottom, showNewMessages: () => setHasNewMessages(true), + suppressAutoscroll, }); const onScroll = useCallback( - (event: React.UIEvent) => { - const element = event.target as HTMLDivElement; + (event: React.UIEvent) => { + const element = event.target as HTMLElement; const scrollTop = element.scrollTop; updateScrollTop(scrollTop); diff --git a/src/components/MessageList/hooks/MessageList/useUnreadMessagesNotification.ts b/src/components/MessageList/hooks/MessageList/useUnreadMessagesNotification.ts index 17dafb7b15..a55541c237 100644 --- a/src/components/MessageList/hooks/MessageList/useUnreadMessagesNotification.ts +++ b/src/components/MessageList/hooks/MessageList/useUnreadMessagesNotification.ts @@ -1,7 +1,9 @@ -import { useChannelStateContext } from '../../../../context'; import { useEffect, useRef, useState } from 'react'; import { MESSAGE_LIST_MAIN_PANEL_CLASS } from '../../MessageListMainPanel'; import { UNREAD_MESSAGE_SEPARATOR_CLASS } from '../../UnreadMessagesSeparator'; +import type { MessagePaginatorState, UnreadSnapshotState } from 'stream-chat'; +import { useStateStore } from '../../../../store'; +import { useMessagePaginator } from '../../../../hooks'; const targetScrolledAboveVisibleContainerArea = ( element: Element, @@ -29,13 +31,30 @@ export type UseUnreadMessagesNotificationParams = { unreadCount?: number; }; +const messagePaginatorStateSelector = (state: MessagePaginatorState) => ({ + messages: state.items ?? [], +}); + +const unreadStateSnapshotSelector = (state: UnreadSnapshotState) => ({ + unreadCount: state.unreadCount, +}); + export const useUnreadMessagesNotification = ({ isMessageListScrolledToBottom, listElement, showAlways, - unreadCount, }: UseUnreadMessagesNotificationParams) => { - const { messages } = useChannelStateContext('UnreadMessagesNotification'); + const messagePaginator = useMessagePaginator(); + + const { messages } = useStateStore( + messagePaginator.state, + messagePaginatorStateSelector, + ); + + const { unreadCount } = useStateStore( + messagePaginator.unreadStateSnapshot, + unreadStateSnapshotSelector, + ); const [show, setShow] = useState(false); const isScrolledAboveTargetTop = useRef(false); const intersectionObserverIsSupported = typeof IntersectionObserver !== 'undefined'; @@ -56,7 +75,7 @@ export const useUnreadMessagesNotification = ({ UNREAD_MESSAGE_SEPARATOR_CLASS, ); if (!observedTarget) { - setShow(true); + setShow(unreadCount > 0); } return; } @@ -65,7 +84,7 @@ export const useUnreadMessagesNotification = ({ UNREAD_MESSAGE_SEPARATOR_CLASS, ); if (!observedTarget) { - setShow(true); + setShow(unreadCount > 0); return; } diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/useNewMessageNotification.ts b/src/components/MessageList/hooks/VirtualizedMessageList/useNewMessageNotification.ts index 88eb77fcb0..687ad1ff89 100644 --- a/src/components/MessageList/hooks/VirtualizedMessageList/useNewMessageNotification.ts +++ b/src/components/MessageList/hooks/VirtualizedMessageList/useNewMessageNotification.ts @@ -1,15 +1,26 @@ import { useEffect, useRef, useState } from 'react'; -import type { LocalMessage } from 'stream-chat'; +import type { LocalMessage, MessagePaginatorState } from 'stream-chat'; import type { RenderedMessage } from '../../utils'; +import { useMessagePaginator } from '../../../../hooks'; +import { useStateStore } from '../../../../store'; + +const messagePaginatorStateSelector = (state: MessagePaginatorState) => ({ + hasMoreNewer: state.hasMoreHead ?? [], +}); export function useNewMessageNotification( messages: RenderedMessage[], currentUserId: string | undefined, - hasMoreNewer?: boolean, + // hasMoreNewer?: boolean, ) { const [newMessagesNotification, setNewMessagesNotification] = useState(false); const [isMessageListScrolledToBottom, setIsMessageListScrolledToBottom] = useState(true); + const messagePaginator = useMessagePaginator(); + const { hasMoreNewer } = useStateStore( + messagePaginator.state, + messagePaginatorStateSelector, + ); /** * use the flag to avoid the initial "new messages" quick blink */ diff --git a/src/components/MessageList/hooks/VirtualizedMessageList/useUnreadMessagesNotificationVirtualized.ts b/src/components/MessageList/hooks/VirtualizedMessageList/useUnreadMessagesNotificationVirtualized.ts index dbaf6665dd..3e7a1d1e1d 100644 --- a/src/components/MessageList/hooks/VirtualizedMessageList/useUnreadMessagesNotificationVirtualized.ts +++ b/src/components/MessageList/hooks/VirtualizedMessageList/useUnreadMessagesNotificationVirtualized.ts @@ -1,13 +1,20 @@ import { useCallback, useEffect, useState } from 'react'; import type { RenderedMessage } from '../../utils'; -import type { LocalMessage } from 'stream-chat'; +import type { LocalMessage, UnreadSnapshotState } from 'stream-chat'; +import { useMessagePaginator } from '../../../../hooks'; +import { useStateStore } from '../../../../store'; export type UseUnreadMessagesNotificationParams = { showAlways: boolean; - unreadCount: number; - lastRead?: Date | null; + // unreadCount: number; + // lastRead?: Date | null; }; +const unreadStateSnapshotSelector = (state: UnreadSnapshotState) => ({ + lastReadAt: state.lastReadAt, + unreadCount: state.unreadCount, +}); + /** * Controls the logic when an `UnreadMessagesNotification` component should be shown. * In virtualized message list there is no notion of being scrolled below or above `UnreadMessagesSeparator`. @@ -16,16 +23,17 @@ export type UseUnreadMessagesNotificationParams = { * messages created later than the last read message in the channel, then the * `UnreadMessagesNotification` component is rendered. This is an approximate equivalent to being * scrolled below the `UnreadMessagesNotification` component. - * @param lastRead * @param showAlways - * @param unreadCount */ export const useUnreadMessagesNotificationVirtualized = ({ - lastRead, showAlways, - unreadCount, }: UseUnreadMessagesNotificationParams) => { const [show, setShow] = useState(false); + const messagePaginator = useMessagePaginator(); + const { lastReadAt, unreadCount } = useStateStore( + messagePaginator.unreadStateSnapshot, + unreadStateSnapshotSelector, + ); const toggleShowUnreadMessagesNotification = useCallback( (renderedMessages: RenderedMessage[]) => { @@ -40,7 +48,7 @@ export const useUnreadMessagesNotificationVirtualized = ({ const lastRenderedMessageTime = new Date( (lastRenderedMessage as LocalMessage).created_at ?? 0, ).getTime(); - const lastReadTime = new Date(lastRead ?? 0).getTime(); + const lastReadTime = new Date(lastReadAt ?? 0).getTime(); const scrolledBelowSeparator = !!lastReadTime && firstRenderedMessageTime > lastReadTime; @@ -53,7 +61,7 @@ export const useUnreadMessagesNotificationVirtualized = ({ : scrolledBelowSeparator, ); }, - [lastRead, showAlways, unreadCount], + [lastReadAt, showAlways, unreadCount], ); useEffect(() => { diff --git a/src/components/MessageList/hooks/__tests__/useMarkRead.test.js b/src/components/MessageList/hooks/__tests__/useMarkRead.test.js index 931e065053..913a436e1b 100644 --- a/src/components/MessageList/hooks/__tests__/useMarkRead.test.js +++ b/src/components/MessageList/hooks/__tests__/useMarkRead.test.js @@ -1,37 +1,15 @@ import React from 'react'; -import { renderHook } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react'; + import { useMarkRead } from '../useMarkRead'; -import { - ChannelActionProvider, - ChannelStateProvider, - ChatProvider, -} from '../../../../context'; +import { ChannelInstanceProvider, ChatProvider } from '../../../../context'; +import { ThreadProvider } from '../../../Threads/ThreadContext'; import { dispatchMessageNewEvent, - generateChannel, generateMessage, generateUser, initClientWithChannels, } from '../../../../mock-builders'; -import { act } from 'react'; - -const visibilityChangeScenario = 'visibilitychange event'; -const markRead = jest.fn(); -const setChannelUnreadUiState = jest.fn(); - -const render = ({ channel, client, params }) => { - const wrapper = ({ children }) => ( - - - - {children} - - - - ); - const { result } = renderHook(() => useMarkRead(params), { wrapper }); - return result.current; -}; const unreadLastMessageChannelData = () => { const user = generateUser(); @@ -52,676 +30,133 @@ const unreadLastMessageChannelData = () => { }; }; -const readLastMessageChannelData = () => { - const user = generateUser(); - const messages = [ - generateMessage({ created_at: new Date(1) }), - generateMessage({ created_at: new Date(2) }), - ]; - return { - channel: { config: { read_events: true } }, - messages, - read: [ - { - last_read: new Date(2).toISOString(), - last_read_message_id: messages[1].id, - unread_messages: 0, - user, - }, - ], - }; -}; +const renderUseMarkRead = ({ channel, client, params, thread }) => { + const wrapper = ({ children }) => ( + + + {children} + + + ); -const emptyChannelData = () => { - const user = generateUser(); - return { - messages: [], - read: [ - { - last_read: undefined, - last_read_message_id: undefined, - unread_messages: 0, - user, - }, - ], - }; + renderHook(() => useMarkRead(params), { wrapper }); }; describe('useMarkRead', () => { - const shouldMarkReadParams = { - isMessageListScrolledToBottom: true, - markReadOnScrolledToBottom: true, - messageListIsThread: false, - wasMarkedUnread: false, - }; - - beforeEach(jest.clearAllMocks); - - describe.each([[visibilityChangeScenario], ['render']])('on %s', (scenario) => { - it('should mark channel read from non-thread message list scrolled to the bottom not previously marked unread with unread messages', async () => { - const channelData = unreadLastMessageChannelData(); - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData: [channelData], - customUser: channelData.read[0].user, - }); - - await render({ - channel, - client, - params: shouldMarkReadParams, - }); - if (scenario === visibilityChangeScenario) { - await act(() => { - document.dispatchEvent(new Event('visibilitychange')); - }); - expect(markRead).toHaveBeenCalledTimes(2); - } else { - expect(markRead).toHaveBeenCalledTimes(1); - } - }); - - it('should not mark channel read from non-thread message list scrolled to the bottom previously marked unread with unread messages', async () => { - const channelData = unreadLastMessageChannelData(); - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData: [channelData], - customUser: channelData.read[0].user, - }); - - await render({ - channel, - client, - params: { ...shouldMarkReadParams, wasMarkedUnread: true }, - }); - expect(markRead).toHaveBeenCalledTimes(0); - }); - - it('should not mark channel read from non-thread message list scrolled to the bottom not previously marked unread with 0 unread messages', async () => { - const channelData = readLastMessageChannelData(); - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData: [channelData], - customUser: channelData.read[0].user, - }); + it('uses messageDeliveryReporter.throttledMarkRead for channel lists', async () => { + const channelData = unreadLastMessageChannelData(); + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelData], + customUser: channelData.read[0].user, + }); + const throttledMarkReadSpy = jest.spyOn( + client.messageDeliveryReporter, + 'throttledMarkRead', + ); + + renderUseMarkRead({ + channel, + client, + params: { isMessageListScrolledToBottom: true, messageListIsThread: false }, + }); + + expect(throttledMarkReadSpy).toHaveBeenCalledWith(channel); + expect(throttledMarkReadSpy).toHaveBeenCalledTimes(1); + }); - await render({ - channel, - client, - params: shouldMarkReadParams, - }); - if (scenario === visibilityChangeScenario) { - await act(() => { - document.dispatchEvent(new Event('visibilitychange')); - }); - } - expect(markRead).toHaveBeenCalledTimes(0); + it('does not mark read when unread snapshot indicates an explicit unread anchor', async () => { + const channelData = unreadLastMessageChannelData(); + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelData], + customUser: channelData.read[0].user, }); + const throttledMarkReadSpy = jest.spyOn( + client.messageDeliveryReporter, + 'throttledMarkRead', + ); - it('should not mark empty channel read', async () => { - const channelData = emptyChannelData(); - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData: [channelData], - customUser: channelData.read[0].user, - }); - - await render({ - channel, - client, - params: shouldMarkReadParams, - }); - if (scenario === visibilityChangeScenario) { - await act(() => { - document.dispatchEvent(new Event('visibilitychange')); - }); - } - expect(markRead).toHaveBeenCalledTimes(0); + channel.messagePaginator.unreadStateSnapshot.partialNext({ + firstUnreadMessageId: 'explicit-unread-anchor', }); - it('should not mark channel read from message list not scrolled to the bottom', async () => { - const { - channels: [channel], - client, - } = await initClientWithChannels(); - - await render({ - channel, - client, - params: { - ...shouldMarkReadParams, - isMessageListScrolledToBottom: false, - }, - }); - - if (scenario === visibilityChangeScenario) { - document.dispatchEvent(new Event('visibilitychange')); - } - expect(markRead).not.toHaveBeenCalled(); + renderUseMarkRead({ + channel, + client, + params: { isMessageListScrolledToBottom: true, messageListIsThread: false }, }); - it('should not mark channel read from thread message list', async () => { - const { - channels: [channel], - client, - } = await initClientWithChannels(); - - render({ - channel, - client, - params: { - ...shouldMarkReadParams, - messageListIsThread: true, - }, - }); - if (scenario === visibilityChangeScenario) { - document.dispatchEvent(new Event('visibilitychange')); - } - expect(markRead).not.toHaveBeenCalled(); - }); + expect(throttledMarkReadSpy).not.toHaveBeenCalled(); }); - describe('on message.new', () => { - it('should mark channel read from non-thread message list scrolled to the bottom not previously marked unread with unread messages', async () => { - const channelData = unreadLastMessageChannelData(); - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData: [channelData], - customUser: channelData.read[0].user, - }); - - await render({ - channel, - client, - params: shouldMarkReadParams, - }); - - await act(() => { - dispatchMessageNewEvent(client, generateMessage(), channel); - }); - expect(markRead).toHaveBeenCalledTimes(2); - expect(setChannelUnreadUiState).not.toHaveBeenCalled(); - }); - - it('should mark channel read for own messages when scrolled to bottom in main message list', async () => { - const channelData = readLastMessageChannelData(); - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData: [channelData], - customUser: channelData.read[0].user, - }); - - await render({ - channel, - client, - params: shouldMarkReadParams, - }); - - await act(() => { - dispatchMessageNewEvent( - client, - generateMessage({ user: channelData.read[0].user }), - channel, - ); - }); - - expect(markRead).toHaveBeenCalledTimes(1); - expect(setChannelUnreadUiState).not.toHaveBeenCalled(); - }); - - it('should mark channel read from non-thread message list scrolled to the bottom not previously marked unread with originally 0 unread messages', async () => { - const channelData = readLastMessageChannelData(); - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData: [channelData], - customUser: channelData.read[0].user, - }); - - await render({ - channel, - client, - params: shouldMarkReadParams, - }); + it('targets thread collection when thread context is present', async () => { + const channelData = unreadLastMessageChannelData(); + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelData], + customUser: channelData.read[0].user, + }); + const throttledMarkReadSpy = jest.spyOn( + client.messageDeliveryReporter, + 'throttledMarkRead', + ); + const thread = { id: 'parent-1', ownUnreadCount: 3 }; + + renderUseMarkRead({ + channel, + client, + params: { isMessageListScrolledToBottom: true, messageListIsThread: true }, + thread, + }); + + expect(throttledMarkReadSpy).toHaveBeenCalledWith(thread); + expect(throttledMarkReadSpy).toHaveBeenCalledTimes(1); + }); - await act(() => { - dispatchMessageNewEvent(client, generateMessage(), channel); - }); - expect(markRead).toHaveBeenCalledTimes(1); - expect(setChannelUnreadUiState).not.toHaveBeenCalled(); + it('ignores thread-only message.new in channel list unless show_in_channel is true', async () => { + const channelData = unreadLastMessageChannelData(); + const { + channels: [channel], + client, + } = await initClientWithChannels({ + channelsData: [channelData], + customUser: channelData.read[0].user, }); + const throttledMarkReadSpy = jest.spyOn( + client.messageDeliveryReporter, + 'throttledMarkRead', + ); - it('should mark originally empty channel read', async () => { - const channelData = emptyChannelData(); - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData: [channelData], - customUser: channelData.read[0].user, - }); - - await render({ - channel, - client, - params: shouldMarkReadParams, - }); - - await act(() => { - dispatchMessageNewEvent(client, generateMessage(), channel); - }); - expect(markRead).toHaveBeenCalledTimes(1); - expect(setChannelUnreadUiState).not.toHaveBeenCalled(); + renderUseMarkRead({ + channel, + client, + params: { isMessageListScrolledToBottom: true, messageListIsThread: false }, }); + expect(throttledMarkReadSpy).toHaveBeenCalledTimes(1); - it('should not mark channel read from non-thread message list scrolled to the bottom previously marked unread', async () => { - const channelData = unreadLastMessageChannelData(); - const { - channels: [channel], + act(() => { + dispatchMessageNewEvent( client, - } = await initClientWithChannels({ - channelsData: [channelData], - customUser: channelData.read[0].user, - }); - - await render({ + generateMessage({ parent_id: 'thread-1', show_in_channel: false }), channel, - client, - params: { - ...shouldMarkReadParams, - wasMarkedUnread: true, - }, - }); - - let channelUnreadUiStateCb; - setChannelUnreadUiState.mockImplementationOnce( - (cb) => (channelUnreadUiStateCb = cb), ); - await act(() => { - dispatchMessageNewEvent(client, generateMessage(), channel); - }); - expect(setChannelUnreadUiState).toHaveBeenCalledTimes(1); - const channelUnreadUiState = channelUnreadUiStateCb(); - expect(channelUnreadUiState.unread_messages).toBe(1); - expect(markRead).not.toHaveBeenCalled(); }); + expect(throttledMarkReadSpy).toHaveBeenCalledTimes(1); - it('should mark channel read from message list not scrolled to the bottom', async () => { - const channelData = readLastMessageChannelData(); - const { - channels: [channel], + act(() => { + dispatchMessageNewEvent( client, - } = await initClientWithChannels({ - channelsData: [channelData], - customUser: channelData.read[0].user, - }); - - await render({ + generateMessage({ parent_id: 'thread-1', show_in_channel: true }), channel, - client, - params: { - ...shouldMarkReadParams, - isMessageListScrolledToBottom: false, - }, - }); - - let channelUnreadUiStateCb; - setChannelUnreadUiState.mockImplementationOnce( - (cb) => (channelUnreadUiStateCb = cb), ); - await act(() => { - dispatchMessageNewEvent(client, generateMessage(), channel); - }); - expect(setChannelUnreadUiState).toHaveBeenCalledTimes(1); - const channelUnreadUiState = channelUnreadUiStateCb(); - expect(channelUnreadUiState.unread_messages).toBe(1); - expect(markRead).not.toHaveBeenCalled(); - }); - - it('should not increase unread count if the read events are disabled', async () => { - const channelData = { - ...readLastMessageChannelData(), - channel: { config: { read_events: false } }, - }; - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData: [channelData], - customUser: channelData.read[0].user, - }); - - await render({ - channel, - client, - params: { - ...shouldMarkReadParams, - isMessageListScrolledToBottom: false, - }, - }); - - await act(() => { - dispatchMessageNewEvent(client, generateMessage(), channel); - }); - expect(setChannelUnreadUiState).toHaveBeenCalledTimes(0); - expect(markRead).not.toHaveBeenCalled(); - }); - - it('should not mark channel read from thread message list', async () => { - const channelData = readLastMessageChannelData(); - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData: [channelData], - customUser: channelData.read[0].user, - }); - - render({ - channel, - client, - params: { - ...shouldMarkReadParams, - messageListIsThread: true, - }, - }); - await act(() => { - dispatchMessageNewEvent(client, generateMessage(), channel); - }); - expect(setChannelUnreadUiState).not.toHaveBeenCalled(); - expect(markRead).not.toHaveBeenCalled(); - }); - - it('should not mark channel read for messages incoming to other channels', async () => { - const channelData = readLastMessageChannelData(); - const { - channels: [activeChannel, otherChannel], - client, - } = await initClientWithChannels({ - channelsData: [channelData, generateChannel()], - customUser: channelData.read[0].user, - }); - - await render({ - channel: activeChannel, - client, - params: shouldMarkReadParams, - }); - - await act(() => { - dispatchMessageNewEvent(client, generateMessage(), otherChannel); - }); - - expect(markRead).not.toHaveBeenCalled(); - expect(setChannelUnreadUiState).not.toHaveBeenCalled(); - }); - - it('should not mark channel read for thread messages', async () => { - const { - channels: [channel], - client, - } = await initClientWithChannels(); - - await render({ - channel, - client, - params: shouldMarkReadParams, - }); - - await act(() => { - dispatchMessageNewEvent(client, generateMessage({ parent_id: 'X' }), channel); - }); - - expect(markRead).not.toHaveBeenCalled(); - expect(setChannelUnreadUiState).not.toHaveBeenCalled(); - }); - - it('should mark channel read for thread messages with event.show_in_channel enabled', async () => { - const { - channels: [channel], - client, - } = await initClientWithChannels(); - - await render({ - channel, - client, - params: shouldMarkReadParams, - }); - - await act(() => { - dispatchMessageNewEvent( - client, - generateMessage({ parent_id: 'X', show_in_channel: true }), - channel, - ); - }); - - expect(markRead).toHaveBeenCalledTimes(1); - expect(setChannelUnreadUiState).not.toHaveBeenCalled(); - }); - - describe('update unread UI state unread_messages', () => { - it('should be performed when message list is not scrolled to bottom', async () => { - let channelUnreadUiStateCb; - setChannelUnreadUiState.mockImplementationOnce( - (cb) => (channelUnreadUiStateCb = cb), - ); - const { - channels: [channel], - client, - } = await initClientWithChannels(); - - await render({ - channel, - client, - params: { - ...shouldMarkReadParams, - isMessageListScrolledToBottom: false, - }, - }); - - await act(() => { - dispatchMessageNewEvent(client, generateMessage(), channel); - }); - - expect(setChannelUnreadUiState).toHaveBeenCalledTimes(1); - const channelUnreadUiState = channelUnreadUiStateCb(); - expect(channelUnreadUiState.unread_messages).toBe(1); - }); - - it('should be performed when channel was marked unread and is scrolled to the bottom', async () => { - let channelUnreadUiStateCb; - setChannelUnreadUiState.mockImplementationOnce( - (cb) => (channelUnreadUiStateCb = cb), - ); - const { - channels: [channel], - client, - } = await initClientWithChannels(); - - await render({ - channel, - client, - params: { - ...shouldMarkReadParams, - wasMarkedUnread: true, - }, - }); - - await act(() => { - dispatchMessageNewEvent(client, generateMessage(), channel); - }); - - expect(setChannelUnreadUiState).toHaveBeenCalledTimes(1); - const channelUnreadUiState = channelUnreadUiStateCb(); - expect(channelUnreadUiState.unread_messages).toBe(1); - }); - - it('should be performed when document is hidden and is scrolled to the bottom', async () => { - let channelUnreadUiStateCb; - setChannelUnreadUiState.mockImplementationOnce( - (cb) => (channelUnreadUiStateCb = cb), - ); - const { - channels: [channel], - client, - } = await initClientWithChannels(); - - await render({ - channel, - client, - params: shouldMarkReadParams, - }); - - const docHiddenSpy = jest - .spyOn(document, 'hidden', 'get') - .mockReturnValueOnce(true); - await act(() => { - dispatchMessageNewEvent(client, generateMessage(), channel); - }); - - expect(setChannelUnreadUiState).toHaveBeenCalledTimes(1); - const channelUnreadUiState = channelUnreadUiStateCb(); - expect(channelUnreadUiState.unread_messages).toBe(1); - docHiddenSpy.mockRestore(); - }); - }); - - describe('update unread UI state last_read', () => { - it('should be performed when message list is not scrolled to bottom', async () => { - let channelUnreadUiStateCb; - setChannelUnreadUiState.mockImplementationOnce( - (cb) => (channelUnreadUiStateCb = cb), - ); - const channelsData = [ - generateChannel({ messages: Array.from({ length: 2 }, generateMessage) }), - ]; - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData, - }); - - await render({ - channel, - client, - params: { - ...shouldMarkReadParams, - isMessageListScrolledToBottom: false, - }, - }); - - await act(() => { - dispatchMessageNewEvent(client, generateMessage(), channel); - }); - - const prevLastRead = 'X'; - let channelUnreadUiState = channelUnreadUiStateCb({ last_read: prevLastRead }); - expect(channelUnreadUiState.last_read).toBe(prevLastRead); - channelUnreadUiState = channelUnreadUiStateCb({ unread_messages: 0 }); - expect(channelUnreadUiState.last_read.getTime()).toBe( - channelsData[0].messages[1].created_at.getTime(), - ); - channelUnreadUiState = channelUnreadUiStateCb(); - expect(channelUnreadUiState.last_read.getTime()).toBe( - channelsData[0].messages[1].created_at.getTime(), - ); - channelUnreadUiState = channelUnreadUiStateCb({ unread_messages: 1 }); - expect(channelUnreadUiState.last_read.getTime()).toBe(0); - }); - - it('should be performed when channel was marked unread and is scrolled to the bottom', async () => { - let channelUnreadUiStateCb; - setChannelUnreadUiState.mockImplementation((cb) => (channelUnreadUiStateCb = cb)); - const channelsData = [generateChannel({ messages: [generateMessage()] })]; - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData, - }); - - await render({ - channel, - client, - params: { - ...shouldMarkReadParams, - wasMarkedUnread: true, - }, - }); - - await act(async () => { - await dispatchMessageNewEvent(client, generateMessage(), channel); - }); - - const prevLastRead = 'X'; - let channelUnreadUiState = channelUnreadUiStateCb({ last_read: prevLastRead }); - expect(channelUnreadUiState.last_read).toBe(prevLastRead); - channelUnreadUiState = channelUnreadUiStateCb({ unread_messages: 0 }); - expect(channelUnreadUiState.last_read.getTime()).toBe( - channelsData[0].messages[0].created_at.getTime(), - ); - channelUnreadUiState = channelUnreadUiStateCb(); - expect(channelUnreadUiState.last_read.getTime()).toBe( - channelsData[0].messages[0].created_at.getTime(), - ); - channelUnreadUiState = channelUnreadUiStateCb({ unread_messages: 1 }); - expect(channelUnreadUiState.last_read.getTime()).toBe(0); - }); - - it('should be performed when document is hidden and is scrolled to the bottom', async () => { - let channelUnreadUiStateCb; - setChannelUnreadUiState.mockImplementation((cb) => (channelUnreadUiStateCb = cb)); - const channelsData = [generateChannel({ messages: [generateMessage()] })]; - const { - channels: [channel], - client, - } = await initClientWithChannels({ - channelsData, - }); - - await render({ - channel, - client, - params: shouldMarkReadParams, - }); - - const docHiddenSpy = jest - .spyOn(document, 'hidden', 'get') - .mockReturnValueOnce(true); - await act(async () => { - await dispatchMessageNewEvent(client, generateMessage(), channel); - }); - - const prevLastRead = 'X'; - let channelUnreadUiState = channelUnreadUiStateCb({ last_read: prevLastRead }); - expect(channelUnreadUiState.last_read).toBe(prevLastRead); - channelUnreadUiState = channelUnreadUiStateCb({ unread_messages: 0 }); - expect(channelUnreadUiState.last_read.getTime()).toBe( - channelsData[0].messages[0].created_at.getTime(), - ); - channelUnreadUiState = channelUnreadUiStateCb(); - expect(channelUnreadUiState.last_read.getTime()).toBe( - channelsData[0].messages[0].created_at.getTime(), - ); - channelUnreadUiState = channelUnreadUiStateCb({ unread_messages: 1 }); - expect(channelUnreadUiState.last_read.getTime()).toBe(0); - docHiddenSpy.mockRestore(); - }); }); + expect(throttledMarkReadSpy).toHaveBeenCalledTimes(2); }); }); diff --git a/src/components/MessageList/hooks/__tests__/useMessageListScrollManager.test.js b/src/components/MessageList/hooks/__tests__/useMessageListScrollManager.test.js index 0294453529..4f6404219b 100644 --- a/src/components/MessageList/hooks/__tests__/useMessageListScrollManager.test.js +++ b/src/components/MessageList/hooks/__tests__/useMessageListScrollManager.test.js @@ -216,4 +216,46 @@ describe('useMessageListScrollManager', () => { expect(scrollToBottom).toHaveBeenCalledTimes(1); }); + + it('does not emit scroll to bottom when suppressAutoscroll is enabled', () => { + const scrollToBottom = jest.fn(); + const showNewMessages = jest.fn(); + const Comp = (props) => { + const updateScrollTop = useMessageListScrollManager({ + ...defaultInputs, + messages: props.messages, + scrollContainerMeasures: () => ({ + scrollHeight: props.scrollHeight, + }), + scrollToBottom, + showNewMessages, + suppressAutoscroll: true, + }); + + updateScrollTop(props.scrollTop); + + return
    ; + }; + + const messages = generateMessages(20); + const { rerender } = render( + + + , + ); + + rerender( + + + , + ); + + expect(scrollToBottom).not.toHaveBeenCalled(); + expect(showNewMessages).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/components/MessageList/hooks/useLastDeliveredData.ts b/src/components/MessageList/hooks/useLastDeliveredData.ts index 7cd84250ac..9bdef6deca 100644 --- a/src/components/MessageList/hooks/useLastDeliveredData.ts +++ b/src/components/MessageList/hooks/useLastDeliveredData.ts @@ -1,6 +1,8 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useMemo } from 'react'; import type { Channel, LocalMessage, UserResponse } from 'stream-chat'; +import { useStateStore } from '../../../store/hooks/useStateStore'; + type UseLastDeliveredDataParams = { channel: Channel; messages: LocalMessage[]; @@ -8,53 +10,31 @@ type UseLastDeliveredDataParams = { lastOwnMessage?: LocalMessage; }; +const trackerSnapshotSelector = (next: { + deliveredByMessageId: Record; + revision: number; +}) => ({ + deliveredByMessageId: next.deliveredByMessageId, + revision: next.revision, +}); + export const useLastDeliveredData = ( props: UseLastDeliveredDataParams, ): Record => { - const { channel, lastOwnMessage, messages, returnAllReadData } = props; - - const calculateForAll = useCallback( - () => - messages.reduce( - (acc, msg) => { - acc[msg.id] = channel.messageReceiptsTracker.deliveredForMessage({ - msgId: msg.id, - timestampMs: msg.created_at.getTime(), - }); - return acc; - }, - {} as Record, - ), - [channel, messages], + const { channel, lastOwnMessage, returnAllReadData } = props; + const trackerSnapshot = useStateStore( + channel.messageReceiptsTracker.snapshotStore, + trackerSnapshotSelector, ); - const calculateForLastOwn = useCallback(() => { + return useMemo(() => { + const deliveredByMessageId = trackerSnapshot?.deliveredByMessageId ?? {}; + + if (returnAllReadData) return deliveredByMessageId; + if (!lastOwnMessage) return {}; return { - [lastOwnMessage.id]: channel.messageReceiptsTracker.deliveredForMessage({ - msgId: lastOwnMessage.id, - timestampMs: lastOwnMessage.created_at.getTime(), - }), + [lastOwnMessage.id]: deliveredByMessageId[lastOwnMessage.id] ?? [], }; - }, [channel, lastOwnMessage]); - - const [deliveredTo, setDeliveredTo] = useState>( - returnAllReadData ? calculateForAll : calculateForLastOwn, - ); - - useEffect(() => { - if (!returnAllReadData) return; - setDeliveredTo(calculateForAll); - return channel.on('message.delivered', () => setDeliveredTo(calculateForAll)) - .unsubscribe; - }, [channel, calculateForAll, returnAllReadData]); - - useEffect(() => { - if (returnAllReadData) return; - else setDeliveredTo(calculateForLastOwn); - return channel.on('message.delivered', () => setDeliveredTo(calculateForLastOwn)) - .unsubscribe; - }, [channel, calculateForLastOwn, returnAllReadData]); - - return deliveredTo; + }, [lastOwnMessage, returnAllReadData, trackerSnapshot]); }; diff --git a/src/components/MessageList/hooks/useLastReadData.ts b/src/components/MessageList/hooks/useLastReadData.ts index c1a6636a08..96d33c80b9 100644 --- a/src/components/MessageList/hooks/useLastReadData.ts +++ b/src/components/MessageList/hooks/useLastReadData.ts @@ -1,5 +1,7 @@ import { useMemo } from 'react'; -import type { Channel, LocalMessage, UserResponse } from 'stream-chat'; +import type { Channel, LocalMessage, MessageReceiptsSnapshot } from 'stream-chat'; + +import { useStateStore } from '../../../store/hooks/useStateStore'; type UseLastReadDataParams = { channel: Channel; @@ -8,29 +10,26 @@ type UseLastReadDataParams = { lastOwnMessage?: LocalMessage; }; +const trackerSnapshotSelector = (next: MessageReceiptsSnapshot) => ({ + readersByMessageId: next.readersByMessageId, + revision: next.revision, +}); + export const useLastReadData = (props: UseLastReadDataParams) => { - const { channel, lastOwnMessage, messages, returnAllReadData } = props; + const { channel, lastOwnMessage, returnAllReadData } = props; + const trackerSnapshot = useStateStore( + channel.messageReceiptsTracker.snapshotStore, + trackerSnapshotSelector, + ); return useMemo(() => { - if (returnAllReadData) { - return messages.reduce( - (acc, msg) => { - acc[msg.id] = channel.messageReceiptsTracker.readersForMessage({ - msgId: msg.id, - timestampMs: msg.created_at.getTime(), - }); - return acc; - }, - {} as Record, - ); - } + const readersByMessageId = trackerSnapshot?.readersByMessageId ?? {}; + + if (returnAllReadData) return readersByMessageId; if (!lastOwnMessage) return {}; return { - [lastOwnMessage.id]: channel.messageReceiptsTracker.readersForMessage({ - msgId: lastOwnMessage.id, - timestampMs: lastOwnMessage.created_at.getTime(), - }), + [lastOwnMessage.id]: readersByMessageId[lastOwnMessage.id] ?? [], }; - }, [channel, lastOwnMessage, messages, returnAllReadData]); + }, [lastOwnMessage, returnAllReadData, trackerSnapshot]); }; diff --git a/src/components/MessageList/hooks/useMarkRead.ts b/src/components/MessageList/hooks/useMarkRead.ts index 9de52ae2af..1efa244828 100644 --- a/src/components/MessageList/hooks/useMarkRead.ts +++ b/src/components/MessageList/hooks/useMarkRead.ts @@ -1,10 +1,8 @@ -import { useEffect } from 'react'; -import { - useChannelActionContext, - useChannelStateContext, - useChatContext, -} from '../../../context'; -import type { Channel, Event, LocalMessage, MessageResponse } from 'stream-chat'; +import { useCallback, useEffect } from 'react'; +import { useChannel, useChatContext } from '../../../context'; +import { useMessagePaginator } from '../../../hooks'; +import { useThreadContext } from '../../Threads'; +import type { Channel, Event } from 'stream-chat'; const hasReadLastMessage = (channel: Channel, userId: string) => { const latestMessageIdInChannel = channel.state.latestMessages.slice(-1)[0]?.id; @@ -14,64 +12,62 @@ const hasReadLastMessage = (channel: Channel, userId: string) => { type UseMarkReadParams = { isMessageListScrolledToBottom: boolean; + // todo: remove and infer only from useThreadContext return value - if undefined, not a thread list messageListIsThread: boolean; - wasMarkedUnread?: boolean; }; /** - * Takes care of marking a channel read. The channel is read only if all the following applies: - * 1. the message list is not rendered in a thread - * 2. the message list is scrolled to the bottom - * 3. the channel was not marked unread by the user - * @param isMessageListScrolledToBottom - * @param messageListIsThread - * @param wasChannelMarkedUnread + * Takes care of marking the active message collection read (channel or thread). + * The collection is marked read only if: + * 1. the list is scrolled to the bottom + * 2. it was not explicitly marked unread by the user */ export const useMarkRead = ({ isMessageListScrolledToBottom, messageListIsThread, - wasMarkedUnread, }: UseMarkReadParams) => { - const { client } = useChatContext('useMarkRead'); - const { markRead, setChannelUnreadUiState } = useChannelActionContext('useMarkRead'); - const { channel } = useChannelStateContext('useMarkRead'); + const { client } = useChatContext(); + const channel = useChannel(); + const thread = useThreadContext(); + const messagePaginator = useMessagePaginator(); + + const isThreadList = !!thread || messageListIsThread; + + const markRead = useCallback(() => { + if (thread) { + client.messageDeliveryReporter.throttledMarkRead(thread); + return; + } + client.messageDeliveryReporter.throttledMarkRead(channel); + }, [channel, client.messageDeliveryReporter, thread]); useEffect(() => { if (!channel.getConfig()?.read_events) return; - const shouldMarkRead = () => - !document.hidden && - !wasMarkedUnread && - !messageListIsThread && - isMessageListScrolledToBottom && - client.user?.id && - !hasReadLastMessage(channel, client.user.id); + const shouldMarkRead = () => { + const wasMarkedUnread = + !!messagePaginator.unreadStateSnapshot.getLatestValue().firstUnreadMessageId; + return ( + !document.hidden && + !wasMarkedUnread && + isMessageListScrolledToBottom && + (isThreadList + ? (thread?.ownUnreadCount ?? 0) > 0 + : !!client.user?.id && !hasReadLastMessage(channel, client.user.id)) + ); + }; const onVisibilityChange = () => { if (shouldMarkRead()) markRead(); }; const handleMessageNew = (event: Event) => { + const threadUpdated = !!thread && event.message?.parent_id === thread.id; const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel; + const activeCollectionUpdated = isThreadList ? threadUpdated : mainChannelUpdated; + if (!activeCollectionUpdated) return; - if (!isMessageListScrolledToBottom || wasMarkedUnread || document.hidden) { - setChannelUnreadUiState((prev) => { - const previousUnreadCount = prev?.unread_messages ?? 0; - const previousLastMessage = getPreviousLastMessage( - channel.state.messages, - event.message, - ); - return { - ...(prev || {}), - last_read: - prev?.last_read ?? - (previousUnreadCount === 0 && previousLastMessage?.created_at - ? new Date(previousLastMessage.created_at) - : new Date(0)), // not having information about the last read message means the whole channel is unread, - unread_messages: previousUnreadCount + 1, - }; - }); - } else if (mainChannelUpdated && shouldMarkRead()) { + if (shouldMarkRead()) { markRead(); } }; @@ -92,22 +88,23 @@ export const useMarkRead = ({ client, isMessageListScrolledToBottom, markRead, - messageListIsThread, - setChannelUnreadUiState, - wasMarkedUnread, + isThreadList, + messagePaginator, + thread, ]); }; -function getPreviousLastMessage(messages: LocalMessage[], newMessage?: MessageResponse) { - if (!newMessage) return; - let previousLastMessage; - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (!msg?.id) break; - if (msg.id !== newMessage.id) { - previousLastMessage = msg; - break; - } - } - return previousLastMessage; -} +// todo: remove? +// function getPreviousLastMessage(messages: LocalMessage[], newMessage?: MessageResponse) { +// if (!newMessage) return; +// let previousLastMessage; +// for (let i = messages.length - 1; i >= 0; i--) { +// const msg = messages[i]; +// if (!msg?.id) break; +// if (msg.id !== newMessage.id) { +// previousLastMessage = msg; +// break; +// } +// } +// return previousLastMessage; +// } diff --git a/src/components/MessageList/renderMessages.tsx b/src/components/MessageList/renderMessages.tsx index b46b030704..2febbb5fef 100644 --- a/src/components/MessageList/renderMessages.tsx +++ b/src/components/MessageList/renderMessages.tsx @@ -7,16 +7,30 @@ import { Message } from '../Message'; import { DateSeparator as DefaultDateSeparator } from '../DateSeparator'; import { EventComponent as DefaultMessageSystem } from '../EventComponent'; import { UnreadMessagesSeparator as DefaultUnreadMessagesSeparator } from './UnreadMessagesSeparator'; -import type { LocalMessage, UserResponse } from 'stream-chat'; +import type { + Channel, + LocalMessage, + UnreadSnapshotState, + UserResponse, +} from 'stream-chat'; import type { ComponentContextValue, CustomClasses } from '../../context'; -import type { ChannelUnreadUiState } from '../../types'; +// import type { ChannelUnreadUiState } from '../../types'; export interface RenderMessagesOptions { + channel: Channel; + /** + * Current user's channel read state used to render components reflecting unread state. + * It does not reflect the back-end state if a channel is marked read on mount. + * This is in order to keep the unread UI when an unread channel is open. + */ + channelUnreadUiState: UnreadSnapshotState; components: ComponentContextValue; lastReceivedMessageId: string | null; messageGroupStyles: Record; messages: Array; ownMessagesDeliveredToOthers: Record; + /** The message id currently signaled for focus by the paginator. */ + focusedMessageId?: string | null; /** * Object mapping message IDs of own messages to the users who read those messages. */ @@ -27,12 +41,6 @@ export interface RenderMessagesOptions { sharedMessageProps: SharedMessageProps; /** Latest own message in currently displayed message set. */ lastOwnMessage?: LocalMessage; - /** - * Current user's channel read state used to render components reflecting unread state. - * It does not reflect the back-end state if a channel is marked read on mount. - * This is in order to keep the unread UI when an unread channel is open. - */ - channelUnreadUiState?: ChannelUnreadUiState; customClasses?: CustomClasses; } @@ -50,9 +58,11 @@ type MessagePropsToOmit = | 'readBy'; export function defaultRenderMessages({ + channel, channelUnreadUiState, components, customClasses, + focusedMessageId, lastOwnMessage, lastReceivedMessageId: lastReceivedId, messageGroupStyles, @@ -111,22 +121,19 @@ export function defaultRenderMessages({ customClasses?.message || `str-chat__li str-chat__li--${groupStyles}`; const isFirstUnreadMessage = getIsFirstUnreadMessage({ - firstUnreadMessageId: channelUnreadUiState?.first_unread_message_id, + ...channelUnreadUiState, + channel, + firstUnreadMessageId: channelUnreadUiState?.firstUnreadMessageId, isFirstMessage: !!firstMessage?.id && firstMessage.id === message.id, - lastReadDate: channelUnreadUiState?.last_read, - lastReadMessageId: channelUnreadUiState?.last_read_message_id, message, previousMessage, - unreadMessageCount: channelUnreadUiState?.unread_messages, }); renderedMessages.push( {isFirstUnreadMessage && UnreadMessagesSeparator && ( - + )} { // prevent showing unread indicator in threads - if (message.parent_id) return false; + if (message.parent_id || !channel) return false; + // unread separator is snapshot-driven; if snapshot says there are no unread messages, + // the separator should not be rendered. + if (!unreadCount) return false; const createdAtTimestamp = message.created_at && new Date(message.created_at).getTime(); - const lastReadTimestamp = lastReadDate?.getTime(); + const lastReadTimestamp = lastReadAt?.getTime(); const messageIsUnread = !!createdAtTimestamp && !!lastReadTimestamp && createdAtTimestamp > lastReadTimestamp; @@ -372,8 +385,6 @@ export const getIsFirstUnreadMessage = ({ return ( firstUnreadMessageId === message.id || - (!!unreadMessageCount && - messageIsUnread && - (isFirstMessage || previousMessageIsLastRead)) + (messageIsUnread && (isFirstMessage || previousMessageIsLastRead)) ); }; diff --git a/src/components/Modal/CloseButtonOnModalOverlay.tsx b/src/components/Modal/CloseButtonOnModalOverlay.tsx index 164fe4ba51..aae9c9ff68 100644 --- a/src/components/Modal/CloseButtonOnModalOverlay.tsx +++ b/src/components/Modal/CloseButtonOnModalOverlay.tsx @@ -1,5 +1,6 @@ import { Button } from '../Button'; import { IconCrossMedium } from '../Icons'; +import React from 'react'; import type { ComponentProps } from 'react'; import clsx from 'clsx'; diff --git a/src/components/Poll/PollActions/PollActions.tsx b/src/components/Poll/PollActions/PollActions.tsx index b63072ff3e..675223cf0e 100644 --- a/src/components/Poll/PollActions/PollActions.tsx +++ b/src/components/Poll/PollActions/PollActions.tsx @@ -15,13 +15,14 @@ import type { PollResultsProps } from './PollResults'; import { PollResults as DefaultPollResults } from './PollResults'; import { MAX_OPTIONS_DISPLAYED, MAX_POLL_OPTIONS } from '../constants'; import { - useChannelStateContext, + useChannel, useChatContext, useMessageContext, usePollContext, useTranslationContext, } from '../../../context'; import { useStateStore } from '../../../store'; +import { useChannelCapabilities } from '../../Channel/hooks/useChannelCapabilities'; import type { PollState } from 'stream-chat'; @@ -63,9 +64,10 @@ export const PollActions = ({ PollResults = DefaultPollResults, SuggestPollOptionForm = DefaultSuggestPollOptionForm, }: PollActionsProps) => { + const channel = useChannel(); const { client } = useChatContext(); const { t } = useTranslationContext('PollActions'); - const { channelCapabilities = {} } = useChannelStateContext('PollActions'); + const channelCapabilities = useChannelCapabilities({ cid: channel.cid }); const { message } = useMessageContext('PollActions'); const { poll } = usePollContext(); const { @@ -80,7 +82,7 @@ export const PollActions = ({ } = useStateStore(poll.state, pollStateSelector); const [modalOpen, setModalOpen] = useState(); - const canCastVote = channelCapabilities['cast-poll-vote'] && !is_closed; + const canCastVote = channelCapabilities.has('cast-poll-vote') && !is_closed; const closeModal = useCallback(() => setModalOpen(undefined), []); const onUpdateAnswerClick = useCallback(() => setModalOpen('add-comment'), []); @@ -90,7 +92,7 @@ export const PollActions = ({ options.length > MAX_OPTIONS_DISPLAYED || (canCastVote && allow_user_suggested_options && options.length < MAX_POLL_OPTIONS) || (!is_closed && allow_answers) || - (answers_count > 0 && channelCapabilities['query-poll-votes']); + (answers_count > 0 && channelCapabilities.has('query-poll-votes')); if (!hasContents) return null; @@ -164,7 +166,7 @@ export const PollActions = ({ )} - {answers_count > 0 && channelCapabilities['query-poll-votes'] && ( + {answers_count > 0 && channelCapabilities.has('query-poll-votes') && ( { + const channel = useChannel(); const { t } = useTranslationContext(); - const { channelCapabilities = {} } = useChannelStateContext( - 'PollOptionWithLatestVotes', - ); + const channelCapabilities = useChannelCapabilities({ cid: channel.cid }); const { poll } = usePollContext(); const { latest_votes_by_option } = useStateStore(poll.state, pollStateSelector); @@ -41,7 +37,7 @@ export const PollOptionWithLatestVotes = ({
    {votes && } - {channelCapabilities['query-poll-votes'] && + {channelCapabilities.has('query-poll-votes') && showAllVotes && votes?.length > countVotesPreview && (
    diff --git a/src/components/Threads/ThreadList/ThreadListSlot.tsx b/src/components/Threads/ThreadList/ThreadListSlot.tsx new file mode 100644 index 0000000000..a210833008 --- /dev/null +++ b/src/components/Threads/ThreadList/ThreadListSlot.tsx @@ -0,0 +1,95 @@ +import React, { useEffect } from 'react'; + +import { + createChatViewSlotBinding, + getChatViewEntityBinding, + useChatViewContext, +} from '../../ChatView'; +import { useLayoutViewState } from '../../ChatView/hooks/useLayoutViewState'; +import { Slot } from '../../ChatView/layout/Slot'; + +import type { PropsWithChildren, ReactNode } from 'react'; +import type { ChatView } from '../../ChatView'; +import type { SlotName } from '../../ChatView/layoutController/layoutControllerTypes'; + +export type ThreadListSlotProps = PropsWithChildren<{ + fallback?: ReactNode; + slot?: SlotName; +}>; + +const LIST_BINDING_KEY = 'thread-list'; +const LIST_ENTITY_KIND = 'threadList'; + +const registerListSlotHint = ( + view: ChatView, + slot: SlotName, + state: ReturnType['layoutController']['state'], +) => { + state.next((current) => { + if (current.listSlotByView?.[view] === slot) return current; + + return { + ...current, + listSlotByView: { + ...(current.listSlotByView ?? {}), + [view]: slot, + }, + }; + }); +}; + +export const ThreadListSlot = ({ + children, + fallback = null, + slot, +}: ThreadListSlotProps) => { + const { activeView, layoutController } = useChatViewContext(); + const { availableSlots, slotBindings } = useLayoutViewState(); + + const requestedSlot = slot && availableSlots.includes(slot) ? slot : undefined; + const existingListSlot = availableSlots.find( + (candidate) => + getChatViewEntityBinding(slotBindings[candidate])?.kind === LIST_ENTITY_KIND, + ); + const firstFreeSlot = availableSlots.find((candidate) => !slotBindings[candidate]); + const listSlot = + requestedSlot ?? existingListSlot ?? firstFreeSlot ?? availableSlots[0]; + + useEffect(() => { + if (!listSlot) return; + + registerListSlotHint(activeView, listSlot, layoutController.state); + + if (requestedSlot && existingListSlot && existingListSlot !== requestedSlot) { + layoutController.clear(existingListSlot); + } + + const existingEntity = getChatViewEntityBinding(slotBindings[listSlot]); + if ( + existingEntity?.kind === LIST_ENTITY_KIND && + existingEntity.source.view === activeView + ) { + return; + } + + layoutController.setSlotBinding( + listSlot, + createChatViewSlotBinding({ + key: LIST_BINDING_KEY, + kind: LIST_ENTITY_KIND, + source: { view: activeView }, + }), + ); + }, [ + activeView, + existingListSlot, + layoutController, + listSlot, + requestedSlot, + slotBindings, + ]); + + if (!listSlot) return <>{fallback}; + + return {children ?? fallback}; +}; diff --git a/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx b/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx new file mode 100644 index 0000000000..ef814cfc92 --- /dev/null +++ b/src/components/Threads/ThreadList/__tests__/ThreadList.test.tsx @@ -0,0 +1,100 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useChatContext } from '../../../../context'; +import { useThreadList } from '../ThreadList'; + +jest.mock('../ThreadListItem', () => ({ + ThreadListItem: () => null, +})); + +jest.mock('../ThreadListEmptyPlaceholder', () => ({ + ThreadListEmptyPlaceholder: () => null, +})); + +jest.mock('../ThreadListUnseenThreadsBanner', () => ({ + ThreadListUnseenThreadsBanner: () => null, +})); + +jest.mock('../ThreadListLoadingIndicator', () => ({ + ThreadListLoadingIndicator: () => null, +})); + +jest.mock('react-virtuoso', () => ({ + Virtuoso: () => null, +})); + +jest.mock('../../../../context', () => ({ + useChatContext: jest.fn(), +})); + +const mockedUseChatContext = jest.mocked(useChatContext); + +describe('useThreadList', () => { + let documentVisibilityState: DocumentVisibilityState; + + beforeEach(() => { + documentVisibilityState = 'visible'; + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => documentVisibilityState, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('reloads first page on mount and reacts to visibility changes', () => { + const activate = jest.fn(); + const deactivate = jest.fn(); + const reload = jest.fn().mockResolvedValue(undefined); + const partialNext = jest.fn(); + const getLatestValue = jest.fn(() => ({ + pagination: { isLoading: false, isLoadingNext: false, nextCursor: 'cursor-1' }, + })); + + mockedUseChatContext.mockReturnValue({ + client: { + threads: { + activate, + deactivate, + reload, + state: { + getLatestValue, + partialNext, + }, + }, + }, + } as never); + + const { unmount } = renderHook(() => useThreadList()); + + expect(getLatestValue).toHaveBeenCalledTimes(1); + expect(partialNext).toHaveBeenCalledWith({ + isThreadOrderStale: false, + pagination: { isLoading: false, isLoadingNext: false, nextCursor: null }, + ready: false, + threads: [], + unseenThreadIds: [], + }); + expect(reload).toHaveBeenCalledTimes(1); + expect(reload).toHaveBeenCalledWith({ force: true }); + expect(activate).toHaveBeenCalledTimes(1); + expect(deactivate).toHaveBeenCalledTimes(0); + + act(() => { + documentVisibilityState = 'hidden'; + document.dispatchEvent(new Event('visibilitychange')); + }); + expect(deactivate).toHaveBeenCalledTimes(1); + + act(() => { + documentVisibilityState = 'visible'; + document.dispatchEvent(new Event('visibilitychange')); + }); + expect(activate).toHaveBeenCalledTimes(2); + + unmount(); + expect(deactivate).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/components/Threads/ThreadList/index.ts b/src/components/Threads/ThreadList/index.ts index 2faf2a4b92..308a5dc2bd 100644 --- a/src/components/Threads/ThreadList/index.ts +++ b/src/components/Threads/ThreadList/index.ts @@ -1,3 +1,4 @@ export * from './ThreadList'; export * from './ThreadListItem'; export * from './ThreadListItemUI'; +export * from './ThreadListSlot'; diff --git a/src/components/TypingIndicator/TypingIndicator.tsx b/src/components/TypingIndicator/TypingIndicator.tsx index 6097286b7f..acb58c9aa1 100644 --- a/src/components/TypingIndicator/TypingIndicator.tsx +++ b/src/components/TypingIndicator/TypingIndicator.tsx @@ -1,15 +1,19 @@ import React from 'react'; import clsx from 'clsx'; +import type { TextComposerState, ThreadState } from 'stream-chat'; -import { useChannelStateContext } from '../../context/ChannelStateContext'; import { useChatContext } from '../../context/ChatContext'; -import { useTypingContext } from '../../context/TypingContext'; import { useTranslationContext } from '../../context/TranslationContext'; +import { useThreadContext } from '../Threads/ThreadContext'; +import { useStateStore } from '../../store'; +import { useChannelConfig } from '../Channel/hooks/useChannelConfig'; +import { useMessageComposer } from '../MessageInput'; -export type TypingIndicatorProps = { - /** Whether the typing indicator is in a thread */ - threadList?: boolean; -}; +const threadParentMessageSelector = ({ parentMessage }: ThreadState) => ({ + parentMessage, +}); + +const textComposerTypingSelector = ({ typing }: TextComposerState) => ({ typing }); const useJoinTypingUsers = (names: string[]) => { const { t } = useTranslationContext(); @@ -39,33 +43,38 @@ const useJoinTypingUsers = (names: string[]) => { /** * TypingIndicator lists users currently typing, it needs to be a child of Channel component */ -const UnMemoizedTypingIndicator = (props: TypingIndicatorProps) => { - const { threadList } = props; - - const { channelConfig, thread } = useChannelStateContext('TypingIndicator'); +export const TypingIndicator = () => { + const messageComposer = useMessageComposer(); + const channelConfig = useChannelConfig({ cid: messageComposer.channel.cid }); const { client } = useChatContext('TypingIndicator'); - const { typing = {} } = useTypingContext('TypingIndicator'); - - const typingInChannel = !threadList + const { typing = {} } = + useStateStore(messageComposer.textComposer?.state, textComposerTypingSelector) ?? {}; + const thread = useThreadContext(); + const isThreadList = !!thread; + const { parentMessage } = + useStateStore(thread?.state, threadParentMessageSelector) ?? {}; + + const typingInChannel = !isThreadList ? Object.values(typing).filter( ({ parent_id, user }) => user?.id !== client.user?.id && !parent_id, ) : []; - const typingInThread = threadList + const typingInThread = isThreadList ? Object.values(typing).filter( - ({ parent_id, user }) => user?.id !== client.user?.id && parent_id === thread?.id, + ({ parent_id, user }) => + user?.id !== client.user?.id && parent_id === parentMessage?.id, ) : []; - const typingUserList = (threadList ? typingInThread : typingInChannel) + const typingUserList = (isThreadList ? typingInThread : typingInChannel) .map(({ user }) => user?.name || user?.id) .filter(Boolean) as string[]; const joinedTypingUsers = useJoinTypingUsers(typingUserList); const isTypingActive = - (threadList && typingInThread.length) || (!threadList && typingInChannel.length); + (isThreadList && typingInThread.length) || (!isThreadList && typingInChannel.length); if (channelConfig?.typing_events === false) { return null; @@ -90,7 +99,3 @@ const UnMemoizedTypingIndicator = (props: TypingIndicatorProps) => {
    ); }; - -export const TypingIndicator = React.memo( - UnMemoizedTypingIndicator, -) as typeof UnMemoizedTypingIndicator; diff --git a/src/components/TypingIndicator/__tests__/TypingIndicator.test.js b/src/components/TypingIndicator/__tests__/TypingIndicator.test.js index 283da34ba3..f2c4ae87fd 100644 --- a/src/components/TypingIndicator/__tests__/TypingIndicator.test.js +++ b/src/components/TypingIndicator/__tests__/TypingIndicator.test.js @@ -6,35 +6,54 @@ import { toHaveNoViolations } from 'jest-axe'; import { axe } from '../../../../axe-helper'; import { TypingIndicator } from '../TypingIndicator'; -import { ChannelStateProvider } from '../../../context/ChannelStateContext'; +import { ChannelInstanceProvider } from '../../../context/ChannelInstanceContext'; import { ChatProvider } from '../../../context/ChatContext'; import { ComponentProvider } from '../../../context/ComponentContext'; -import { TypingProvider } from '../../../context/TypingContext'; +import { ThreadProvider } from '../../Threads/ThreadContext'; import { generateChannel, generateUser, getOrCreateChannelApi, - getTestClientWithUser, + initClientWithChannels, useMockedApis, } from '../../../mock-builders'; expect.extend(toHaveNoViolations); const me = generateUser(); - -async function renderComponent(typing = {}, threadList, value = {}) { - const client = await getTestClientWithUser(me); +const makeThread = (parentMessageId) => + parentMessageId + ? { + state: { + getLatestValue: () => ({ parentMessage: { id: parentMessageId } }), + subscribeWithSelector: () => () => null, + }, + } + : undefined; + +async function renderComponent(typing = {}, value = {}, threadParentId) { + const { + channels: [defaultChannel], + client, + } = await initClientWithChannels(); + const channel = value.channel || defaultChannel; + channel.messageComposer.textComposer.typing = typing; + const channelConfig = value.channelConfig ?? channel.getConfig(); + + client.configsStore.partialNext({ + configs: { [channel.cid]: channelConfig }, + }); return render( - + - - - + + + - + , ); } @@ -42,31 +61,31 @@ async function renderComponent(typing = {}, threadList, value = {}) { describe('TypingIndicator', () => { afterEach(cleanup); - it('should render null without proper context values', () => { - jest.spyOn(console, 'warn').mockImplementationOnce(() => null); - const { container } = render( - - + it('should throw without proper context values', () => { + expect(() => + render( + - - , - ); - expect(container).toBeEmptyDOMElement(); + , + ), + ).toThrow('The useChannel hook could not resolve a channel.'); }); it('should render hidden indicator with empty typing', async () => { - const client = await getTestClientWithUser(me); + const { + channels: [channel], + client, + } = await initClientWithChannels(); + channel.messageComposer.textComposer.typing = {}; const { container } = render( - + - - - + - + , ); @@ -130,22 +149,27 @@ describe('TypingIndicator', () => { }); it('should render null if typing_events is disabled', async () => { - const client = await getTestClientWithUser(); + const { + channels: [defaultChannel], + client, + } = await initClientWithChannels(); + defaultChannel.messageComposer.textComposer.typing = {}; const ch = generateChannel({ config: { typing_events: false } }); useMockedApis(client, [getOrCreateChannelApi(ch)]); const channel = client.channel('messaging', ch.id); const channelConfig = { typing_events: false }; await channel.watch(); + client.configsStore.partialNext({ + configs: { [channel.cid]: channelConfig }, + }); const { container } = render( - + - - - + - + , ); @@ -160,7 +184,8 @@ describe('TypingIndicator', () => { const otherUserId = 'test-user'; beforeEach(async () => { - client = await getTestClientWithUser(); + const setup = await initClientWithChannels(); + client = setup.client; ch = generateChannel({ config: { typing_events: true } }); useMockedApis(client, [getOrCreateChannelApi(ch)]); channel = client.channel('messaging', ch.id); @@ -172,12 +197,11 @@ describe('TypingIndicator', () => { it('should render TypingIndicator if user is typing in thread', async () => { const { container } = await renderComponent( { [otherUserId]: { parent_id, user: otherUserId } }, - true, { channel, client, - thread: { id: parent_id }, }, + parent_id, ); expect(container.firstChild).toHaveClass('str-chat__typing-indicator--typing'); @@ -186,11 +210,9 @@ describe('TypingIndicator', () => { it('should not render TypingIndicator in main channel if user is typing in thread', async () => { const { container } = await renderComponent( { [otherUserId]: { parent_id, user: otherUserId } }, - false, { channel, client, - thread: { id: parent_id }, }, ); @@ -200,12 +222,11 @@ describe('TypingIndicator', () => { it('should not render TypingIndicator in thread if user is typing in main channel', async () => { const { container } = await renderComponent( { [otherUserId]: { user: otherUserId } }, - true, { channel, client, - thread: { id: parent_id }, }, + parent_id, ); expect(container).toBeEmptyDOMElement(); @@ -214,12 +235,11 @@ describe('TypingIndicator', () => { it('should not render TypingIndicator in thread if user is typing in another thread', async () => { const { container } = await renderComponent( { example: { parent_id: 'sample-thread-2', user: otherUserId } }, - true, { channel, client, - thread: { id: parent_id }, }, + parent_id, ); expect(container).toBeEmptyDOMElement(); diff --git a/src/components/Window/Window.tsx b/src/components/Window/Window.tsx index e8b0ffd786..215d22fafa 100644 --- a/src/components/Window/Window.tsx +++ b/src/components/Window/Window.tsx @@ -2,28 +2,10 @@ import type { PropsWithChildren } from 'react'; import React from 'react'; import clsx from 'clsx'; -import type { LocalMessage } from 'stream-chat'; -import { useChannelStateContext } from '../../context/ChannelStateContext'; +const UnMemoizedWindow = (props: PropsWithChildren) => { + const { children } = props; -export type WindowProps = { - /** optional prop to force addition of class str-chat__main-panel---with-thread-opn to the Window root element */ - thread?: LocalMessage; -}; - -const UnMemoizedWindow = (props: PropsWithChildren) => { - const { children, thread: propThread } = props; - - const { thread: contextThread } = useChannelStateContext('Window'); - - return ( -
    - {children} -
    - ); + return
    {children}
    ; }; /** diff --git a/src/components/Window/__tests__/Window.test.js b/src/components/Window/__tests__/Window.test.js index b8c6fb4327..2576d6d1f2 100644 --- a/src/components/Window/__tests__/Window.test.js +++ b/src/components/Window/__tests__/Window.test.js @@ -4,54 +4,11 @@ import '@testing-library/jest-dom'; import { Window } from '../Window'; -import { ChannelStateProvider } from '../../../context/ChannelStateContext'; -import { generateMessage } from '../../../mock-builders'; - -const renderComponent = ({ channelStateContextMock, props }) => - render( - - - , - ); - -const thread = generateMessage(); -const THREAD_OPEN_CLASS_NAME = 'str-chat__main-panel--thread-open'; +const renderComponent = () => render(); describe('Window', () => { - it.each([ - ['add', thread], - ['', undefined], - ])( - 'should %s class str-chat__main-panel--thread-open when thread is open', - (_, thread) => { - const { container } = renderComponent({ - channelStateContextMock: { - thread, - }, - }); - if (thread) { - expect(container.firstChild).toHaveClass(THREAD_OPEN_CLASS_NAME); - } else { - expect(container.firstChild).not.toHaveClass(THREAD_OPEN_CLASS_NAME); - } - }, - ); - - it.each([ - ['add', thread], - ['', undefined], - ])( - 'should %s class str-chat__main-panel--thread-open when thread is passed via prop', - (_, thread) => { - const { container } = renderComponent({ - props: { thread }, - }); - - if (thread) { - expect(container.firstChild).toHaveClass(THREAD_OPEN_CLASS_NAME); - } else { - expect(container.firstChild).not.toHaveClass(THREAD_OPEN_CLASS_NAME); - } - }, - ); + it('renders the base main panel class', () => { + const { container } = renderComponent(); + expect(container.firstChild).toHaveClass('str-chat__main-panel'); + }); }); diff --git a/src/context/ChannelActionContext.tsx b/src/context/ChannelActionContext.tsx deleted file mode 100644 index d53278b4fc..0000000000 --- a/src/context/ChannelActionContext.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import type { PropsWithChildren } from 'react'; -import React, { useContext } from 'react'; - -import type { - DeleteMessageOptions, - LocalMessage, - Message, - MessageResponse, - SendMessageOptions, - UpdateMessageAPIResponse, - UpdateMessageOptions, -} from 'stream-chat'; - -import type { ChannelStateReducerAction } from '../components/Channel/channelState'; -import type { CustomMentionHandler } from '../components/Message/hooks/useMentionsHandler'; - -import type { ChannelUnreadUiState } from '../types/types'; - -export type MarkReadWrapperOptions = { - /** - * Signal, whether the `channelUnreadUiState` should be updated. - * By default, the local state update is prevented when the Channel component is mounted. - * This is in order to keep the UI indicating the original unread state, when the user opens a channel. - */ - updateChannelUiUnreadState?: boolean; -}; - -export type RetrySendMessage = (message: LocalMessage) => Promise; - -export type ChannelActionContextValue = { - addNotification: (text: string, type: 'success' | 'error') => void; - closeThread: (event?: React.BaseSyntheticEvent) => void; - deleteMessage: ( - message: LocalMessage, - options?: DeleteMessageOptions, - ) => Promise; - dispatch: React.Dispatch; - editMessage: ( - message: LocalMessage | MessageResponse, - options?: UpdateMessageOptions, - ) => Promise; - jumpToFirstUnreadMessage: ( - queryMessageLimit?: number, - highlightDuration?: number, - ) => Promise; - jumpToLatestMessage: () => Promise; - jumpToMessage: ( - messageId: string, - limit?: number, - highlightDuration?: number, - ) => Promise; - loadMore: (limit?: number) => Promise; - loadMoreNewer: (limit?: number) => Promise; - loadMoreThread: () => Promise; - markRead: (options?: MarkReadWrapperOptions) => void; - onMentionsClick: CustomMentionHandler; - onMentionsHover: CustomMentionHandler; - openThread: (message: LocalMessage, event?: React.BaseSyntheticEvent) => void; - removeMessage: (message: LocalMessage) => void; - retrySendMessage: RetrySendMessage; - sendMessage: (params: { - localMessage: LocalMessage; - message: Message; - options?: SendMessageOptions; - }) => Promise; - setChannelUnreadUiState: React.Dispatch< - React.SetStateAction - >; - updateMessage: (message: MessageResponse | LocalMessage) => void; -}; - -export const ChannelActionContext = React.createContext< - ChannelActionContextValue | undefined ->(undefined); - -export const ChannelActionProvider = ({ - children, - value, -}: PropsWithChildren<{ - value: ChannelActionContextValue; -}>) => ( - - {children} - -); - -export const useChannelActionContext = (componentName?: string) => { - const contextValue = useContext(ChannelActionContext); - - if (!contextValue) { - console.warn( - `The useChannelActionContext hook was called outside of the ChannelActionContext provider. Make sure this hook is called within a child of the Channel component. The errored call is located in the ${componentName} component.`, - ); - - return {} as ChannelActionContextValue; - } - - return contextValue as unknown as ChannelActionContextValue; -}; diff --git a/src/context/ChannelInstanceContext.tsx b/src/context/ChannelInstanceContext.tsx new file mode 100644 index 0000000000..aa4dd430a6 --- /dev/null +++ b/src/context/ChannelInstanceContext.tsx @@ -0,0 +1,34 @@ +import type { PropsWithChildren } from 'react'; +import React, { useContext } from 'react'; +import type { Channel } from 'stream-chat'; + +export type ChannelInstanceContextValue = { + channel: Channel; +}; + +export const ChannelInstanceContext = React.createContext< + ChannelInstanceContextValue | undefined +>(undefined); + +export const ChannelInstanceProvider = ({ + children, + value, +}: PropsWithChildren<{ + value: ChannelInstanceContextValue; +}>) => ( + + {children} + +); + +export const useChannelInstanceContext = () => { + const contextValue = useContext(ChannelInstanceContext); + + if (!contextValue) { + return {} as ChannelInstanceContextValue; + } + + return contextValue as unknown as ChannelInstanceContextValue; +}; diff --git a/src/context/ChannelStateContext.tsx b/src/context/ChannelStateContext.tsx deleted file mode 100644 index 07c27887fb..0000000000 --- a/src/context/ChannelStateContext.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import type { PropsWithChildren } from 'react'; -import React, { useContext } from 'react'; -import type { - Channel, - ChannelConfigWithInfo, - GiphyVersions, - LocalMessage, - Mute, - ChannelState as StreamChannelState, -} from 'stream-chat'; - -import type { - ChannelUnreadUiState, - ImageAttachmentSizeHandler, - VideoAttachmentSizeHandler, -} from '../types/types'; - -export type ChannelNotifications = Array<{ - id: string; - text: string; - type: 'success' | 'error'; -}>; - -export type ChannelState = { - suppressAutoscroll: boolean; - error?: Error | null; - hasMore?: boolean; - hasMoreNewer?: boolean; - highlightedMessageId?: string; - loading?: boolean; - loadingMore?: boolean; - loadingMoreNewer?: boolean; - members?: StreamChannelState['members']; - messages?: LocalMessage[]; - pinnedMessages?: LocalMessage[]; - read?: StreamChannelState['read']; - thread?: LocalMessage | null; - threadHasMore?: boolean; - threadLoadingMore?: boolean; - threadMessages?: LocalMessage[]; - threadSuppressAutoscroll?: boolean; - typing?: StreamChannelState['typing']; - watcherCount?: number; - watchers?: StreamChannelState['watchers']; -}; - -export type ChannelStateContextValue = Omit & { - channel: Channel; - channelCapabilities: Record; - channelConfig: ChannelConfigWithInfo | undefined; - imageAttachmentSizeHandler: ImageAttachmentSizeHandler; - notifications: ChannelNotifications; - shouldGenerateVideoThumbnail: boolean; - videoAttachmentSizeHandler: VideoAttachmentSizeHandler; - channelUnreadUiState?: ChannelUnreadUiState; - giphyVersion?: GiphyVersions; - mutes?: Array; - watcher_count?: number; -}; - -export const ChannelStateContext = React.createContext< - ChannelStateContextValue | undefined ->(undefined); - -export const ChannelStateProvider = ({ - children, - value, -}: PropsWithChildren<{ - value: ChannelStateContextValue; -}>) => ( - - {children} - -); - -export const useChannelStateContext = (componentName?: string) => { - const contextValue = useContext(ChannelStateContext); - - if (!contextValue) { - console.warn( - `The useChannelStateContext hook was called outside of the ChannelStateContext provider. Make sure this hook is called within a child of the Channel component. The errored call is located in the ${componentName} component.`, - ); - - return {} as ChannelStateContextValue; - } - - return contextValue as unknown as ChannelStateContextValue; -}; diff --git a/src/context/ChatContext.tsx b/src/context/ChatContext.tsx index fbd7f74c7a..1ad4d181ae 100644 --- a/src/context/ChatContext.tsx +++ b/src/context/ChatContext.tsx @@ -1,11 +1,6 @@ import React, { useContext } from 'react'; import type { PropsWithChildren } from 'react'; -import type { - AppSettingsAPIResponse, - Channel, - Mute, - SearchController, -} from 'stream-chat'; +import type { AppSettingsAPIResponse, SearchController } from 'stream-chat'; import type { ChatProps } from '../components/Chat/Chat'; import type { ChannelsQueryState } from '../components/Chat/hooks/useChannelsQueryState'; @@ -31,29 +26,12 @@ export type ChatContextValue = { * Indicates, whether a channels query has been triggered within ChannelList by its channels pagination controller. */ channelsQueryState: ChannelsQueryState; - closeMobileNav: () => void; getAppSettings: () => Promise | null; latestMessageDatesByChannels: Record; - mutes: Array; openMobileNav: () => void; /** Instance of SearchController class that allows to control all the search operations. */ searchController: SearchController; - /** - * Sets active channel to be rendered within Channel component. - * @param newChannel - * @param watchers - * @param event - */ - setActiveChannel: ( - newChannel?: Channel, - watchers?: { limit?: number; offset?: number }, - event?: React.BaseSyntheticEvent, - ) => void; useImageFlagEmojisOnWindows: boolean; - /** - * Active channel used to render the contents of the Channel component. - */ - channel?: Channel; /** * Object through which custom classes can be set for main container components of the SDK. */ diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index 6b765a72dc..ec09648711 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -49,7 +49,6 @@ import { type ThreadListItemUIProps, type TimestampProps, type TranslationIndicatorProps, - type TypingIndicatorProps, type UnreadMessagesNotificationProps, type UnreadMessagesSeparatorProps, type VoiceRecordingPreviewSlotProps, @@ -240,7 +239,7 @@ export type ComponentContextValue = { /** Custom UI component to display a date used in timestamps. It's used internally by the default `MessageTimestamp`, and to display a timestamp for edited messages. */ Timestamp?: React.ComponentType; /** Custom UI component for the typing indicator, defaults to and accepts same props as: [TypingIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/TypingIndicator/TypingIndicator.tsx) */ - TypingIndicator?: React.ComponentType; + TypingIndicator?: React.ComponentType; /** Custom UI component that indicates a user is viewing unread messages. It disappears once the user scrolls to UnreadMessagesSeparator. Defaults to and accepts same props as: [UnreadMessagesNotification](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/UnreadMessagesNotification.tsx) */ UnreadMessagesNotification?: React.ComponentType; /** Custom UI component that separates read messages from unread, defaults to and accepts same props as: [UnreadMessagesSeparator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/MessageList/UnreadMessagesSeparator.tsx) */ diff --git a/src/context/MessageBounceContext.tsx b/src/context/MessageBounceContext.tsx index d85ca9d06b..85a64ca232 100644 --- a/src/context/MessageBounceContext.tsx +++ b/src/context/MessageBounceContext.tsx @@ -1,8 +1,8 @@ import type { ReactEventHandler } from 'react'; import React, { createContext, useCallback, useContext, useMemo } from 'react'; import { useMessageContext } from './MessageContext'; -import { useChannelActionContext } from './ChannelActionContext'; -import { isMessageBounced, useMessageComposer } from '../components'; +import { useChannel } from './useChannel'; +import { isMessageBounced, useMessageComposer, useRetryHandler } from '../components'; import type { LocalMessage } from 'stream-chat'; import type { PropsWithChildrenOnly } from '../types/types'; @@ -33,9 +33,8 @@ export function useMessageBounceContext(componentName?: string) { export function MessageBounceProvider({ children }: PropsWithChildrenOnly) { const messageComposer = useMessageComposer(); - const { handleRetry: doHandleRetry, message } = useMessageContext( - 'MessageBounceProvider', - ); + const { message } = useMessageContext('MessageBounceProvider'); + const doHandleRetry = useRetryHandler(); if (!isMessageBounced(message)) { console.warn( @@ -43,11 +42,11 @@ export function MessageBounceProvider({ children }: PropsWithChildrenOnly) { ); } - const { removeMessage } = useChannelActionContext('MessageBounceProvider'); + const channel = useChannel(); const handleDelete: ReactEventHandler = useCallback(() => { - removeMessage(message); - }, [message, removeMessage]); + channel.state.removeMessage(message); + }, [channel, message]); const handleEdit: ReactEventHandler = useCallback( (e) => { @@ -57,8 +56,8 @@ export function MessageBounceProvider({ children }: PropsWithChildrenOnly) { [message, messageComposer], ); - const handleRetry = useCallback(() => { - doHandleRetry(message); + const handleRetry: ReactEventHandler = useCallback(() => { + void doHandleRetry({ localMessage: message }); }, [doHandleRetry, message]); const value = useMemo( diff --git a/src/context/MessageContext.tsx b/src/context/MessageContext.tsx index b795e8ae38..c4d5fe4ec9 100644 --- a/src/context/MessageContext.tsx +++ b/src/context/MessageContext.tsx @@ -4,16 +4,12 @@ import React, { useContext } from 'react'; import type { DeleteMessageOptions, LocalMessage, - Mute, ReactionResponse, ReactionSort, UserResponse, } from 'stream-chat'; -import type { ChannelActionContextValue } from './ChannelActionContext'; - import type { ActionHandlerReturnType } from '../components/Message/hooks/useActionHandler'; -import type { PinPermissions } from '../components/Message/hooks/usePinHandler'; import type { ReactEventHandler } from '../components/Message/types'; import type { MessageActionsArray } from '../components/Message/utils'; import type { GroupStyle } from '../components/MessageList/utils'; @@ -48,8 +44,6 @@ export type MessageContextValue = { handleMarkUnread: ReactEventHandler; /** Function to mute a user in a Channel */ handleMute: ReactEventHandler; - /** Function to open a Thread on a Message */ - handleOpenThread: ReactEventHandler; /** Function to pin a Message in a Channel */ handlePin: ReactEventHandler; /** Function to post a reaction on a Message */ @@ -57,14 +51,10 @@ export type MessageContextValue = { reactionType: string, event: React.BaseSyntheticEvent, ) => Promise; - /** Function to retry sending a Message */ - handleRetry: ChannelActionContextValue['retrySendMessage']; /** Function that returns whether the Message belongs to the current user */ isMyMessage: () => boolean; /** The message object */ message: LocalMessage; - /** Indicates whether a message has not been read yet or has been marked unread */ - messageIsUnread: boolean; /** Handler function for a click event on an @mention in Message */ onMentionsClickMessage: ReactEventHandler; /** Handler function for a hover event on an @mention in Message */ @@ -103,10 +93,6 @@ export type MessageContextValue = { lastReceivedId?: string | null; /** DOMRect object for parent MessageList component */ messageListRect?: DOMRect; - /** Array of muted users coming from [ChannelStateContext](https://getstream.io/chat/docs/sdk/react/contexts/channel_state_context/#mutes) */ - mutes?: Mute[]; - /** @deprecated in favor of `channelCapabilities - The user roles allowed to pin Messages in various channel types */ - pinPermissions?: PinPermissions; /** Sort options to provide to a reactions query */ reactionDetailsSort?: ReactionSort; /** A list of users that have read this Message */ @@ -127,8 +113,6 @@ export type MessageContextValue = { sortReactionDetails?: ReactionDetailsComparator; /** Comparator function to sort reactions, defaults to chronological order */ sortReactions?: ReactionsComparator; - /** Whether or not the Message is in a Thread */ - threadList?: boolean; /** render HTML instead of markdown. Posting HTML is only allowed server-side */ unsafeHTML?: boolean; /** diff --git a/src/context/MessageListContext.tsx b/src/context/MessageListContext.tsx index 3d5048e407..e6e9faa7c8 100644 --- a/src/context/MessageListContext.tsx +++ b/src/context/MessageListContext.tsx @@ -6,7 +6,7 @@ export type MessageListContextValue = { /** Enriched message list, including date separators and intro message (if enabled) */ processedMessages: RenderedMessage[]; /** The scroll container within which the messages and typing indicator are rendered */ - listElement: HTMLDivElement | null; + listElement: HTMLElement | null; /** Function that scrolls the `listElement` to the bottom. */ scrollToBottom: () => void; }; diff --git a/src/context/TypingContext.tsx b/src/context/TypingContext.tsx deleted file mode 100644 index cb518a3eda..0000000000 --- a/src/context/TypingContext.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React, { useContext } from 'react'; -import type { PropsWithChildren } from 'react'; - -import type { ChannelState as StreamChannelState } from 'stream-chat'; - -export type TypingContextValue = { - typing?: StreamChannelState['typing']; -}; - -export const TypingContext = React.createContext( - undefined, -); - -export const TypingProvider = ({ - children, - value, -}: PropsWithChildren<{ - value: TypingContextValue; -}>) => ( - - {children} - -); - -export const useTypingContext = (componentName?: string) => { - const contextValue = useContext(TypingContext); - - if (!contextValue) { - console.warn( - `The useTypingContext hook was called outside of the TypingContext provider. Make sure this hook is called within a child of the Channel component. The errored call is located in the ${componentName} component.`, - ); - - return {} as TypingContextValue; - } - - return contextValue as TypingContextValue; -}; diff --git a/src/context/index.ts b/src/context/index.ts index 2277cdbab9..8205e46f6d 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -1,6 +1,5 @@ -export * from './ChannelActionContext'; export * from './ChannelListContext'; -export * from './ChannelStateContext'; +export * from './ChannelInstanceContext'; export * from './ChatContext'; export * from './ComponentContext'; export * from './DialogManagerContext'; @@ -11,5 +10,5 @@ export * from './MessageListContext'; export * from './MessageTranslationViewContext'; export * from './PollContext'; export * from './TranslationContext'; -export * from './TypingContext'; +export * from './useChannel'; export * from './WithComponents'; diff --git a/src/context/useChannel.ts b/src/context/useChannel.ts new file mode 100644 index 0000000000..67ea49a004 --- /dev/null +++ b/src/context/useChannel.ts @@ -0,0 +1,20 @@ +import { useThreadContext } from '../components/Threads'; +import { useChannelInstanceContext } from './ChannelInstanceContext'; + +import type { Channel } from 'stream-chat'; + +export const useChannel = (): Channel => { + const thread = useThreadContext(); + const channelFromThread = thread?.channel; + const { channel: channelFromInstanceContext } = useChannelInstanceContext(); + const channel = channelFromThread ?? channelFromInstanceContext; + + if (!channel) { + console.warn( + 'The useChannel hook could not resolve a channel. Make sure this hook is called within a Thread subtree or within a child of Channel (ChannelInstanceContext provider).', + ); + throw new Error('The useChannel hook could not resolve a channel.'); + } + + return channel; +}; diff --git a/src/experimental/Search/SearchResults/SearchResultItem.tsx b/src/experimental/Search/SearchResults/SearchResultItem.tsx index 86ff7dddbe..fe3bb0b752 100644 --- a/src/experimental/Search/SearchResults/SearchResultItem.tsx +++ b/src/experimental/Search/SearchResults/SearchResultItem.tsx @@ -5,6 +5,8 @@ import type { Channel, MessageResponse, User } from 'stream-chat'; import { useSearchContext } from '../SearchContext'; import { Avatar } from '../../../components/Avatar'; +import { useSlotChannel } from '../../../components/ChatView'; +import { useChatViewNavigation } from '../../../components/ChatView/ChatViewNavigationContext'; import { ChannelPreview } from '../../../components/ChannelPreview'; import { useChannelListContext, useChatContext } from '../../../context'; import { DEFAULT_JUMP_TO_PAGE_SIZE } from '../../../constants/limits'; @@ -14,13 +16,13 @@ export type ChannelSearchResultItemProps = { }; export const ChannelSearchResultItem = ({ item }: ChannelSearchResultItemProps) => { - const { setActiveChannel } = useChatContext(); + const { openChannel } = useChatViewNavigation(); const { setChannels } = useChannelListContext(); const onSelect = useCallback(() => { - setActiveChannel(item); + openChannel(item); setChannels?.((channels) => uniqBy([item, ...channels], 'cid')); - }, [item, setActiveChannel, setChannels]); + }, [item, openChannel, setChannels]); return ( { - const { - channel: activeChannel, - client, - searchController, - setActiveChannel, - } = useChatContext(); + const { client, searchController } = useChatContext(); + const { openChannel } = useChatViewNavigation(); const { setChannels } = useChannelListContext(); + const activeChannel = useSlotChannel(); const channel = useMemo(() => { const { channel: channelData } = item; @@ -62,9 +61,9 @@ export const MessageSearchResultItem = ({ ); // FIXME: message focus should be handled by yet non-existent msg list controller in client packaged searchController._internalState.partialNext({ focusedMessage: item }); - setActiveChannel(channel); + openChannel(channel); setChannels?.((channels) => uniqBy([channel, ...channels], 'cid')); - }, [channel, item, searchController, setActiveChannel, setChannels]); + }, [channel, item, openChannel, searchController, setChannels]); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const getLatestMessagePreview = useCallback(() => item.text!, [item]); @@ -90,7 +89,8 @@ export type UserSearchResultItemProps = { }; export const UserSearchResultItem = ({ item }: UserSearchResultItemProps) => { - const { client, setActiveChannel } = useChatContext(); + const { client } = useChatContext(); + const { openChannel } = useChatViewNavigation(); const { setChannels } = useChannelListContext(); const { directMessagingChannelType } = useSearchContext(); @@ -99,9 +99,9 @@ export const UserSearchResultItem = ({ item }: UserSearchResultItemProps) => { members: [client.userID as string, item.id], }); newChannel.watch(); - setActiveChannel(newChannel); + openChannel(newChannel); setChannels?.((channels) => uniqBy([newChannel, ...channels], 'cid')); - }, [client, item, setActiveChannel, setChannels, directMessagingChannelType]); + }, [client, item, openChannel, setChannels, directMessagingChannelType]); return (